max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+946 insertions,
-8 deletions
| @@ -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" |
| @@ -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 | + | } |