Skip to main content

max / makeover

Restructure into monorepo Move server files to server/ subdirectory. Absorb multithreaded, pom, and mnw-cli (previously separate repos with .gitignore'd directories). Move shared libraries from ../Shared/ into shared/ subdirectory. Update all path dependencies accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-15 19:17 UTC
Commit: b3740fc7f3e782bebc53d6edf6eaf38f4049ddbd
6 files changed, +1157 insertions, -0 deletions
A Cargo.lock +500
@@ -0,0 +1,504 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "anyhow"
7 + version = "1.0.102"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
10 +
11 + [[package]]
12 + name = "bitflags"
13 + version = "2.11.0"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
16 +
17 + [[package]]
18 + name = "cfg-if"
19 + version = "1.0.4"
20 + source = "registry+https://github.com/rust-lang/crates.io-index"
21 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
22 +
23 + [[package]]
24 + name = "equivalent"
25 + version = "1.0.2"
26 + source = "registry+https://github.com/rust-lang/crates.io-index"
27 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
28 +
29 + [[package]]
30 + name = "errno"
31 + version = "0.3.14"
32 + source = "registry+https://github.com/rust-lang/crates.io-index"
33 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
34 + dependencies = [
35 + "libc",
36 + "windows-sys",
37 + ]
38 +
39 + [[package]]
40 + name = "fastrand"
41 + version = "2.3.0"
42 + source = "registry+https://github.com/rust-lang/crates.io-index"
43 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
44 +
45 + [[package]]
46 + name = "foldhash"
47 + version = "0.1.5"
48 + source = "registry+https://github.com/rust-lang/crates.io-index"
49 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
50 +
51 + [[package]]
52 + name = "getrandom"
53 + version = "0.4.2"
54 + source = "registry+https://github.com/rust-lang/crates.io-index"
55 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
56 + dependencies = [
57 + "cfg-if",
58 + "libc",
59 + "r-efi",
60 + "wasip2",
61 + "wasip3",
62 + ]
63 +
64 + [[package]]
65 + name = "hashbrown"
66 + version = "0.15.5"
67 + source = "registry+https://github.com/rust-lang/crates.io-index"
68 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
69 + dependencies = [
70 + "foldhash",
71 + ]
72 +
73 + [[package]]
74 + name = "hashbrown"
75 + version = "0.16.1"
76 + source = "registry+https://github.com/rust-lang/crates.io-index"
77 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
78 +
79 + [[package]]
80 + name = "heck"
81 + version = "0.5.0"
82 + source = "registry+https://github.com/rust-lang/crates.io-index"
83 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
84 +
85 + [[package]]
86 + name = "id-arena"
87 + version = "2.3.0"
88 + source = "registry+https://github.com/rust-lang/crates.io-index"
89 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
90 +
91 + [[package]]
92 + name = "indexmap"
93 + version = "2.13.0"
94 + source = "registry+https://github.com/rust-lang/crates.io-index"
95 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
96 + dependencies = [
97 + "equivalent",
98 + "hashbrown 0.16.1",
99 + "serde",
100 + "serde_core",
101 + ]
102 +
103 + [[package]]
104 + name = "itoa"
105 + version = "1.0.18"
106 + source = "registry+https://github.com/rust-lang/crates.io-index"
107 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
108 +
109 + [[package]]
110 + name = "leb128fmt"
111 + version = "0.1.0"
112 + source = "registry+https://github.com/rust-lang/crates.io-index"
113 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
114 +
115 + [[package]]
116 + name = "libc"
117 + version = "0.2.183"
118 + source = "registry+https://github.com/rust-lang/crates.io-index"
119 + checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
120 +
121 + [[package]]
122 + name = "linux-raw-sys"
123 + version = "0.12.1"
124 + source = "registry+https://github.com/rust-lang/crates.io-index"
125 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
126 +
127 + [[package]]
128 + name = "log"
129 + version = "0.4.29"
130 + source = "registry+https://github.com/rust-lang/crates.io-index"
131 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
132 +
133 + [[package]]
134 + name = "memchr"
135 + version = "2.8.0"
136 + source = "registry+https://github.com/rust-lang/crates.io-index"
137 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
138 +
139 + [[package]]
140 + name = "once_cell"
141 + version = "1.21.4"
142 + source = "registry+https://github.com/rust-lang/crates.io-index"
143 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
144 +
145 + [[package]]
146 + name = "prettyplease"
147 + version = "0.2.37"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
150 + dependencies = [
151 + "proc-macro2",
152 + "syn",
153 + ]
154 +
155 + [[package]]
156 + name = "proc-macro2"
157 + version = "1.0.106"
158 + source = "registry+https://github.com/rust-lang/crates.io-index"
159 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
160 + dependencies = [
161 + "unicode-ident",
162 + ]
163 +
164 + [[package]]
165 + name = "quote"
166 + version = "1.0.45"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
169 + dependencies = [
170 + "proc-macro2",
171 + ]
172 +
173 + [[package]]
174 + name = "r-efi"
175 + version = "6.0.0"
176 + source = "registry+https://github.com/rust-lang/crates.io-index"
177 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
178 +
179 + [[package]]
180 + name = "rustix"
181 + version = "1.1.4"
182 + source = "registry+https://github.com/rust-lang/crates.io-index"
183 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
184 + dependencies = [
185 + "bitflags",
186 + "errno",
187 + "libc",
188 + "linux-raw-sys",
189 + "windows-sys",
190 + ]
191 +
192 + [[package]]
193 + name = "semver"
194 + version = "1.0.27"
195 + source = "registry+https://github.com/rust-lang/crates.io-index"
196 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
197 +
198 + [[package]]
199 + name = "serde"
200 + version = "1.0.228"
201 + source = "registry+https://github.com/rust-lang/crates.io-index"
202 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
203 + dependencies = [
204 + "serde_core",
205 + "serde_derive",
206 + ]
207 +
208 + [[package]]
209 + name = "serde_core"
210 + version = "1.0.228"
211 + source = "registry+https://github.com/rust-lang/crates.io-index"
212 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
213 + dependencies = [
214 + "serde_derive",
215 + ]
216 +
217 + [[package]]
218 + name = "serde_derive"
219 + version = "1.0.228"
220 + source = "registry+https://github.com/rust-lang/crates.io-index"
221 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
222 + dependencies = [
223 + "proc-macro2",
224 + "quote",
225 + "syn",
226 + ]
227 +
228 + [[package]]
229 + name = "serde_json"
230 + version = "1.0.149"
231 + source = "registry+https://github.com/rust-lang/crates.io-index"
232 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
233 + dependencies = [
234 + "itoa",
235 + "memchr",
236 + "serde",
237 + "serde_core",
238 + "zmij",
239 + ]
240 +
241 + [[package]]
242 + name = "serde_spanned"
243 + version = "0.6.9"
244 + source = "registry+https://github.com/rust-lang/crates.io-index"
245 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
246 + dependencies = [
247 + "serde",
248 + ]
249 +
250 + [[package]]
251 + name = "syn"
252 + version = "2.0.117"
253 + source = "registry+https://github.com/rust-lang/crates.io-index"
254 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
255 + dependencies = [
256 + "proc-macro2",
257 + "quote",
258 + "unicode-ident",
259 + ]
260 +
261 + [[package]]
262 + name = "tempfile"
263 + version = "3.27.0"
264 + source = "registry+https://github.com/rust-lang/crates.io-index"
265 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
266 + dependencies = [
267 + "fastrand",
268 + "getrandom",
269 + "once_cell",
270 + "rustix",
271 + "windows-sys",
272 + ]
273 +
274 + [[package]]
275 + name = "theme-common"
276 + version = "0.3.0"
277 + dependencies = [
278 + "serde",
279 + "tempfile",
280 + "toml",
281 + ]
282 +
283 + [[package]]
284 + name = "toml"
285 + version = "0.8.23"
286 + source = "registry+https://github.com/rust-lang/crates.io-index"
287 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
288 + dependencies = [
289 + "serde",
290 + "serde_spanned",
291 + "toml_datetime",
292 + "toml_edit",
293 + ]
294 +
295 + [[package]]
296 + name = "toml_datetime"
297 + version = "0.6.11"
298 + source = "registry+https://github.com/rust-lang/crates.io-index"
299 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
300 + dependencies = [
301 + "serde",
302 + ]
303 +
304 + [[package]]
305 + name = "toml_edit"
306 + version = "0.22.27"
307 + source = "registry+https://github.com/rust-lang/crates.io-index"
308 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
309 + dependencies = [
310 + "indexmap",
311 + "serde",
312 + "serde_spanned",
313 + "toml_datetime",
314 + "toml_write",
315 + "winnow",
316 + ]
317 +
318 + [[package]]
319 + name = "toml_write"
320 + version = "0.1.2"
321 + source = "registry+https://github.com/rust-lang/crates.io-index"
322 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
323 +
324 + [[package]]
325 + name = "unicode-ident"
326 + version = "1.0.24"
327 + source = "registry+https://github.com/rust-lang/crates.io-index"
328 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
329 +
330 + [[package]]
331 + name = "unicode-xid"
332 + version = "0.2.6"
333 + source = "registry+https://github.com/rust-lang/crates.io-index"
334 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
335 +
336 + [[package]]
337 + name = "wasip2"
338 + version = "1.0.2+wasi-0.2.9"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
341 + dependencies = [
342 + "wit-bindgen",
343 + ]
344 +
345 + [[package]]
346 + name = "wasip3"
347 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
348 + source = "registry+https://github.com/rust-lang/crates.io-index"
349 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
350 + dependencies = [
351 + "wit-bindgen",
352 + ]
353 +
354 + [[package]]
355 + name = "wasm-encoder"
356 + version = "0.244.0"
357 + source = "registry+https://github.com/rust-lang/crates.io-index"
358 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
359 + dependencies = [
360 + "leb128fmt",
361 + "wasmparser",
362 + ]
363 +
364 + [[package]]
365 + name = "wasm-metadata"
366 + version = "0.244.0"
367 + source = "registry+https://github.com/rust-lang/crates.io-index"
368 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
369 + dependencies = [
370 + "anyhow",
371 + "indexmap",
372 + "wasm-encoder",
373 + "wasmparser",
374 + ]
375 +
376 + [[package]]
377 + name = "wasmparser"
378 + version = "0.244.0"
379 + source = "registry+https://github.com/rust-lang/crates.io-index"
380 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
381 + dependencies = [
382 + "bitflags",
383 + "hashbrown 0.15.5",
384 + "indexmap",
385 + "semver",
386 + ]
387 +
388 + [[package]]
389 + name = "windows-link"
390 + version = "0.2.1"
391 + source = "registry+https://github.com/rust-lang/crates.io-index"
392 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
393 +
394 + [[package]]
395 + name = "windows-sys"
396 + version = "0.61.2"
397 + source = "registry+https://github.com/rust-lang/crates.io-index"
398 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
399 + dependencies = [
400 + "windows-link",
401 + ]
402 +
403 + [[package]]
404 + name = "winnow"
405 + version = "0.7.15"
406 + source = "registry+https://github.com/rust-lang/crates.io-index"
407 + checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
408 + dependencies = [
409 + "memchr",
410 + ]
411 +
412 + [[package]]
413 + name = "wit-bindgen"
414 + version = "0.51.0"
415 + source = "registry+https://github.com/rust-lang/crates.io-index"
416 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
417 + dependencies = [
418 + "wit-bindgen-rust-macro",
419 + ]
420 +
421 + [[package]]
422 + name = "wit-bindgen-core"
423 + version = "0.51.0"
424 + source = "registry+https://github.com/rust-lang/crates.io-index"
425 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
426 + dependencies = [
427 + "anyhow",
428 + "heck",
429 + "wit-parser",
430 + ]
431 +
432 + [[package]]
433 + name = "wit-bindgen-rust"
434 + version = "0.51.0"
435 + source = "registry+https://github.com/rust-lang/crates.io-index"
436 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
437 + dependencies = [
438 + "anyhow",
439 + "heck",
440 + "indexmap",
441 + "prettyplease",
442 + "syn",
443 + "wasm-metadata",
444 + "wit-bindgen-core",
445 + "wit-component",
446 + ]
447 +
448 + [[package]]
449 + name = "wit-bindgen-rust-macro"
450 + version = "0.51.0"
451 + source = "registry+https://github.com/rust-lang/crates.io-index"
452 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
453 + dependencies = [
454 + "anyhow",
455 + "prettyplease",
456 + "proc-macro2",
457 + "quote",
458 + "syn",
459 + "wit-bindgen-core",
460 + "wit-bindgen-rust",
461 + ]
462 +
463 + [[package]]
464 + name = "wit-component"
465 + version = "0.244.0"
466 + source = "registry+https://github.com/rust-lang/crates.io-index"
467 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
468 + dependencies = [
469 + "anyhow",
470 + "bitflags",
471 + "indexmap",
472 + "log",
473 + "serde",
474 + "serde_derive",
475 + "serde_json",
476 + "wasm-encoder",
477 + "wasm-metadata",
478 + "wasmparser",
479 + "wit-parser",
480 + ]
481 +
482 + [[package]]
483 + name = "wit-parser"
484 + version = "0.244.0"
485 + source = "registry+https://github.com/rust-lang/crates.io-index"
486 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
487 + dependencies = [
488 + "anyhow",
489 + "id-arena",
490 + "indexmap",
491 + "log",
492 + "semver",
493 + "serde",
494 + "serde_derive",
495 + "serde_json",
496 + "unicode-xid",
497 + "wasmparser",
498 + ]
499 +
500 + [[package]]
Lines truncated
A Cargo.toml +11
@@ -0,0 +1,11 @@
1 + [package]
2 + name = "theme-common"
3 + version = "0.3.0"
4 + edition = "2024"
5 +
6 + [dependencies]
7 + serde = { version = "1", features = ["derive"] }
8 + toml = "0.8"
9 +
10 + [dev-dependencies]
11 + tempfile = "3"
A README.md +110
@@ -0,0 +1,110 @@
1 + # theme-common
2 +
3 + Shared theme loading logic for TOML-based theme files. Parses theme metadata and color values from `.toml` files on disk.
4 +
5 + Used by GoingsOn and Balanced Breakfast (Tauri apps that load themes at runtime). audiofiles uses a different compile-time embedding approach and does not use this crate.
6 +
7 + ## Usage
8 +
9 + ```rust
10 + use theme_common::{load_theme, list_themes_from_dirs, dev_themes_dir};
11 + use std::path::PathBuf;
12 +
13 + // Set up theme directories (later entries override earlier ones)
14 + let bundled = PathBuf::from("/path/to/Shared/themes");
15 + let custom = PathBuf::from("/path/to/user/custom-themes");
16 + let dirs = vec![(bundled, false), (custom, true)];
17 +
18 + // List available themes (sorted by name)
19 + let themes = list_themes_from_dirs(&dirs);
20 + for t in &themes {
21 + println!("{} ({}, {})", t.name, t.id, t.variant);
22 + }
23 +
24 + // Load a specific theme by ID
25 + let theme = load_theme(&dirs, "catppuccin-mocha").unwrap();
26 + println!("Name: {}", theme.meta.name); // "Catppuccin Mocha"
27 + println!("Variant: {}", theme.meta.variant); // "dark"
28 + println!("BG: {}", theme.colors["background.primary"]); // "#181825"
29 +
30 + // Dev helper: find Shared/themes/ relative to CARGO_MANIFEST_DIR
31 + if let Some(dev_dir) = dev_themes_dir(Path::new(env!("CARGO_MANIFEST_DIR")), 2) {
32 + // dev_dir = .../Code/Shared/themes
33 + }
34 + ```
35 +
36 + ## Theme File Format
37 +
38 + Theme files are TOML with four color sections. See `../themes/` for the 16 bundled themes.
39 +
40 + ```toml
41 + # Attribution comment (optional, for credit)
42 + # Based on Catppuccin by Catppuccin Org -- MIT License
43 +
44 + [meta]
45 + name = "Theme Name" # Display name (required)
46 + variant = "dark" # "dark", "light", or "high-contrast" (default: "dark")
47 +
48 + [background]
49 + primary = "#181825" # Main background
50 + secondary = "#11111b" # Sidebar / panel background (optional)
51 + tertiary = "#313244" # Hover / selection background (optional)
52 + surface = "#1e1e2e" # Card / elevated surface (optional)
53 +
54 + [foreground]
55 + primary = "#cdd6f4" # Main text
56 + secondary = "#bac2de" # Secondary text (optional)
57 + muted = "#9399b2" # Placeholder / disabled text (optional)
58 +
59 + [accent]
60 + red = "#f38ba8" # Error, destructive actions
61 + green = "#a6e3a1" # Success, positive actions
62 + blue = "#89b4fa" # Links, primary accent
63 + yellow = "#f9e2af" # Warnings
64 + purple = "#cba6f7" # Tags, special elements
65 + cyan = "#89dceb" # Info, secondary accent
66 +
67 + [border]
68 + default = "#45475a" # Default border color
69 + ```
70 +
71 + ### Color Key Flattening
72 +
73 + Colors are loaded into a flat `HashMap<String, String>` with dotted keys:
74 +
75 + ```
76 + "background.primary" -> "#181825"
77 + "foreground.muted" -> "#9399b2"
78 + "accent.blue" -> "#89b4fa"
79 + "border.default" -> "#45475a"
80 + ```
81 +
82 + Apps map these keys to CSS variables or egui color values.
83 +
84 + ### Theme ID
85 +
86 + The theme ID is the filename without `.toml` (e.g., `catppuccin-mocha.toml` has ID `catppuccin-mocha`). IDs must contain only alphanumeric characters, hyphens, and underscores. Path traversal characters are rejected.
87 +
88 + ## API
89 +
90 + | Function | Description |
91 + |----------|-------------|
92 + | `list_themes_from_dirs(dirs)` | Scan directories for `.toml` files, return sorted `Vec<ThemeMeta>` |
93 + | `load_theme(dirs, id)` | Load a theme by ID, returning `ThemeColors` (metadata + color map) |
94 + | `find_theme_path(dirs, id)` | Find the file path for a theme ID (highest-priority directory wins) |
95 + | `parse_meta(id, table, is_custom)` | Parse `[meta]` from a TOML table into `ThemeMeta` |
96 + | `extract_colors(table)` | Flatten color sections into a `HashMap<String, String>` |
97 + | `validate_theme_id(id)` | Check that an ID contains only safe characters |
98 + | `dev_themes_dir(manifest_dir, levels)` | Walk up parent directories to find `Shared/themes/` (dev convenience) |
99 +
100 + ## Directory Priority
101 +
102 + `list_themes_from_dirs` and `load_theme` accept a list of `(PathBuf, bool)` pairs. Later directories override earlier ones by theme ID. The `bool` marks whether the directory contains user-custom themes (`is_custom` on `ThemeMeta`).
103 +
104 + Typical setup for a Tauri app:
105 + 1. Bundled themes from `Shared/themes/` (is_custom = false)
106 + 2. User themes from an app data directory (is_custom = true)
107 +
108 + ## License
109 +
110 + PolyForm Noncommercial 1.0.0
@@ -0,0 +1,26 @@
1 + # theme-common — Architecture
2 +
3 + Shared TOML theme loading for GO, BB, and AF.
4 +
5 + ## What It Does
6 +
7 + Parses TOML theme files with `[meta]`, `[background]`, `[foreground]`, `[accent]`, `[border]` sections. Provides metadata extraction, color flattening, filesystem scanning, import/export, and ID validation.
8 +
9 + ## Consumers
10 +
11 + | App | Usage |
12 + |-----|-------|
13 + | GO, BB | `list_themes_from_dirs`, `load_theme`, `import_theme`, `export_theme` (runtime filesystem) |
14 + | AF | `ThemeMeta`, `parse_meta`, `extract_colors` (compile-time embedded themes) |
15 +
16 + ## Key Types
17 +
18 + - `ThemeMeta` — id, name, variant, is_custom
19 + - `ThemeColors` — metadata + flattened color map (`background.primary` etc.)
20 +
21 + ## Key Paths
22 +
23 + | What | Where |
24 + |------|-------|
25 + | All code | `src/lib.rs` (single file, ~585 lines including tests) |
26 + | Theme files | `../themes/*.toml` (16 bundled themes) |
A docs/todo.md +10
@@ -0,0 +1,10 @@
1 + # theme-common — Todo
2 +
3 + Done: Core implementation. Active: None. Next: None planned.
4 +
5 + v0.3.0. 26 tests.
6 +
7 + ## Deferred
8 +
9 + - [ ] Delete theme by ID (remove from custom dir)
10 + - [ ] Theme preview colors (return a small palette for UI previews without loading full theme)
A src/lib.rs +500
@@ -0,0 +1,585 @@
1 + //! Shared theme loading logic for TOML-based theme files.
2 + //!
3 + //! Used by GoingsOn, Balanced Breakfast (Tauri apps), and audiofiles (egui).
4 + //! audiofiles embeds themes at compile time but uses `ThemeMeta`, `parse_meta`,
5 + //! and `extract_colors` from this crate.
6 + //!
7 + //! Theme files are TOML with this structure:
8 + //! ```text
9 + //! [meta]
10 + //! name = "Theme Name"
11 + //! variant = "dark" # or "light"
12 + //!
13 + //! [background]
14 + //! primary = "#1e1e2e"
15 + //!
16 + //! [foreground]
17 + //! primary = "#cdd6f4"
18 + //!
19 + //! [accent]
20 + //! primary = "#89b4fa"
21 + //!
22 + //! [border]
23 + //! primary = "#45475a"
24 + //! ```
25 +
26 + use serde::Serialize;
27 + use std::collections::HashMap;
28 + use std::path::{Path, PathBuf};
29 +
30 + /// Theme metadata parsed from the `[meta]` section.
31 + #[derive(Debug, Clone, Serialize)]
32 + #[serde(rename_all = "camelCase")]
33 + pub struct ThemeMeta {
34 + pub id: String,
35 + pub name: String,
36 + pub variant: String,
37 + pub is_custom: bool,
38 + }
39 +
40 + /// A fully loaded theme: metadata plus flattened color map.
41 + #[derive(Debug, Serialize)]
42 + #[serde(rename_all = "camelCase")]
43 + pub struct ThemeColors {
44 + pub meta: ThemeMeta,
45 + pub colors: HashMap<String, String>,
46 + }
47 +
48 + /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
49 + pub fn validate_theme_id(id: &str) -> Result<(), String> {
50 + if !id
51 + .chars()
52 + .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
53 + {
54 + return Err(format!("Invalid theme ID: {}", id));
55 + }
56 + Ok(())
57 + }
58 +
59 + /// Parse the `[meta]` section from a TOML table into `ThemeMeta`.
60 + ///
61 + /// Falls back to the file ID as the name and `"dark"` as the variant.
62 + pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
63 + let meta = table.get("meta").and_then(|m| m.as_table());
64 + let name = meta
65 + .and_then(|m| m.get("name"))
66 + .and_then(|v| v.as_str())
67 + .unwrap_or(id)
68 + .to_string();
69 + let variant = meta
70 + .and_then(|m| m.get("variant"))
71 + .and_then(|v| v.as_str())
72 + .unwrap_or("dark")
73 + .to_string();
74 +
75 + ThemeMeta {
76 + id: id.to_string(),
77 + name,
78 + variant,
79 + is_custom,
80 + }
81 + }
82 +
83 + /// Extract color sections (background, foreground, accent, border) from a TOML
84 + /// table into a flat `HashMap` with keys like `"background.primary"`.
85 + pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
86 + let mut colors = HashMap::new();
87 + for section in &["background", "foreground", "accent", "border"] {
88 + if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
89 + for (key, val) in sect {
90 + if let Some(color) = val.as_str() {
91 + colors.insert(format!("{}.{}", section, key), color.to_string());
92 + }
93 + }
94 + }
95 + }
96 + colors
97 + }
98 +
99 + /// Scan directories for `.toml` theme files and return metadata for each.
100 + ///
101 + /// Directories are checked in order; later entries override earlier ones by ID.
102 + /// Each entry in `dirs` is `(path, is_custom)`.
103 + pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
104 + let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
105 +
106 + for (dir, is_custom) in dirs {
107 + let entries = match std::fs::read_dir(dir) {
108 + Ok(e) => e,
109 + Err(_) => continue,
110 + };
111 +
112 + for entry in entries {
113 + let entry = match entry {
114 + Ok(e) => e,
115 + Err(_) => continue,
116 + };
117 + let path = entry.path();
118 + if path.extension().and_then(|e| e.to_str()) != Some("toml") {
119 + continue;
120 + }
121 +
122 + let id = path
123 + .file_stem()
124 + .and_then(|s| s.to_str())
125 + .unwrap_or_default()
126 + .to_string();
127 +
128 + let content = match std::fs::read_to_string(&path) {
129 + Ok(c) => c,
130 + Err(_) => continue,
131 + };
132 + let table: toml::Table = match content.parse() {
133 + Ok(t) => t,
134 + Err(_) => continue,
135 + };
136 +
137 + seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
138 + }
139 + }
140 +
141 + let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
142 + themes.sort_by(|a, b| a.name.cmp(&b.name));
143 + themes
144 + }
145 +
146 + /// Find a theme file by ID in the given directories.
147 + ///
148 + /// Checks directories in reverse order so the highest-priority directory wins.
149 + /// Returns `(path, is_custom)` or `None` if not found.
150 + pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
151 + let filename = format!("{}.toml", id);
152 +
153 + for (dir, is_custom) in dirs.iter().rev() {
154 + let path = dir.join(&filename);
155 + if path.is_file() {
156 + return Some((path, *is_custom));
157 + }
158 + }
159 +
160 + None
161 + }
162 +
163 + /// Load a complete theme (metadata + colors) by ID from the given directories.
164 + pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
165 + validate_theme_id(id)?;
166 +
167 + let (path, is_custom) =
168 + find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
169 +
170 + let content = std::fs::read_to_string(&path)
171 + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
172 +
173 + let table: toml::Table = content
174 + .parse()
175 + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
176 +
177 + let meta = parse_meta(id, &table, is_custom);
178 + let colors = extract_colors(&table);
179 +
180 + Ok(ThemeColors { meta, colors })
181 + }
182 +
183 + /// Import a theme TOML file into the custom themes directory.
184 + ///
185 + /// Validates that the file is parseable TOML with the expected color sections,
186 + /// then copies it to `custom_dir/{id}.toml` where `id` is the file stem.
187 + /// Creates `custom_dir` if it doesn't exist. Returns the theme metadata.
188 + pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
189 + let content = std::fs::read_to_string(source_path)
190 + .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
191 +
192 + let table: toml::Table = content
193 + .parse()
194 + .map_err(|e| format!("Invalid TOML: {}", e))?;
195 +
196 + // Verify it has at least one color section
197 + let has_colors = ["background", "foreground", "accent", "border"]
198 + .iter()
199 + .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
200 + if !has_colors {
201 + return Err("Theme file must have at least one color section (background, foreground, accent, or border)".to_string());
202 + }
203 +
204 + let id = source_path
205 + .file_stem()
206 + .and_then(|s| s.to_str())
207 + .ok_or("Invalid file name")?
208 + .to_string();
209 + validate_theme_id(&id)?;
210 +
211 + std::fs::create_dir_all(custom_dir)
212 + .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
213 +
214 + let dest = custom_dir.join(format!("{}.toml", id));
215 + std::fs::copy(source_path, &dest)
216 + .map_err(|e| format!("Failed to copy theme: {}", e))?;
217 +
218 + Ok(parse_meta(&id, &table, true))
219 + }
220 +
221 + /// Export a theme to a user-chosen path.
222 + ///
223 + /// Finds the theme by ID in the given directories and copies the TOML file
224 + /// to `dest_path`.
225 + pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
226 + validate_theme_id(id)?;
227 +
228 + let (source, _) =
229 + find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
230 +
231 + std::fs::copy(&source, dest_path)
232 + .map_err(|e| format!("Failed to export theme: {}", e))?;
233 +
234 + Ok(())
235 + }
236 +
237 + /// Construct a dev fallback theme directory path.
238 + ///
239 + /// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and
240 + /// appends `"themes"`. Returns the path if the directory exists.
241 + pub fn dev_themes_dir(manifest_dir: &Path, levels: usize) -> Option<PathBuf> {
242 + let mut path = manifest_dir.to_path_buf();
243 + for _ in 0..levels {
244 + path = path.parent()?.to_path_buf();
245 + }
246 + let themes = path.join("MNW").join("shared").join("themes");
247 + if themes.is_dir() {
248 + Some(themes)
249 + } else {
250 + None
251 + }
252 + }
253 +
254 + #[cfg(test)]
255 + mod tests {
256 + use super::*;
257 + use std::fs;
258 +
259 + #[test]
260 + fn validate_theme_id_alphanumeric() {
261 + assert!(validate_theme_id("darkmode").is_ok());
262 + assert!(validate_theme_id("Theme123").is_ok());
263 + }
264 +
265 + #[test]
266 + fn validate_theme_id_hyphens_underscores() {
267 + assert!(validate_theme_id("dark-mode").is_ok());
268 + assert!(validate_theme_id("my_theme_v2").is_ok());
269 + assert!(validate_theme_id("a-b_c-d").is_ok());
270 + }
271 +
272 + #[test]
273 + fn validate_theme_id_rejects_spaces() {
274 + assert!(validate_theme_id("has space").is_err());
275 + }
276 +
277 + #[test]
278 + fn validate_theme_id_rejects_path_traversal() {
279 + assert!(validate_theme_id("../etc/passwd").is_err());
280 + assert!(validate_theme_id("foo/bar").is_err());
281 + }
282 +
283 + #[test]
284 + fn validate_theme_id_rejects_special_chars() {
285 + assert!(validate_theme_id("evil<script>").is_err());
286 + assert!(validate_theme_id("theme;drop").is_err());
287 + assert!(validate_theme_id("theme.toml").is_err());
288 + }
289 +
290 + #[test]
291 + fn validate_theme_id_empty_is_valid() {
292 + assert!(
293 + validate_theme_id("").is_ok(),
294 + "empty string has no invalid chars"
295 + );
296 + }
297 +
298 + #[test]
299 + fn parse_meta_with_name_and_variant() {
300 + let toml_str = r#"
301 + [meta]
302 + name = "Solarized Dark"
303 + variant = "light"
304 + "#;
305 + let table: toml::Table = toml_str.parse().unwrap();
306 + let meta = parse_meta("solarized", &table, false);
307 + assert_eq!(meta.id, "solarized");
308 + assert_eq!(meta.name, "Solarized Dark");
309 + assert_eq!(meta.variant, "light");
310 + assert!(!meta.is_custom);
311 + }
312 +
313 + #[test]
314 + fn parse_meta_defaults_to_id_and_dark() {
315 + let table: toml::Table = "".parse().unwrap();
316 + let meta = parse_meta("fallback", &table, true);
317 + assert_eq!(meta.name, "fallback");
318 + assert_eq!(meta.variant, "dark");
319 + assert!(meta.is_custom);
320 + }
321 +
322 + #[test]
323 + fn parse_meta_missing_variant_defaults_dark() {
324 + let toml_str = r#"
325 + [meta]
326 + name = "Minimal"
327 + "#;
328 + let table: toml::Table = toml_str.parse().unwrap();
329 + let meta = parse_meta("minimal", &table, false);
330 + assert_eq!(meta.name, "Minimal");
331 + assert_eq!(meta.variant, "dark");
332 + }
333 +
334 + #[test]
335 + fn extract_colors_from_toml() {
336 + let toml_str = r##"
337 + [background]
338 + primary = "#1e1e2e"
339 + secondary = "#313244"
340 +
341 + [foreground]
342 + primary = "#cdd6f4"
343 +
344 + [accent]
345 + primary = "#89b4fa"
346 +
347 + [border]
348 + primary = "#45475a"
349 + "##;
350 + let table: toml::Table = toml_str.parse().unwrap();
351 + let colors = extract_colors(&table);
352 + assert_eq!(colors.get("background.primary").unwrap(), "#1e1e2e");
353 + assert_eq!(colors.get("background.secondary").unwrap(), "#313244");
354 + assert_eq!(colors.get("foreground.primary").unwrap(), "#cdd6f4");
355 + assert_eq!(colors.get("accent.primary").unwrap(), "#89b4fa");
356 + assert_eq!(colors.get("border.primary").unwrap(), "#45475a");
357 + assert_eq!(colors.len(), 5);
358 + }
359 +
360 + #[test]
361 + fn extract_colors_empty_table() {
362 + let table: toml::Table = "".parse().unwrap();
363 + let colors = extract_colors(&table);
364 + assert!(colors.is_empty());
365 + }
366 +
367 + #[test]
368 + fn list_themes_from_dirs_finds_toml_files() {
369 + let dir = tempfile::tempdir().unwrap();
370 + let theme_content = r#"
371 + [meta]
372 + name = "Test Theme"
373 + variant = "dark"
374 + "#;
375 + fs::write(dir.path().join("test-theme.toml"), theme_content).unwrap();
376 + fs::write(dir.path().join("not-a-theme.txt"), "ignored").unwrap();
377 +
378 + let dirs = vec![(dir.path().to_path_buf(), false)];
379 + let themes = list_themes_from_dirs(&dirs);
380 + assert_eq!(themes.len(), 1);
381 + assert_eq!(themes[0].id, "test-theme");
382 + assert_eq!(themes[0].name, "Test Theme");
383 + }
384 +
385 + #[test]
386 + fn list_themes_later_dir_overrides() {
387 + let dir1 = tempfile::tempdir().unwrap();
388 + let dir2 = tempfile::tempdir().unwrap();
389 +
390 + let theme1 = "[meta]\nname = \"Original\"\nvariant = \"dark\"\n";
391 + let theme2 = "[meta]\nname = \"Override\"\nvariant = \"light\"\n";
392 +
393 + fs::write(dir1.path().join("same-id.toml"), theme1).unwrap();
394 + fs::write(dir2.path().join("same-id.toml"), theme2).unwrap();
395 +
396 + let dirs = vec![
397 + (dir1.path().to_path_buf(), false),
398 + (dir2.path().to_path_buf(), true),
399 + ];
400 + let themes = list_themes_from_dirs(&dirs);
401 + assert_eq!(themes.len(), 1);
402 + assert_eq!(themes[0].name, "Override");
403 + assert!(themes[0].is_custom);
404 + }
405 +
406 + #[test]
407 + fn find_theme_path_checks_reverse_priority() {
408 + let dir1 = tempfile::tempdir().unwrap();
409 + let dir2 = tempfile::tempdir().unwrap();
410 +
411 + fs::write(dir1.path().join("shared.toml"), "[meta]\n").unwrap();
412 + fs::write(dir2.path().join("shared.toml"), "[meta]\n").unwrap();
413 +
414 + let dirs = vec![
415 + (dir1.path().to_path_buf(), false),
416 + (dir2.path().to_path_buf(), true),
417 + ];
418 + let (path, is_custom) = find_theme_path(&dirs, "shared").unwrap();
419 + assert!(is_custom);
420 + assert_eq!(path, dir2.path().join("shared.toml"));
421 + }
422 +
423 + #[test]
424 + fn find_theme_path_returns_none_when_missing() {
425 + let dir = tempfile::tempdir().unwrap();
426 + let dirs = vec![(dir.path().to_path_buf(), false)];
427 + assert!(find_theme_path(&dirs, "nonexistent").is_none());
428 + }
429 +
430 + #[test]
431 + fn load_theme_full_round_trip() {
432 + let dir = tempfile::tempdir().unwrap();
433 + let theme_content = r##"
434 + [meta]
435 + name = "Full Theme"
436 + variant = "light"
437 +
438 + [background]
439 + primary = "#ffffff"
440 +
441 + [foreground]
442 + primary = "#000000"
443 +
444 + [accent]
445 + primary = "#ff0000"
446 + "##;
447 + fs::write(dir.path().join("full.toml"), theme_content).unwrap();
448 +
449 + let dirs = vec![(dir.path().to_path_buf(), false)];
450 + let theme = load_theme(&dirs, "full").unwrap();
451 + assert_eq!(theme.meta.name, "Full Theme");
452 + assert_eq!(theme.meta.variant, "light");
453 + assert_eq!(theme.colors.get("background.primary").unwrap(), "#ffffff");
454 + assert_eq!(theme.colors.get("foreground.primary").unwrap(), "#000000");
455 + assert_eq!(theme.colors.get("accent.primary").unwrap(), "#ff0000");
456 + }
457 +
458 + #[test]
459 + fn load_theme_rejects_invalid_id() {
460 + let dirs = vec![];
461 + assert!(load_theme(&dirs, "../evil").is_err());
462 + }
463 +
464 + #[test]
465 + fn load_theme_not_found() {
466 + let dir = tempfile::tempdir().unwrap();
467 + let dirs = vec![(dir.path().to_path_buf(), false)];
468 + assert!(load_theme(&dirs, "missing").is_err());
469 + }
470 +
471 + #[test]
472 + fn dev_themes_dir_walks_parents() {
473 + // Use a real path that exists
474 + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
475 + // Walking up from theme-common/ to Code/ (3 levels), MNW/shared/themes/ should exist
476 + let result = dev_themes_dir(&manifest, 3);
477 + // This test is environment-dependent, just verify it returns something valid
478 + if let Some(path) = result {
479 + assert!(path.ends_with("themes"));
480 + }
481 + }
482 +
483 + #[test]
484 + fn import_theme_valid() {
485 + let src_dir = tempfile::tempdir().unwrap();
486 + let custom_dir = tempfile::tempdir().unwrap();
487 +
488 + let content = r##"
489 + [meta]
490 + name = "Imported Theme"
491 + variant = "dark"
492 +
493 + [background]
494 + primary = "#1a1b26"
495 +
496 + [foreground]
497 + primary = "#c0caf5"
498 + "##;
499 + let src = src_dir.path().join("my-theme.toml");
500 + fs::write(&src, content).unwrap();
Lines truncated