Skip to main content

max / audiofiles

Describe the settings panel behind an off-by-default feature audiofiles' first screen through quasi, and the first anywhere in an egui app. The shipped panel in ui::settings_panel is untouched; with the feature off none of this compiles at all, which is goingson's 7c8e6be2 arrangement and for the same reason. Four of the nine sections are describable: Appearance, Preview, Forge and Display. The other five are not, and the cause is the one goingson's settings port already found rather than a gap in the vocabulary: a handler is fn(&S, Request), so a section whose subject is the host rather than the app's own data does not come through. Storage and Trash are about files on a disk; License is a key exchanged with a server; Classifier is bespoke. Advanced is the interesting one and it confirms a filed finding rather than adding one: a control that asks the host where to put something and then acts has no vocabulary. FieldKind::File covers picking a file to submit and nothing covers open-a-save-dialog-then-write, which is Export Theme. Second consumer. What this port adds is the second consumer of "a set of choices cannot be grouped": the theme picker groups by variant and sorts by contrast tier within each group, and Choice is a value and a label, so the variant rides in the label and the structure is lost. The test asserts that, so when grouping arrives the assertion is what has to change. One route serves every control, because ConfigKey already closes the key set and from_key already refuses an undeclared one. The state is a two-method Config trait rather than &dyn Backend: a described screen that borrowed the whole backend would say in its own type that it may do anything the app can, and a fixture would have to implement vfs, tags and search to test a checkbox.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 21:18 UTC
Signed with PGP, not checked
Commit: 494a5bd8b2023f358d3eff880cd48ac59a18498e
Parent: 50d60c5
7 files changed, +946 insertions, -8 deletions
M Cargo.lock +230 -8
@@ -83,6 +83,18 @@
83 83 "pkg-config",
84 84 ]
85 85
86 + [[package]]
87 + name = "ammonia"
88 + version = "4.1.4"
89 + source = "registry+https://github.com/rust-lang/crates.io-index"
90 + checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
91 + dependencies = [
92 + "cssparser",
93 + "html5ever",
94 + "maplit",
95 + "url",
96 + ]
97 +
86 98 [[package]]
87 99 name = "android-activity"
88 100 version = "0.6.1"
@@ -443,6 +455,8 @@
443 455 "objc2-foundation 0.3.2",
444 456 "parking_lot",
445 457 "pollster 1.0.1",
458 + "quasi-immediate",
459 + "quasi-router",
446 460 "rayon",
447 461 "rfd",
448 462 "rusqlite",
@@ -1156,6 +1170,17 @@
1156 1170 "rand_core 0.10.1",
1157 1171 ]
1158 1172
1173 + [[package]]
1174 + name = "cssparser"
1175 + version = "0.37.0"
1176 + source = "registry+https://github.com/rust-lang/crates.io-index"
1177 + checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
1178 + dependencies = [
1179 + "dtoa-short",
1180 + "itoa",
1181 + "smallvec",
1182 + ]
1183 +
1159 1184 [[package]]
1160 1185 name = "ctutils"
1161 1186 version = "0.4.2"
@@ -1294,6 +1319,15 @@
1294 1319 "windows-sys 0.61.2",
1295 1320 ]
1296 1321
1322 + [[package]]
1323 + name = "docengine"
1324 + version = "0.7.0"
1325 + dependencies = [
1326 + "ammonia",
1327 + "pulldown-cmark",
1328 + "serde",
1329 + ]
1330 +
1297 1331 [[package]]
1298 1332 name = "document-features"
1299 1333 version = "0.2.12"
@@ -1315,6 +1349,21 @@
1315 1349 source = "registry+https://github.com/rust-lang/crates.io-index"
1316 1350 checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
1317 1351
1352 + [[package]]
1353 + name = "dtoa"
1354 + version = "1.0.11"
1355 + source = "registry+https://github.com/rust-lang/crates.io-index"
1356 + checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1357 +
1358 + [[package]]
1359 + name = "dtoa-short"
1360 + version = "0.3.5"
1361 + source = "registry+https://github.com/rust-lang/crates.io-index"
1362 + checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1363 + dependencies = [
1364 + "dtoa",
1365 + ]
1366 +
1318 1367 [[package]]
1319 1368 name = "ecolor"
1320 1369 version = "0.35.0"
@@ -1898,6 +1947,15 @@
1898 1947 "windows-link",
1899 1948 ]
1900 1949
1950 + [[package]]
1951 + name = "getopts"
1952 + version = "0.2.24"
1953 + source = "registry+https://github.com/rust-lang/crates.io-index"
1954 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
1955 + dependencies = [
1956 + "unicode-width",
1957 + ]
1958 +
1901 1959 [[package]]
1902 1960 name = "getrandom"
1903 1961 version = "0.2.17"
@@ -2319,6 +2377,16 @@
2319 2377 source = "registry+https://github.com/rust-lang/crates.io-index"
2320 2378 checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
2321 2379
2380 + [[package]]
2381 + name = "html5ever"
2382 + version = "0.39.0"
2383 + source = "registry+https://github.com/rust-lang/crates.io-index"
2384 + checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
2385 + dependencies = [
2386 + "log",
2387 + "markup5ever",
2388 + ]
2389 +
2322 2390 [[package]]
2323 2391 name = "http"
2324 2392 version = "1.5.0"
@@ -3017,6 +3085,23 @@
3017 3085 source = "registry+https://github.com/rust-lang/crates.io-index"
3018 3086 checksum = "6752c263c93eb808ac878e54887257792e487d1713e98e2dcbac68d31363c9f9"
3019 3087
3088 + [[package]]
3089 + name = "maplit"
3090 + version = "1.0.2"
3091 + source = "registry+https://github.com/rust-lang/crates.io-index"
3092 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
3093 +
3094 + [[package]]
3095 + name = "markup5ever"
3096 + version = "0.39.0"
3097 + source = "registry+https://github.com/rust-lang/crates.io-index"
3098 + checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
3099 + dependencies = [
3100 + "log",
3101 + "tendril",
3102 + "web_atoms",
3103 + ]
3104 +
3020 3105 [[package]]
3021 3106 name = "matchers"
3022 3107 version = "0.2.0"
@@ -3184,6 +3269,12 @@
3184 3269 "jni-sys 0.3.1",
3185 3270 ]
3186 3271
3272 + [[package]]
3273 + name = "new_debug_unreachable"
3274 + version = "1.0.6"
3275 + source = "registry+https://github.com/rust-lang/crates.io-index"
3276 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
3277 +
3187 3278 [[package]]
3188 3279 name = "no-std-compat"
3189 3280 version = "0.4.1"
@@ -3861,6 +3952,45 @@
3861 3952 source = "registry+https://github.com/rust-lang/crates.io-index"
3862 3953 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
3863 3954
3955 + [[package]]
3956 + name = "phf"
3957 + version = "0.13.1"
3958 + source = "registry+https://github.com/rust-lang/crates.io-index"
3959 + checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
3960 + dependencies = [
3961 + "phf_shared",
3962 + "serde",
3963 + ]
3964 +
3965 + [[package]]
3966 + name = "phf_codegen"
3967 + version = "0.13.1"
3968 + source = "registry+https://github.com/rust-lang/crates.io-index"
3969 + checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
3970 + dependencies = [
3971 + "phf_generator",
3972 + "phf_shared",
3973 + ]
3974 +
3975 + [[package]]
3976 + name = "phf_generator"
3977 + version = "0.13.1"
3978 + source = "registry+https://github.com/rust-lang/crates.io-index"
3979 + checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
3980 + dependencies = [
3981 + "fastrand",
3982 + "phf_shared",
3983 + ]
3984 +
3985 + [[package]]
3986 + name = "phf_shared"
3987 + version = "0.13.1"
3988 + source = "registry+https://github.com/rust-lang/crates.io-index"
3989 + checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
3990 + dependencies = [
3991 + "siphasher",
3992 + ]
3993 +
3864 3994 [[package]]
3865 3995 name = "pin-project"
3866 3996 version = "1.1.13"
@@ -3992,6 +4122,12 @@
3992 4122 "zerovec",
3993 4123 ]
3994 4124
4125 + [[package]]
4126 + name = "precomputed-hash"
4127 + version = "0.1.1"
4128 + source = "registry+https://github.com/rust-lang/crates.io-index"
4129 + checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
4130 +
3995 4131 [[package]]
3996 4132 name = "primal-check"
3997 4133 version = "0.3.4"
@@ -4079,12 +4215,49 @@
4079 4215 source = "registry+https://github.com/rust-lang/crates.io-index"
4080 4216 checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
4081 4217
4218 + [[package]]
4219 + name = "pulldown-cmark"
4220 + version = "0.13.4"
4221 + source = "registry+https://github.com/rust-lang/crates.io-index"
4222 + checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
4223 + dependencies = [
4224 + "bitflags 2.13.1",
4225 + "getopts",
4226 + "memchr",
4227 + "pulldown-cmark-escape",
4228 + "unicase",
4229 + ]
4230 +
4231 + [[package]]
4232 + name = "pulldown-cmark-escape"
4233 + version = "0.11.0"
4234 + source = "registry+https://github.com/rust-lang/crates.io-index"
4235 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
4236 +
4082 4237 [[package]]
4083 4238 name = "pxfm"
4084 4239 version = "0.1.30"
4085 4240 source = "registry+https://github.com/rust-lang/crates.io-index"
4086 4241 checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
4087 4242
4243 + [[package]]
4244 + name = "quasi-immediate"
4245 + version = "0.1.0"
4246 + source = "git+https://makenot.work/git/max/quasi.git#34f5145445f0351eb78984f97030918594bacf16"
4247 + dependencies = [
4248 + "docengine",
4249 + "egui",
4250 + "makeover-immediate",
4251 + "quasi-router",
4252 + ]
4253 +
4254 + [[package]]
4255 + name = "quasi-router"
4256 + version = "0.2.0"
4257 + dependencies = [
4258 + "makeover-layout",
4259 + ]
4260 +
4088 4261 [[package]]
4089 4262 name = "quick-error"
4090 4263 version = "2.0.1"
@@ -4806,6 +4979,12 @@
4806 4979 source = "registry+https://github.com/rust-lang/crates.io-index"
4807 4980 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
4808 4981
4982 + [[package]]
4983 + name = "siphasher"
4984 + version = "1.0.3"
4985 + source = "registry+https://github.com/rust-lang/crates.io-index"
4986 + checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
4987 +
4809 4988 [[package]]
4810 4989 name = "skrifa"
4811 4990 version = "0.42.1"
@@ -4980,6 +5159,30 @@
4980 5159 source = "registry+https://github.com/rust-lang/crates.io-index"
4981 5160 checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
4982 5161
5162 + [[package]]
5163 + name = "string_cache"
5164 + version = "0.9.0"
5165 + source = "registry+https://github.com/rust-lang/crates.io-index"
5166 + checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
5167 + dependencies = [
5168 + "new_debug_unreachable",
5169 + "parking_lot",
5170 + "phf_shared",
5171 + "precomputed-hash",
5172 + ]
5173 +
5174 + [[package]]
5175 + name = "string_cache_codegen"
5176 + version = "0.6.1"
5177 + source = "registry+https://github.com/rust-lang/crates.io-index"
5178 + checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
5179 + dependencies = [
5180 + "phf_generator",
5181 + "phf_shared",
5182 + "proc-macro2",
5183 + "quote",
5184 + ]
5185 +
4983 5186 [[package]]
4984 5187 name = "subtle"
4985 5188 version = "2.6.1"
@@ -5503,6 +5706,15 @@
5503 5706 "windows-sys 0.61.2",
5504 5707 ]
5505 5708
5709 + [[package]]
5710 + name = "tendril"
5711 + version = "0.5.1"
5712 + source = "registry+https://github.com/rust-lang/crates.io-index"
5713 + checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
5714 + dependencies = [
5715 + "new_debug_unreachable",
5716 + ]
5717 +
5506 5718 [[package]]
5507 5719 name = "thin-vec"
5508 5720 version = "0.2.19"
@@ -5933,6 +6145,12 @@
5933 6145 "windows-sys 0.61.2",
5934 6146 ]
5935 6147
6148 + [[package]]
6149 + name = "unicase"
6150 + version = "2.9.0"
6151 + source = "registry+https://github.com/rust-lang/crates.io-index"
6152 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
6153 +
5936 6154 [[package]]
5937 6155 name = "unicode-general-category"
5938 6156 version = "1.1.0"
@@ -6338,6 +6556,18 @@
6338 6556 "wasm-bindgen",
6339 6557 ]
6340 6558
6559 + [[package]]
6560 + name = "web_atoms"
6561 + version = "0.2.6"
6562 + source = "registry+https://github.com/rust-lang/crates.io-index"
6563 + checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
6564 + dependencies = [
6565 + "phf",
6566 + "phf_codegen",
6567 + "string_cache",
6568 + "string_cache_codegen",
6569 + ]
6570 +
6341 6571 [[package]]
6342 6572 name = "webbrowser"
6343 6573 version = "1.2.4"
@@ -7320,10 +7550,6 @@
7320 7550 name = "quasi-http"
7321 7551 version = "0.2.0"
7322 7552
7323 - [[patch.unused]]
7324 - name = "quasi-router"
7325 - version = "0.2.0"
7326 -
7327 7553 [[patch.unused]]
7328 7554 name = "quasi-store"
7329 7555 version = "0.1.0"
@@ -7336,10 +7562,6 @@
7336 7562 name = "quasi-webview"
7337 7563 version = "0.2.0"
7338 7564
7339 - [[patch.unused]]
7340 - name = "docengine"
7341 - version = "0.7.0"
7342 -
7343 7565 [[patch.unused]]
7344 7566 name = "kberg"
7345 7567 version = "0.1.0"
M Cargo.toml +4
@@ -19,6 +19,10 @@
19 19 # the `Column` the renderer matches on have to be the same type.
20 20 makeover-layout = "0.19.0"
21 21 makeover-immediate = "0.18.0"
22 + # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 + # with a version requirement, per the tree's rule for cross-repo deps.
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.2" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.1" }
22 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
23 27 egui_extras = { version = "0.35", default-features = false }
24 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -7,10 +7,16 @@
7 7 [features]
8 8 default = ["device-profiles"]
9 9 device-profiles = ["dep:audiofiles-rhai", "dep:rayon"]
10 + # The described screens, off by default. On, `crate::quasi` compiles and the
11 + # shipped egui panels in `crate::ui` are untouched; off, none of it is built.
12 + # goingson's `7c8e6be2` arrangement, for the same reason.
13 + quasi = ["dep:quasi-router", "dep:quasi-immediate"]
10 14
11 15 [dependencies]
12 16 audiofiles-core = { workspace = true }
13 17 audiofiles-rhai = { workspace = true, optional = true }
18 + quasi-router = { workspace = true, optional = true }
19 + quasi-immediate = { workspace = true, optional = true }
14 20 audiofiles-sync = { workspace = true }
15 21 egui = { workspace = true }
16 22 makeover-layout = { workspace = true }
@@ -18,6 +18,10 @@
18 18 pub mod preview;
19 19 pub mod state;
20 20 pub mod ui;
21 +
22 + /// The described screens, behind the off-by-default `quasi` feature.
23 + #[cfg(feature = "quasi")]
24 + pub mod quasi;
21 25 pub mod waveform;
22 26
23 27 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
@@ -1,0 +1,121 @@
1 + //! audiofiles' screens, described.
2 + //!
3 + //! Behind the off-by-default `quasi` feature, so the shipped egui panels in
4 + //! [`crate::ui`] are exactly what they were while the described versions are
5 + //! proved beside them. That is goingson's arrangement, and the reason is the
6 + //! same: a port that replaces a working screen on the way in has to be right
7 + //! first time, and a port that sits beside one can be wrong cheaply.
8 + //!
9 + //! # What the router's state is, and why it is this small
10 + //!
11 + //! A handler is `fn(&S, Request)`: sync, holding only what the app put in `S`.
12 + //! [`Settings`] is therefore the *narrowest* thing the described screens need
13 + //! rather than the whole of [`BrowserState`](crate::state::BrowserState), and
14 + //! that turns out to be two things:
15 + //!
16 + //! - **The backend**, whose `get_config` and `set_config` already take `&self`.
17 + //! Every control on the settings screen writes a `user_config` key, so this is
18 + //! the whole of what the screen does.
19 + //! - **The themes**, resolved once by the host. This is the settled rule from
20 + //! goingson's settings port applied first time out: *a host fact readable at
21 + //! startup goes in `S`*, resolved where the app still has a handle to ask. The
22 + //! alternative — a capability surface on quasi — was refused on 2026-08-09 and
23 + //! nothing here reopens it.
24 + //!
25 + //! Notably absent is anything `&mut`. The described screens read and write
26 + //! through the backend and touch no in-memory UI state, which is what makes the
27 + //! feature safe to leave off: with it off, nothing here is compiled at all.
28 +
29 + // Handlers take their request by value because `quasi_router::Handler` is a
30 + // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
31 + // a choice made here.
32 + #![allow(clippy::needless_pass_by_value)]
33 +
34 + pub mod settings;
35 +
36 + use audiofiles_core::config_key::ConfigKey;
37 + use quasi_router::Router;
38 +
39 + use crate::backend::Backend;
40 +
41 + /// The config store, as much of it as a described screen needs.
42 + ///
43 + /// Two methods against `Backend`'s several dozen, and the narrowing is the
44 + /// point rather than tidiness. Three things fall out of it:
45 + ///
46 + /// - **`Settings` is honestly the narrowest thing the screens need.** Borrowing
47 + /// `&dyn Backend` would have said "this screen may do anything the app can do"
48 + /// in its own type, which is exactly what a description layer is for not
49 + /// saying.
50 + /// - **The screens are testable with no app.** A fixture implements two methods
51 + /// rather than a trait tree covering vfs, tags, search and the rest. That is
52 + /// what makes the tests below run without a `BrowserState` or a window.
53 + /// - **The error stops being the backend's.** A route answers `RouteError`, so
54 + /// the store's failure is flattened to a string here and classified there.
55 + ///
56 + /// Blanket-implemented for every `Backend`, so the app hands over the handle it
57 + /// already has and nothing is wired twice.
58 + pub trait Config {
59 + /// What is stored under this key, if anything is.
60 + ///
61 + /// # Errors
62 + /// Whatever the store said, as text.
63 + fn get(&self, key: ConfigKey) -> Result<Option<String>, String>;
64 +
65 + /// Store this value under this key.
66 + ///
67 + /// # Errors
68 + /// Whatever the store said, as text.
69 + fn set(&self, key: ConfigKey, value: &str) -> Result<(), String>;
70 + }
71 +
72 + impl<T: Backend + ?Sized> Config for T {
73 + fn get(&self, key: ConfigKey) -> Result<Option<String>, String> {
74 + self.get_config(key).map_err(|error| error.to_string())
75 + }
76 +
77 + fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> {
78 + self.set_config(key, value)
79 + .map_err(|error| error.to_string())
80 + }
81 + }
82 +
83 + /// A theme the host resolved, as the description needs to name it.
84 + ///
85 + /// Three strings rather than the app's own `ThemeMeta`, so the described screen
86 + /// does not depend on the shape of the theme loader: what a `Choice` needs is a
87 + /// value and something to show, and the variant is what the grouping finding is
88 + /// about.
89 + #[derive(Debug, Clone, PartialEq, Eq)]
90 + pub struct ThemeChoice {
91 + /// The id stored under `ConfigKey::Theme`.
92 + pub id: String,
93 + /// What the picker shows.
94 + pub name: String,
95 + /// `dark`, `light` or `high-contrast`.
96 + pub variant: String,
97 + }
98 +
99 + /// What the described screens read and write.
100 + ///
101 + /// Borrowed rather than owned, which is what lets the app hand over the backend
102 + /// it already has without an `Arc` or a second handle. `Router<S>` puts no
103 + /// bounds on `S`, so a state that borrows is as valid as one that owns.
104 + pub struct Settings<'a> {
105 + /// The config store, and nothing else about the app.
106 + pub config: &'a dyn Config,
107 + /// The themes on offer, resolved by the host at startup.
108 + pub themes: &'a [ThemeChoice],
109 + }
110 +
111 + /// Every described screen this app serves.
112 + ///
113 + /// Built per call rather than once: it is a `Vec` of function pointers, so the
114 + /// cost is nothing, and building it fresh is what lets the state borrow.
115 + #[must_use]
116 + pub fn router<'a>() -> Router<Settings<'a>> {
117 + settings::routes(Router::new())
118 + }
119 +
120 + #[cfg(test)]
121 + mod tests;
@@ -1,0 +1,303 @@
1 + //! The settings panel, described rather than built.
2 + //!
3 + //! The first audiofiles screen to go through `quasi`, behind an off-by-default
4 + //! feature so the shipped panel in `ui::settings_panel` stays exactly as it is
5 + //! while this one is proved. Same arrangement goingson's port programme uses.
6 + //!
7 + //! # What is describable here, and what is not
8 + //!
9 + //! The panel has nine sections and **four of them are describable**. That ratio
10 + //! is not a disappointment; it is the same result goingson's settings port got
11 + //! (five of eight not describable) and for the same cause, which is worth
12 + //! stating precisely because it is easy to misread as a gap in the vocabulary:
13 + //!
14 + //! **A handler is `fn(&S, Request)`.** It is sync, it holds only what the app
15 + //! put in `S`, and it cannot open a window. So a section whose subject is the
16 + //! *host* rather than the app's own data does not come through:
17 + //!
18 + //! | Section | Described | Why not |
19 + //! |---|---|---|
20 + //! | Appearance | yes | a select over themes the host resolved at startup |
21 + //! | Preview | yes | two booleans in `user_config` |
22 + //! | Forge | yes | one boolean in `user_config` |
23 + //! | Display | yes | five booleans, a number and a control |
24 + //! | Storage | **no** | library paths, reachability, relocation: the filesystem |
25 + //! | Advanced | **no** | native file dialogs for theme import and export |
26 + //! | License | **no** | a key exchanged with a server |
27 + //! | Trash | **no** | filesystem sizes and a destructive sweep over them |
28 + //! | Classifier | **no** | its own model state, and bespoke |
29 + //!
30 + //! Storage and Trash are the honest kind of "no": they are about files on a
31 + //! disk, and a description that named them would be describing this host's
32 + //! filesystem. Advanced is the *interesting* one, and it is the finding
33 + //! goingson's settings port already filed and this port confirms: **a control
34 + //! that asks the host where to put something and then acts has no vocabulary.**
35 + //! `FieldKind::File` covers picking a file to submit; nothing covers "open a
36 + //! save dialog, then write there", which is what Export Theme is. Second
37 + //! consumer, which under the evidence rule is convergence rather than drift.
38 + //!
39 + //! # The finding this port adds
40 + //!
41 + //! **A set of choices cannot be grouped.** `draw_appearance_section` builds its
42 + //! theme picker as four groups (Dark, Light, High Contrast, plus Follow the
43 + //! system) and badges each theme with a contrast tier. [`Choice`] is a value and
44 + //! a label, so the described version puts the variant in the label and loses the
45 + //! structure. That is goingson's own settings finding, and audiofiles is its
46 + //! **second consumer** with a stronger case: goingson grouped four `<optgroup>`s
47 + //! in a webview, and this one groups *and* sorts within each group by measured
48 + //! contrast.
49 + //!
50 + //! # One write route for the whole screen
51 + //!
52 + //! Every control here writes a `user_config` key, and the key set is closed by
53 + //! [`ConfigKey`] with `from_key` refusing an undeclared one. So one route serves
54 + //! all of them, exactly as goingson's `POST /settings/config/{key}` does, and
55 + //! the screen carries no second list of what it is willing to name.
56 +
57 + use audiofiles_core::config_key::ConfigKey;
58 + use quasi_router::layout::FieldKind;
59 + use quasi_router::{
60 + Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
61 + Slot,
62 + };
63 +
64 + use super::Settings;
65 +
66 + /// The region the whole screen answers into.
67 + const BODY: &str = "settings-body";
68 +
69 + /// The columns the file list can show, as described names against the flags the
70 + /// stored `column_config` blob carries.
71 + ///
72 + /// A described field per column, against one opaque key. That split is the
73 + /// second half of this port's findings: the description names five booleans
74 + /// because five is what the user sees, and storage keeps them in one JSON value
75 + /// because that is what `ColumnConfig` already was. The route below is what
76 + /// reconciles the two, and it is the right place for it — a description that
77 + /// named the blob would be describing a storage format.
78 + const COLUMNS: &[(&str, &str)] = &[
79 + ("column.bpm", "BPM"),
80 + ("column.key", "Key"),
81 + ("column.duration", "Duration"),
82 + ("column.peak_db", "Peak dB"),
83 + ("column.tags", "Tags"),
84 + ];
85 +
86 + /// Register this screen's routes.
87 + pub fn routes(router: Router<Settings<'_>>) -> Router<Settings<'_>> {
88 + router
89 + .get("/settings", index)
90 + .post("/settings/config/{key}", write)
91 + .post("/settings/columns/reset", reset_columns)
92 + }
93 +
94 + /// `GET /settings`
95 + fn index(state: &Settings<'_>, _request: Request) -> Result<Response, RouteError> {
96 + Ok(screen(state)?.into())
97 + }
98 +
99 + /// `POST /settings/config/{key}`
100 + ///
101 + /// One route for every control on the screen. An undeclared key is a
102 + /// `NotFound` rather than an internal error: the address is reachable by
103 + /// typing, and `ConfigKey::from_key` is the same refusal the rest of the app
104 + /// makes.
105 + fn write(state: &Settings<'_>, request: Request) -> Result<Response, RouteError> {
106 + let name = request.captures.require("key")?;
107 + let value = request.payload.get(name).unwrap_or_default();
108 +
109 + if let Some((key, stored)) = column_write(state, name, value) {
110 + set(state, key, &stored)?;
111 + return Ok(screen(state)?.into());
112 + }
113 +
114 + let key = ConfigKey::from_key(name).ok_or_else(|| RouteError::not_found("no such setting"))?;
115 + set(state, key, value)?;
116 + Ok(screen(state)?.into())
117 + }
118 +
119 + /// `POST /settings/columns/reset`
120 + fn reset_columns(state: &Settings<'_>, _request: Request) -> Result<Response, RouteError> {
121 + set(state, ConfigKey::ColumnConfig, "")?;
122 + Ok(Response::from(screen(state)?).toast(
123 + quasi_router::layout::Tone::Success,
124 + "Columns restored to defaults.",
125 + ))
126 + }
127 +
128 + /// The whole screen.
129 + fn screen(state: &Settings<'_>) -> Result<Screen, RouteError> {
130 + let mut body = Slot::new(BODY, RegionKind::Pane)
131 + .with(Node::page("Settings"))
132 + .with(Node::section("Appearance"))
133 + .with(appearance(state))
134 + .with(Node::section("Preview"))
135 + .with(toggle(state, ConfigKey::PreviewLoop, "Loop playback")?)
136 + .with(toggle(
137 + state,
138 + ConfigKey::PreviewAutoplay,
139 + "Auto-play on navigate",
140 + )?)
141 + .with(Node::section("Forge"))
142 + .with(toggle(
143 + state,
144 + ConfigKey::ForgeAutoTrimOvershoot,
145 + "Auto-trim resample overshoot",
146 + )?)
147 + .with(Node::section("Display"));
148 +
149 + let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
150 + for (name, label) in COLUMNS {
151 + body = body.with(Node::Field(Box::new(
152 + Field::new(FieldKind::Checkbox, *name, *label)
153 + .value(if column_shown(&stored, name) {
154 + "on"
155 + } else {
156 + ""
157 + })
158 + .changes(Action::post(format!("/settings/config/{name}"))),
159 + )));
160 + }
161 +
162 + body = body
163 + .with(Node::Act(
164 + Act::new("Reset columns", Action::post("/settings/columns/reset"))
165 + .confirm("Restore column visibility, sort and row density to defaults?"),
166 + ))
167 + .with(Node::Field(Box::new(row_height(state)?)));
168 +
169 + Ok(Screen::sidebar_content("Settings").with(body))
170 + }
171 +
172 + /// The theme picker.
173 + ///
174 + /// A `Field` and not a `Node::Select`, which is worth saying because the wrong
175 + /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of
176 + /// a handful of choices, and a theme picker is thirty-odd options that has to
177 + /// collapse. That is a dropdown, which is `FieldKind::Select`. The vocabulary
178 + /// draws the line at how many there are and whether they fold away, not at what
179 + /// the thing means.
180 + ///
181 + /// Flat, and the finding in the module header is why: the shipped picker groups
182 + /// by variant and sorts by contrast tier within each group, and `Choice` carries
183 + /// a value and a label. The variant goes in the label so the fact survives; the
184 + /// structure does not.
185 + fn appearance(state: &Settings<'_>) -> Node {
186 + let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
187 + let options = state
188 + .themes
189 + .iter()
190 + .map(|theme| {
191 + Choice::new(
192 + theme.id.clone(),
193 + format!("{} ({})", theme.name, theme.variant),
194 + )
195 + })
196 + .collect();
197 + let mut field = Field::select(ConfigKey::Theme.as_str(), "Theme", options)
198 + .changes(Action::post("/settings/config/theme"));
199 + field.value = chosen;
200 + Node::Field(Box::new(field))
201 + }
202 +
203 + /// A boolean setting as a checkbox that writes when it changes.
204 + ///
205 + /// `Field::changes` rather than a form: this screen has no submit and never
206 + /// should, which is the shape `14612ed8` was recounted for. Thirteen of
207 + /// goingson's nineteen change-sites were standalone controls, and every control
208 + /// here is one.
209 + fn toggle(state: &Settings<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
210 + let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true");
211 + Ok(Node::Field(Box::new(
212 + Field::new(FieldKind::Checkbox, key.as_str(), label)
213 + .value(if on { "on" } else { "" })
214 + .changes(Action::post(format!("/settings/config/{}", key.as_str()))),
215 + )))
216 + }
217 +
218 + /// Row density, as a bounded number rather than a slider.
219 + ///
220 + /// The description says what the value may be and not what it looks like:
221 + /// `Field::min` and `max` are the bounds the shipped slider draws as a track,
222 + /// and a renderer with no slider draws a number that still cannot go out of
223 + /// range. Naming the widget would have been the description choosing a control.
224 + fn row_height(state: &Settings<'_>) -> Result<Field, RouteError> {
225 + let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned());
226 + Ok(Field::new(
227 + FieldKind::Number,
228 + ConfigKey::RowHeight.as_str(),
229 + "Row height",
230 + )
231 + .value(current)
232 + .hint("Between 20 and 32 pixels.")
233 + .changes(Action::post(format!(
234 + "/settings/config/{}",
235 + ConfigKey::RowHeight.as_str()
236 + ))))
237 + }
238 +
239 + /// Whether a column is shown, read out of the stored blob.
240 + ///
241 + /// Absent means shown, which is what the app's own default does: a fresh
242 + /// install with no `column_config` row shows every column.
243 + fn column_shown(stored: &str, name: &str) -> bool {
244 + let Some(flag) = name.strip_prefix("column.") else {
245 + return true;
246 + };
247 + // The stored blob is JSON written by `ColumnConfig`. Read by looking for the
248 + // flag rather than by parsing: this route reconciles a described name with a
249 + // storage format it does not own, and taking a JSON dependency here to read
250 + // one boolean would put the format's shape in the description layer.
251 + match stored.find(&format!("\"show_{flag}\"")) {
252 + Some(at) => !stored[at..].starts_with(&format!("\"show_{flag}\":false")),
253 + None => true,
254 + }
255 + }
256 +
257 + /// A described column name as the key and value the store wants.
258 + ///
259 + /// `None` when the name is not a column, which is what sends the caller down the
260 + /// ordinary `ConfigKey` path.
261 + fn column_write(state: &Settings<'_>, name: &str, value: &str) -> Option<(ConfigKey, String)> {
262 + let flag = name.strip_prefix("column.")?;
263 + let stored = state
264 + .config
265 + .get(ConfigKey::ColumnConfig)
266 + .ok()
267 + .flatten()
268 + .unwrap_or_default();
269 + let on = !value.is_empty();
270 + let merged = merge_column(&stored, flag, on);
271 + Some((ConfigKey::ColumnConfig, merged))
272 + }
273 +
274 + /// Set one flag in the stored column blob, leaving the rest alone.
275 + fn merge_column(stored: &str, flag: &str, on: bool) -> String {
276 + let key = format!("\"show_{flag}\"");
277 + let replacement = format!("{key}:{on}");
278 + match stored.find(&key) {
279 + Some(at) => {
280 + let rest = &stored[at..];
281 + let end = rest
282 + .find(',')
283 + .or_else(|| rest.find('}'))
284 + .unwrap_or(rest.len());
285 + format!("{}{replacement}{}", &stored[..at], &rest[end..])
286 + }
287 + None if stored.trim().is_empty() => format!("{{{replacement}}}"),
288 + None => {
289 + let trimmed = stored.trim_end().trim_end_matches('}');
290 + format!("{trimmed},{replacement}}}")
291 + }
292 + }
293 + }
294 +
295 + /// Read a key, reporting a store failure as this app's own.
296 + fn get(state: &Settings<'_>, key: ConfigKey) -> Result<Option<String>, RouteError> {
297 + state.config.get(key).map_err(RouteError::internal)
298 + }
299 +
300 + /// Write a key.
301 + fn set(state: &Settings<'_>, key: ConfigKey, value: &str) -> Result<(), RouteError> {
302 + state.config.set(key, value).map_err(RouteError::internal)
303 + }
@@ -1,0 +1,278 @@
1 + //! The described screens, called with no host in sight.
2 + //!
3 + //! Every test here builds a `Settings`, calls the router, and reads the `Screen`
4 + //! that came back. No egui, no window, no `BrowserState`: that is the property
5 + //! the description layer exists to give, and it is why these run in
6 + //! milliseconds where the panel they replace cannot be tested at all.
7 +
8 + use std::cell::RefCell;
9 + use std::collections::BTreeMap;
10 +
11 + use audiofiles_core::config_key::ConfigKey;
12 + use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 +
14 + use super::{Config, Settings, ThemeChoice, router};
15 +
16 + /// A config store in memory.
17 + ///
18 + /// Two methods, which is the whole of what a described screen needs. Standing
19 + /// this up against `Backend` itself would have meant implementing vfs, tags and
20 + /// search to test a checkbox, and that cost is what `Config` exists to refuse.
21 + #[derive(Default)]
22 + struct Store {
23 + values: RefCell<BTreeMap<String, String>>,
24 + }
25 +
26 + impl Config for Store {
27 + fn get(&self, key: ConfigKey) -> Result<Option<String>, String> {
28 + Ok(self.values.borrow().get(key.as_str()).cloned())
29 + }
30 +
31 + fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> {
32 + self.values
33 + .borrow_mut()
34 + .insert(key.as_str().to_owned(), value.to_owned());
35 + Ok(())
36 + }
37 + }
38 +
39 + impl Store {
40 + fn with(pairs: &[(ConfigKey, &str)]) -> Self {
41 + let store = Self::default();
42 + for (key, value) in pairs {
43 + store
44 + .values
45 + .borrow_mut()
46 + .insert(key.as_str().to_owned(), (*value).to_owned());
47 + }
48 + store
49 + }
50 +
51 + fn get(&self, key: ConfigKey) -> Option<String> {
52 + self.values.borrow().get(key.as_str()).cloned()
53 + }
54 + }
55 +
56 + fn themes() -> Vec<ThemeChoice> {
57 + vec![
58 + ThemeChoice {
59 + id: "audiofiles".into(),
60 + name: "audiofiles".into(),
61 + variant: "light".into(),
62 + },
63 + ThemeChoice {
64 + id: "nord".into(),
65 + name: "Nord".into(),
66 + variant: "dark".into(),
67 + },
68 + ]
69 + }
70 +
71 + /// The screen out of a response, or a failure naming what came instead.
72 + fn screen_of(response: &Response) -> &Screen {
73 + match &response.outcome {
74 + Outcome::Screen(screen) => screen,
75 + other => panic!("expected a screen, got {other:?}"),
76 + }
77 + }
78 +
79 + /// Every field on the screen, by name.
80 + fn fields(screen: &Screen) -> BTreeMap<String, Option<String>> {
81 + let mut found = BTreeMap::new();
82 + for slot in &screen.slots {
83 + for node in &slot.body {
84 + if let Node::Field(field) = node {
85 + found.insert(field.name.clone(), field.value.clone());
86 + }
87 + }
88 + }
89 + found
90 + }
91 +
92 + #[test]
93 + fn the_screen_answers_with_every_control_it_describes() {
94 + let store = Store::default();
95 + let themes = themes();
96 + let state = Settings {
97 + config: &store,
98 + themes: &themes,
99 + };
100 + let response = router()
101 + .handle(&state, Request::get("/settings"))
102 + .expect("the route answered");
103 + let screen = screen_of(&response);
104 + let named = fields(screen);
105 +
106 + // The four describable sections, as the controls a user sees.
107 + assert!(named.contains_key(ConfigKey::PreviewLoop.as_str()));
108 + assert!(named.contains_key(ConfigKey::PreviewAutoplay.as_str()));
109 + assert!(named.contains_key(ConfigKey::ForgeAutoTrimOvershoot.as_str()));
110 + assert!(named.contains_key(ConfigKey::RowHeight.as_str()));
111 + for column in ["column.bpm", "column.key", "column.tags"] {
112 + assert!(named.contains_key(column), "{column} is not on the screen");
113 + }
114 + }
115 +
116 + #[test]
117 + fn every_control_writes_through_one_route() {
118 + // The shape goingson's settings port settled: a screen that is a key/value
119 + // editor reads as one, and carries no second list of what it may name.
120 + let table: Vec<(Method, String)> = router().routes().map(|(m, p)| (m, p.to_owned())).collect();
121 + assert!(table.contains(&(Method::Get, "/settings".to_owned())));
122 + assert!(table.contains(&(Method::Post, "/settings/config/{key}".to_owned())));
123 + assert_eq!(table.len(), 3, "{table:?}");
124 + }
125 +
126 + #[test]
127 + fn a_toggle_reads_what_is_stored_and_writes_what_was_sent() {
128 + let store = Store::with(&[(ConfigKey::PreviewLoop, "1")]);
129 + let themes = themes();
130 + let state = Settings {
131 + config: &store,
132 + themes: &themes,
133 + };
134 +
135 + let response = router()
136 + .handle(&state, Request::get("/settings"))
137 + .expect("answered");
138 + let on = fields(screen_of(&response));
139 + assert_eq!(
140 + on.get(ConfigKey::PreviewLoop.as_str()),
141 + Some(&Some("on".to_owned())),
142 + "a stored 1 draws as ticked"
143 + );
144 +
145 + router()
146 + .handle(
147 + &state,
148 + Request::post(format!(
149 + "/settings/config/{}",
150 + ConfigKey::PreviewLoop.as_str()
151 + ))
152 + .sending(Params::new().with(ConfigKey::PreviewLoop.as_str().to_owned(), String::new())),
153 + )
154 + .expect("answered");
155 + assert_eq!(store.get(ConfigKey::PreviewLoop).as_deref(), Some(""));
156 + }
157 +
158 + #[test]
159 + fn a_setting_this_app_never_declared_is_a_not_found() {
160 + // `ConfigKey::from_key` is the same refusal the rest of the app makes, and
161 + // the address is reachable by typing, so it is a 404 rather than a 500.
162 + let store = Store::default();
163 + let themes = themes();
164 + let state = Settings {
165 + config: &store,
166 + themes: &themes,
167 + };
168 + let refused = router().handle(
169 + &state,
170 + Request::post("/settings/config/not_a_setting")
171 + .sending(Params::new().with("not_a_setting".to_owned(), "x".to_owned())),
172 + );
173 + let error = refused.expect_err("an undeclared key is refused");
174 + assert_eq!(error.class, quasi_router::Class::NotFound);
175 + }
176 +
177 + #[test]
178 + fn a_column_is_five_described_names_against_one_stored_blob() {
179 + // The reconciliation this port owns: the user sees five booleans and the
180 + // store keeps one JSON value, and the route is where the two meet. A
181 + // description that named the blob would be describing a storage format.
182 + let store = Store::default();
183 + let themes = themes();
184 + let state = Settings {
185 + config: &store,
186 + themes: &themes,
187 + };
188 +
189 + // Absent means shown, which is what a fresh install does.
190 + let response = router()
191 + .handle(&state, Request::get("/settings"))
192 + .expect("answered");
193 + assert_eq!(
194 + fields(screen_of(&response)).get("column.bpm"),
195 + Some(&Some("on".to_owned()))
196 + );
197 +
198 + // Turning one off leaves the others alone.
199 + router()
200 + .handle(
201 + &state,
202 + Request::post("/settings/config/column.bpm")
203 + .sending(Params::new().with("column.bpm".to_owned(), String::new())),
204 + )
205 + .expect("answered");
206 + let stored = store.get(ConfigKey::ColumnConfig).expect("written");
207 + assert!(stored.contains("\"show_bpm\":false"), "{stored}");
208 +
209 + let response = router()
210 + .handle(&state, Request::get("/settings"))
211 + .expect("answered");
212 + let named = fields(screen_of(&response));
213 + assert_eq!(named.get("column.bpm"), Some(&Some(String::new())));
214 + assert_eq!(
215 + named.get("column.key"),
216 + Some(&Some("on".to_owned())),
217 + "turning one column off turned another off too"
218 + );
219 + }
220 +
221 + #[test]
222 + fn the_theme_picker_offers_what_the_host_resolved() {
223 + // The settled host-boundary rule, applied first time out: a host fact
224 + // readable at startup goes in `S` rather than through a capability surface.
225 + let store = Store::with(&[(ConfigKey::Theme, "nord")]);
226 + let themes = themes();
227 + let state = Settings {
228 + config: &store,
229 + themes: &themes,
230 + };
231 + let response = router()
232 + .handle(&state, Request::get("/settings"))
233 + .expect("answered");
234 +
235 + // A field and not a `Node::Select`: `Selector` is a strip of a handful of
236 + // choices, and thirty-odd themes that fold away is a dropdown.
237 + let picker = screen_of(&response)
238 + .slots
239 + .iter()
240 + .flat_map(|slot| &slot.body)
241 + .find_map(|node| match node {
242 + Node::Field(field) if field.name == ConfigKey::Theme.as_str() => Some(field),
243 + _ => None,
244 + })
245 + .expect("the screen offers a theme picker");
246 +
247 + assert_eq!(picker.kind, quasi_router::layout::FieldKind::Select);
248 + assert_eq!(picker.value.as_deref(), Some("nord"));
249 + assert_eq!(picker.options.len(), 2);
250 + // The finding, asserted rather than described in prose: the variant is in
251 + // the label because `Choice` has nowhere else to put it. When grouping
252 + // arrives, this assertion is what should have to change.
253 + assert!(
254 + picker
255 + .options
256 + .iter()
257 + .any(|choice| choice.label.contains("dark")),
258 + "the variant survived only by riding in the label"
259 + );
260 + }
261 +
262 + #[test]
263 + fn resetting_the_columns_says_it_did() {
264 + let store = Store::with(&[(ConfigKey::ColumnConfig, "{\"show_bpm\":false}")]);
265 + let themes = themes();
266 + let state = Settings {
267 + config: &store,
268 + themes: &themes,
269 + };
270 + let response = router()
271 + .handle(&state, Request::post("/settings/columns/reset"))
272 + .expect("answered");
273 + assert_eq!(store.get(ConfigKey::ColumnConfig).as_deref(), Some(""));
274 + assert!(
275 + response.notice.is_some(),
276 + "a destructive-looking control that says nothing is one the user cannot tell worked"
277 + );
278 + }