Skip to main content

max / audiofiles

Adopt makeover ThemeSelection and ThemeDirs in the theme layer Platinum phase 0. theme.rs hand-rolled what makeover already ships: directory precedence, the bundled/custom dedup in list_themes, the by-id load, and the import copy. Those go through the crate now. The bundled tier stays embedded rather than moving to disk, because makeover::bundled_themes_dir resolves against its own manifest dir and would ship an AppImage with no themes. So ThemeDirs carries one tier and every by-id lookup goes through theme_source: on-disk first, embedded second. That order fixes a precedence bug in passing — set_theme checked bundled first while list_themes let custom win, so a custom theme saved under a bundled id was listed and badged as custom and then loaded the bundled file. New capability: follow the system. ThemeSelection is the standing choice, active_id is what it resolves to right now, and the two only differ under Follow. The ambient variant comes from egui's system_theme, which needs a live Context, so apply_theme settles it rather than init. Config key unchanged. ConfigKey::Theme is Posture::Synced, so renaming it to the convention's unprefixed name is a migration on a synced table; the encoding is adopted, the key is not. Consequence accepted: "system" travels between machines and each resolves to its own appearance.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 13:46 UTC
Signed with PGP, not checked
Commit: ce6f6aabd20a3f31b7148504f2c1961a5be759f6
Parent: 4365b00
8 files changed, +370 insertions, -232 deletions
M Cargo.lock +1 -1
@@ -2971,7 +2971,7 @@
2971 2971
2972 2972 [[package]]
2973 2973 name = "makeover"
2974 - version = "2.1.1"
2974 + version = "2.2.0"
2975 2975 dependencies = [
2976 2976 "include_dir",
2977 2977 "serde",
@@ -55,6 +55,26 @@
55 55 | `classification_color(c)` | domain | Sample-class palette. Stable across themes for muscle memory. |
56 56 | `piano_white_key()`, `piano_black_key()` | domain | Instrument panel only. |
57 57
58 + ### Theme selection
59 +
60 + What the user chose and what is being rendered are two different things, and the
61 + code keeps them apart.
62 +
63 + - `theme::chosen()` is the standing instruction: `Follow` (track the desktop's
64 + light/dark appearance) or `Fixed(id)`. This is what the picker ticks and what
65 + `ConfigKey::Theme` stores, as `"system"` or a theme id.
66 + - `theme::active_id()` is what that currently resolves to. It is what gets
67 + exported, and it changes under a `Follow` choice without the choice changing.
68 +
69 + Resolution is `makeover`'s (`ThemeSelection::resolve`), against
70 + `ThemeDefaults::new("audiofiles", "tokyonight").high_contrast("high-contrast")`.
71 + A `Fixed` id naming a theme that is no longer installed resolves the same way
72 + `Follow` does rather than failing.
73 +
74 + The ambient mode is only knowable from a live `egui::Context`, so `apply_theme`
75 + settles it, not `init`. `ConfigKey::Theme` is `Posture::Synced`, so the choice
76 + travels between a user's machines and each resolves it against its own desktop.
77 +
58 78 ### Spacing tokens
59 79
60 80 Never a literal `add_space(N.0)`. Pick by what the space separates, not by how big it should be; the size is a consequence of the name.
@@ -29,21 +29,6 @@
29 29 FileNotFound(PathBuf),
30 30 }
31 31
32 - /// Errors from theme file loading.
33 - #[derive(Error, Debug)]
34 - pub enum ThemeError {
35 - #[error("failed to read {path}: {source}")]
36 - Read {
37 - path: PathBuf,
38 - source: std::io::Error,
39 - },
40 - #[error("failed to parse {path}: {source}")]
41 - Parse {
42 - path: PathBuf,
43 - source: toml::de::Error,
44 - },
45 - }
46 -
47 32 #[cfg(test)]
48 33 mod tests {
49 34 use super::*;
@@ -65,15 +50,6 @@
65 50 assert!(msg.contains("not found"));
66 51 }
67 52
68 - #[test]
69 - fn theme_error_display() {
70 - let err = ThemeError::Read {
71 - path: PathBuf::from("theme.toml"),
72 - source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
73 - };
74 - assert!(err.to_string().contains("theme.toml"));
75 - }
76 -
77 53 #[test]
78 54 fn preview_error_variants_exhaustive() {
79 55 // Verify all variants construct without panic
@@ -405,12 +405,19 @@
405 405 }
406 406 }
407 407
408 - /// Save the current theme ID to the user_config table.
408 + /// Save the theme selection to the user_config table.
409 + ///
410 + /// The selection, not the resolved id: `"system"` is what makes the choice
411 + /// survive a system that flips to dark overnight. `ConfigKey::Theme` is
412 + /// `Posture::Synced`, so it travels between a user's machines and each one
413 + /// resolves it against its own appearance.
409 414 pub fn save_theme_preference(&self) {
410 415 super::log_backend_err(
411 416 "set_config theme",
412 - self.backend
413 - .set_config(crate::backend::ConfigKey::Theme, &self.current_theme_id),
417 + self.backend.set_config(
418 + crate::backend::ConfigKey::Theme,
419 + self.theme_selection.as_str(),
420 + ),
414 421 );
415 422 }
416 423
@@ -194,8 +194,10 @@
194 194 /// Set when an inline sidebar editor (collection/tag create or rename) opens,
195 195 /// so the text field auto-focuses on its first frame (P2 visible-focus gap).
196 196 pub focus_inline_editor: bool,
197 - // Theme
198 - pub current_theme_id: String,
197 + // Theme. The user's standing choice, not the theme being rendered: those
198 + // differ whenever the choice is "follow the system". The rendered id lives
199 + // in `ui::theme`, which is the only thing that knows the ambient mode.
200 + pub theme_selection: crate::ui::theme::ThemeSelection,
199 201
200 202 // Collections
201 203 pub collections_ui: CollectionsUiState,
@@ -296,13 +298,16 @@
296 298 Vec::new()
297 299 });
298 300
299 - // Load saved theme preference
300 - let theme_id = backend
301 + // Load the saved theme selection. Nothing stored means follow the
302 + // system, which is what a first run should do; the old behaviour of
303 + // defaulting to the app's own light skin survives as the light half of
304 + // that, so an existing install looks the same on a light desktop.
305 + let stored_theme = backend
301 306 .get_config(crate::backend::ConfigKey::Theme)
302 307 .ok()
303 - .flatten()
304 - .unwrap_or_else(|| "audiofiles".to_string());
305 - crate::ui::theme::init(Some(&theme_id));
308 + .flatten();
309 + let theme_selection = crate::ui::theme::ThemeSelection::parse(stored_theme.as_deref());
310 + crate::ui::theme::init(stored_theme.as_deref());
306 311
307 312 // Load preview settings
308 313 let loop_enabled = backend
@@ -456,7 +461,7 @@
456 461 focus_search: false,
457 462 focus_tag_input: false,
458 463 focus_inline_editor: false,
459 - current_theme_id: theme_id,
464 + theme_selection,
460 465 collections_ui: CollectionsUiState {
461 466 collections: Arc::new(collections_list),
462 467 ..Default::default()
@@ -474,18 +474,37 @@
474 474 .default_open(false)
475 475 .show(ui, |ui| {
476 476 let themes = theme::list_themes();
477 - let current_name = themes
477 + let active_id = theme::active_id();
478 + let active_name = themes
478 479 .iter()
479 - .find(|t| t.id == state.current_theme_id)
480 - .map_or(state.current_theme_id.as_str(), |t| t.name.as_str());
480 + .find(|t| t.id == active_id)
481 + .map_or(active_id.as_str(), |t| t.name.as_str());
482 + // Following the system is a standing instruction, so name both
483 + // halves: what was asked for, and what it currently comes out as.
484 + let current_name = match &state.theme_selection {
485 + theme::ThemeSelection::Follow => format!("System ({active_name})"),
486 + theme::ThemeSelection::Fixed(_) => active_name.to_string(),
487 + };
481 488
482 - let mut new_theme_id = None;
489 + let mut new_selection = None;
483 490 ui.horizontal(|ui| {
484 491 ui.label("Theme:");
485 492 egui::ComboBox::from_id_salt("settings_theme_select")
486 - .selected_text(current_name)
493 + .selected_text(&current_name)
487 494 .width(200.0)
488 495 .show_ui(ui, |ui| {
496 + let follows = matches!(state.theme_selection, theme::ThemeSelection::Follow);
497 + if ui
498 + .selectable_label(follows, "Follow the system")
499 + .on_hover_text(
500 + "Use the desktop's light or dark appearance, and change with it",
501 + )
502 + .clicked()
503 + {
504 + new_selection = Some(theme::ThemeSelection::Follow);
505 + }
506 + ui.separator();
507 +
489 508 for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
490 509 // Pair each theme with its muted-text contrast tier and
491 510 // sort most-accessible-first, so readable themes surface
@@ -502,7 +521,8 @@
502 521 group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier));
503 522 ui.label(egui::RichText::new(label).small().strong());
504 523 for (t, tier) in group {
505 - let is_selected = t.id == state.current_theme_id;
524 + let is_selected =
525 + state.theme_selection == theme::ThemeSelection::Fixed(t.id.clone());
506 526 ui.horizontal(|ui| {
507 527 // Color swatch (bg + accent)
508 528 if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
@@ -521,7 +541,8 @@
521 541 t.name.clone()
522 542 };
523 543 if ui.selectable_label(is_selected, display).clicked() {
524 - new_theme_id = Some(t.id.clone());
544 + new_selection =
545 + Some(theme::ThemeSelection::Fixed(t.id.clone()));
525 546 }
526 547 // Contrast-tier badge (text legibility, not the
527 548 // theme's accent palette).
@@ -545,9 +566,9 @@
545 566 });
546 567 });
547 568
548 - if let Some(id) = new_theme_id {
549 - theme::set_theme(&id);
550 - state.current_theme_id = id;
569 + if let Some(chosen) = new_selection {
570 + theme::set_selection(chosen.clone());
571 + state.theme_selection = chosen;
551 572 state.save_theme_preference();
552 573 }
553 574
@@ -950,44 +971,35 @@
950 971 .to_string();
951 972 return;
952 973 };
953 - match theme::load_theme(&path) {
954 - Ok(_colors) => {
955 - let id = path
956 - .file_stem()
957 - .and_then(|os| os.to_str())
958 - .unwrap_or("custom")
959 - .to_string();
960 - if let Err(e) = std::fs::create_dir_all(&custom_dir) {
961 - tracing::error!("Failed to create custom themes dir: {e}");
962 - s.status = format!("Theme import failed: {e}");
963 - } else if let Err(e) =
964 - std::fs::copy(&path, custom_dir.join(format!("{id}.toml")))
965 - {
966 - tracing::error!("Failed to copy theme: {e}");
967 - s.status = format!("Theme import failed: {e}");
968 - } else {
969 - theme::set_theme(&id);
970 - s.current_theme_id.clone_from(&id);
971 - s.save_theme_preference();
972 - s.status = format!("Imported theme: {id}");
973 - }
974 + // Validate, name and copy in one call: makeover
975 + // refuses a file with no color section and an id
976 + // that would escape the directory, neither of
977 + // which the hand-rolled copy here checked.
978 + match makeover::import_theme(&path, &custom_dir) {
979 + Ok(meta) => {
980 + let chosen = theme::ThemeSelection::Fixed(meta.id.clone());
981 + theme::set_selection(chosen.clone());
982 + s.theme_selection = chosen;
983 + s.save_theme_preference();
984 + s.status = format!("Imported theme: {}", meta.id);
974 985 }
975 986 Err(e) => {
976 - tracing::error!("Failed to load theme: {e}");
987 + tracing::error!("Failed to import theme: {e}");
977 988 s.status = format!("Theme import failed: {e}");
978 989 }
979 990 }
980 991 });
981 992 }
982 993 if ui.button("Export Current...").clicked() {
983 - let file_name = format!("{}.toml", state.current_theme_id);
994 + // The resolved id, not the selection: "system" is not a
995 + // theme and there would be nothing to write.
996 + let active_id = theme::active_id();
984 997 state.dialogs.save_file(
985 998 "Export Theme",
986 - file_name,
999 + format!("{active_id}.toml"),
987 1000 &[("Theme", &["toml"])],
988 - |s, path| {
989 - if let Some(content) = theme::export_theme_content(&s.current_theme_id)
990 - {
1001 + move |s, path| {
1002 + if let Some(content) = theme::export_theme_content(&active_id) {
991 1003 match std::fs::write(&path, content) {
992 1004 Ok(()) => {
993 1005 s.status = format!("Exported theme to {}", path.display());
@@ -998,14 +1010,8 @@
998 1010 }
999 1011 }
1000 1012 } else {
1001 - tracing::warn!(
1002 - "Theme '{}' not found for export",
1003 - s.current_theme_id
1004 - );
1005 - s.status = format!(
1006 - "Theme export failed: '{}' not found.",
1007 - s.current_theme_id
1008 - );
1013 + tracing::warn!("Theme '{active_id}' not found for export");
1014 + s.status = format!("Theme export failed: '{active_id}' not found.");
1009 1015 }
1010 1016 },
1011 1017 );
@@ -315,11 +315,9 @@
315 315 ui.label("Cloud sync is unavailable.");
316 316 ui.add_space(theme::space::peer());
317 317 ui.label(
318 - egui::RichText::new(
319 - "Open a vault to enable sync.",
320 - )
321 - .small()
322 - .weak(),
318 + egui::RichText::new("Open a vault to enable sync.")
319 + .small()
320 + .weak(),
323 321 );
324 322 },
325 323 );
@@ -4,14 +4,22 @@
4 4 //! that return `Color32` values. Derived colors (row stripes, selection highlight)
5 5 //! are computed from the base palette.
6 6 //!
7 - //! Themes are embedded at compile time from `themes/` within this crate. Users can also
8 - //! drop custom `.toml` files into their platform config directory
9 - //! (`<config>/audiofiles/themes/`) which override bundled themes by ID.
7 + //! The bundled themes come from `makeover`, embedded at compile time. Users can
8 + //! also drop custom `.toml` files into their platform config directory
9 + //! (`<config>/audiofiles/themes/`), which override bundled themes by ID.
10 + //!
11 + //! Which theme is active is two questions, not one: what the user chose
12 + //! ([`chosen`], `Follow` or `Fixed`) and what that resolves to right now
13 + //! ([`active_id`]). Following the system means the second changes while the
14 + //! first does not. See `docs/design-system.md`.
15 + //!
16 + //! <!-- wiki: af-platinum -->
10 17
11 18 use egui::Color32;
19 + use makeover::Variant;
12 20 use parking_lot::RwLock;
13 21 use std::collections::HashMap;
14 - use std::path::{Path, PathBuf};
22 + use std::path::PathBuf;
15 23 use std::sync::LazyLock;
16 24 use std::sync::atomic::{AtomicU32, Ordering};
17 25 use tracing::{error, warn};
@@ -185,7 +193,7 @@
185 193 }
186 194 }
187 195
188 - pub use makeover::ThemeMeta;
196 + pub use makeover::{ThemeMeta, ThemeSelection};
189 197
190 198 static THEME: LazyLock<RwLock<ThemeColors>> = LazyLock::new(|| RwLock::new(ThemeColors::default()));
191 199
@@ -359,6 +367,41 @@
359 367 dirs::config_dir().map(|c| c.join("audiofiles").join("themes"))
360 368 }
361 369
370 + /// The on-disk search path, in makeover's precedence order.
371 + ///
372 + /// One tier, because the bundled set is not on disk: it is embedded at compile
373 + /// time through [`BUNDLED_THEMES`], which is the only form that survives into a
374 + /// shipped AppImage (`makeover::bundled_themes_dir` resolves against the
375 + /// crate's manifest directory, which exists on a dev box and nowhere else).
376 + /// So the builder is doing one job here rather than three, and it is still
377 + /// worth going through: id validation, traversal refusal and "later wins" are
378 + /// the library's to state, not this file's to restate.
379 + fn theme_dirs() -> Vec<(PathBuf, bool)> {
380 + makeover::ThemeDirs::new()
381 + .custom(custom_themes_dir())
382 + .build()
383 + }
384 +
385 + /// A theme's TOML source by id, or `None` if no tier has it.
386 + ///
387 + /// On-disk themes win over embedded ones of the same id. That is the order
388 + /// [`list_themes`] has always shown in the picker and the order the old
389 + /// `set_theme` had backwards: it checked bundled first, so a custom theme
390 + /// saved under a bundled id was listed as custom, badged as custom, and then
391 + /// silently loaded the bundled file.
392 + fn theme_source(id: &str) -> Option<String> {
393 + if let Some((path, _)) = makeover::find_theme_path(&theme_dirs(), id) {
394 + match std::fs::read_to_string(&path) {
395 + Ok(content) => return Some(content),
396 + Err(e) => warn!("Failed to read custom theme '{id}': {e}"),
397 + }
398 + }
399 + BUNDLED_THEMES
400 + .iter()
401 + .find(|(bundled_id, _)| *bundled_id == id)
402 + .map(|(_, content)| (*content).to_string())
403 + }
404 +
362 405 /// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of
363 406 /// `content_muted` against the theme's darkest and lightest panel backgrounds. We
364 407 /// surface this in the picker rather than overriding curated palettes' colors,
@@ -384,17 +427,9 @@
384 427 }
385 428 }
386 429
387 -
388 430 /// Load a theme's full color set by id (bundled or custom on disk).
389 431 fn theme_colors_for(id: &str) -> Option<ThemeColors> {
390 - for (bundled_id, content) in BUNDLED_THEMES.iter() {
391 - if *bundled_id == id {
392 - return parse_theme(content).ok();
393 - }
394 - }
395 - let dir = custom_themes_dir()?;
396 - let content = std::fs::read_to_string(dir.join(format!("{id}.toml"))).ok()?;
397 - parse_theme(&content).ok()
432 + parse_theme(&theme_source(id)?).ok()
398 433 }
399 434
400 435 /// Compute the muted-text contrast tier for a theme id. Defaults to `Standard`
@@ -416,52 +451,22 @@
416 451
417 452 /// List all available themes (bundled + custom). Custom themes override bundled by ID.
418 453 pub fn list_themes() -> Vec<ThemeMeta> {
419 - let mut themes: Vec<ThemeMeta> = Vec::new();
420 - let mut seen = std::collections::HashSet::new();
454 + let mut themes: Vec<ThemeMeta> = BUNDLED_THEMES
455 + .iter()
456 + .filter_map(|(id, content)| {
457 + let table: toml::Table = content.parse().ok()?;
458 + Some(makeover::parse_meta(id, &table, false))
459 + })
460 + .collect();
421 461
422 - // Bundled themes first
423 - for (id, content) in BUNDLED_THEMES.iter() {
424 - let table: toml::Table = match content.parse() {
425 - Ok(t) => t,
426 - Err(_) => continue,
427 - };
428 - seen.insert(id.to_string());
429 - themes.push(makeover::parse_meta(id, &table, false));
430 - }
431 -
432 - // Custom themes from config dir (override bundled by ID)
433 - if let Some(dir) = custom_themes_dir()
434 - && let Ok(entries) = std::fs::read_dir(&dir)
435 - {
436 - for entry in entries.flatten() {
437 - let path = entry.path();
438 - if path.extension().is_some_and(|e| e == "toml") {
439 - let id = path
440 - .file_stem()
441 - .unwrap_or_default()
442 - .to_string_lossy()
443 - .to_string();
444 - if let Ok(content) = std::fs::read_to_string(&path) {
445 - let table: toml::Table = match content.parse() {
446 - Ok(t) => t,
447 - Err(_) => continue,
448 - };
449 - let meta = makeover::parse_meta(&id, &table, true);
450 - if seen.contains(&id) {
451 - if let Some(existing) = themes.iter_mut().find(|t| t.id == id) {
452 - existing.name = meta.name;
453 - existing.variant = meta.variant;
454 - existing.is_custom = true;
455 - }
456 - } else {
457 - seen.insert(id.clone());
458 - themes.push(meta);
459 - }
460 - }
461 - }
462 + for custom in makeover::list_themes_from_dirs(&theme_dirs()) {
463 + match themes.iter_mut().find(|t| t.id == custom.id) {
464 + Some(shadowed) => *shadowed = custom,
465 + None => themes.push(custom),
462 466 }
463 467 }
464 468
469 + themes.sort_by(|a, b| a.name.cmp(&b.name));
465 470 themes
466 471 }
467 472
@@ -538,84 +543,14 @@
538 543 })
539 544 }
540 545
541 - /// Load a theme from a TOML file path. Returns the parsed ThemeColors.
542 - pub fn load_theme(path: &Path) -> Result<ThemeColors, crate::error::ThemeError> {
543 - let content = std::fs::read_to_string(path).map_err(|e| crate::error::ThemeError::Read {
544 - path: path.to_path_buf(),
545 - source: e,
546 - })?;
547 - parse_theme(&content).map_err(|e| crate::error::ThemeError::Parse {
548 - path: path.to_path_buf(),
549 - source: e,
550 - })
551 - }
552 -
553 - /// Initialise the active theme. If `id` is provided, loads that theme;
554 - /// otherwise falls back to "tokyonight".
555 - pub fn init(id: Option<&str>) {
556 - set_theme(id.unwrap_or("audiofiles"));
557 - }
558 -
559 - /// Switch the active theme. Checks bundled themes first, then custom directory.
560 - pub fn set_theme(id: &str) {
561 - // Try bundled themes first
562 - for (bundled_id, content) in BUNDLED_THEMES.iter() {
563 - if *bundled_id == id {
564 - match parse_theme(content) {
565 - Ok(colors) => {
566 - install_theme(colors);
567 - return;
568 - }
569 - Err(e) => {
570 - error!("Failed to parse bundled theme '{id}': {e}");
571 - return;
572 - }
573 - }
574 - }
575 - }
576 -
577 - // Try custom themes directory
578 - if let Some(dir) = custom_themes_dir() {
579 - let path = dir.join(format!("{id}.toml"));
580 - if path.exists() {
581 - match load_theme(&path) {
582 - Ok(colors) => {
583 - install_theme(colors);
584 - return;
585 - }
586 - Err(e) => {
587 - error!("Failed to load custom theme '{id}': {e}");
588 - return;
589 - }
590 - }
591 - }
592 - }
593 -
594 - warn!("Theme '{id}' not found; keeping current theme");
595 - }
596 -
597 546 /// Get preview colors (background, accent, foreground) for a theme by ID.
598 547 /// Returns (surface_page, action, content) or None if theme can't be loaded.
548 + ///
549 + /// Not `makeover::load_theme_preview`, which reads a path: the bundled set is
550 + /// embedded here, so half the ids in the picker have no file to point it at.
551 + /// The swatch keys are the same ones it reads.
599 552 pub fn theme_preview_colors(id: &str) -> Option<(Color32, Color32, Color32)> {
600 - let content = {
601 - // Check bundled first
602 - let mut found = None;
603 - for (bundled_id, c) in BUNDLED_THEMES.iter() {
604 - if *bundled_id == id {
605 - found = Some(c.to_string());
606 - break;
607 - }
608 - }
609 - if found.is_none()
610 - && let Some(dir) = custom_themes_dir()
611 - {
612 - let path = dir.join(format!("{id}.toml"));
613 - found = std::fs::read_to_string(&path).ok();
614 - }
615 - found?
616 - };
617 -
618 - let table: toml::Table = content.parse().ok()?;
553 + let table: toml::Table = theme_source(id)?.parse().ok()?;
619 554 let colors = makeover::extract_colors(&table);
620 555 let bg = get_color(&colors, "surface.page").unwrap_or(Color32::from_rgb(30, 30, 30));
621 556 let accent = get_color(&colors, "action.primary").unwrap_or(Color32::from_rgb(100, 100, 255));
@@ -623,24 +558,105 @@
623 558 Some((bg, accent, fg))
624 559 }
625 560
626 - /// Export a theme's TOML content by ID. Checks custom directory first, then
627 - /// bundled themes. Returns the raw TOML string or `None` if not found.
561 + /// Export a theme's TOML content by ID, or `None` if no tier has it.
628 562 pub fn export_theme_content(id: &str) -> Option<String> {
629 - // Check custom directory first
630 - if let Some(dir) = custom_themes_dir() {
631 - let path = dir.join(format!("{id}.toml"));
632 - if let Ok(content) = std::fs::read_to_string(&path) {
633 - return Some(content);
634 - }
635 - }
563 + theme_source(id)
564 + }
636 565
637 - for (bundled_id, content) in BUNDLED_THEMES.iter() {
638 - if *bundled_id == id {
639 - return Some(content.to_string());
640 - }
641 - }
566 + // --- The active selection ---
642 567
643 - None
568 + /// The theme this app is named for, and the one a light system gets.
569 + pub const DEFAULT_THEME_ID: &str = "audiofiles";
570 +
571 + /// The dark counterpart. There is no `audiofiles-dark`, and this is the theme
572 + /// the app already named as its fallback before the skin existed.
573 + const DEFAULT_DARK_THEME_ID: &str = "tokyonight";
574 +
575 + /// What the user chose, what that resolved to, and the ambient mode it was
576 + /// resolved against.
577 + ///
578 + /// The three travel together because a resolution is only good for one ambient
579 + /// variant: `Follow` means "decide again when the system flips", and deciding
580 + /// again costs a directory scan and a TOML parse, so [`apply_theme`] compares
581 + /// before it acts rather than resolving every frame.
582 + struct Selection {
583 + chosen: ThemeSelection,
584 + active_id: String,
585 + ambient: Variant,
586 + }
587 +
588 + static SELECTION: LazyLock<RwLock<Selection>> = LazyLock::new(|| {
589 + RwLock::new(Selection {
590 + chosen: ThemeSelection::default(),
591 + active_id: DEFAULT_THEME_ID.to_string(),
592 + // Nothing has asked the windowing system yet, and light is what this
593 + // app's own theme is. The first `apply_theme` corrects it.
594 + ambient: Variant::Light,
595 + })
596 + });
597 +
598 + /// The themes a `Follow` choice lands on, and where a `Fixed` choice falls back
599 + /// to when it names a theme that is no longer installed.
600 + fn defaults() -> makeover::ThemeDefaults {
601 + makeover::ThemeDefaults::new(DEFAULT_THEME_ID, DEFAULT_DARK_THEME_ID)
602 + .high_contrast("high-contrast")
603 + }
604 +
605 + /// The system's light/dark preference, as makeover spells it.
606 + ///
607 + /// egui reports `None` when the windowing system never told it. makeover's own
608 + /// rule for a variant it cannot read is dark, so follow that rather than
609 + /// inventing a second answer to the same question.
610 + fn ambient_variant(ctx: &egui::Context) -> Variant {
611 + match ctx.system_theme() {
612 + Some(egui::Theme::Light) => Variant::Light,
613 + Some(egui::Theme::Dark) | None => Variant::Dark,
614 + }
615 + }
616 +
617 + /// Resolve a choice against the ambient mode and install what comes out.
618 + /// Returns the id actually installed.
619 + fn resolve_and_install(chosen: &ThemeSelection, ambient: Variant) -> String {
620 + let id = chosen.resolve(ambient, &defaults(), &list_themes());
621 + match theme_source(&id) {
622 + Some(content) => match parse_theme(&content) {
623 + Ok(colors) => install_theme(colors),
624 + Err(e) => error!("Failed to parse theme '{id}': {e}"),
625 + },
626 + None => warn!("Theme '{id}' not found; keeping current theme"),
627 + }
628 + id
629 + }
630 +
631 + /// Initialise the active theme from a stored selection.
632 + ///
633 + /// `None`, empty, or `"system"` all mean follow the system. Anything else is a
634 + /// theme id; one naming a theme that has since been deleted resolves the same
635 + /// way `Follow` does rather than failing.
636 + pub fn init(stored: Option<&str>) {
637 + set_selection(ThemeSelection::parse(stored));
638 + }
639 +
640 + /// Record a new choice and install what it resolves to, returning that id.
641 + pub fn set_selection(chosen: ThemeSelection) -> String {
642 + let ambient = SELECTION.read().ambient;
643 + let id = resolve_and_install(&chosen, ambient);
644 + let mut selection = SELECTION.write();
645 + selection.chosen = chosen;
646 + selection.active_id.clone_from(&id);
647 + id
648 + }
649 +
650 + /// The user's standing choice, which is what gets persisted and what the
651 + /// picker ticks. Named apart from [`selection`], the selected-row color.
652 + pub fn chosen() -> ThemeSelection {
653 + SELECTION.read().chosen.clone()
654 + }
655 +
656 + /// The id of the theme actually installed, which is what gets exported and
657 + /// what a `Follow` choice currently resolves to.
658 + pub fn active_id() -> String {
659 + SELECTION.read().active_id.clone()
644 660 }
645 661
646 662 // --- Classification colors (domain-specific, not from theme TOML) ---
@@ -691,6 +707,20 @@
691 707 // to this surface's quantum.
692 708 PIXELS_PER_POINT.store(ctx.pixels_per_point().to_bits(), Ordering::Relaxed);
693 709
710 + // The ambient mode is only knowable from a live context, so a `Follow`
711 + // choice settles here rather than at init: egui learns the system
712 + // appearance from the windowing system and reports it through the context,
713 + // which nothing has at the point the stored selection is read. A flip is
714 + // rare and re-resolving is not free, so this compares first.
715 + let ambient = ambient_variant(ctx);
716 + if SELECTION.read().ambient != ambient {
717 + let chosen = chosen();
718 + let id = resolve_and_install(&chosen, ambient);
719 + let mut selection = SELECTION.write();
720 + selection.ambient = ambient;
721 + selection.active_id = id;
722 + }
723 +
694 724 let t = THEME.read();
695 725 // Pick the base preset by luminance of the page background: a light theme
696 726 // needs `dark_mode = false` so `RichText::strong()` renders BLACK (readable
@@ -1183,6 +1213,102 @@
1183 1213 }
1184 1214 }
1185 1215
1216 + // Theme selection
1217 + //
1218 + // These resolve against the bundled set rather than `list_themes()`, which
1219 + // also scans the running user's config directory: a developer with a custom
1220 + // theme installed should not get different test results from CI.
1221 +
1222 + fn bundled_metas() -> Vec<ThemeMeta> {
1223 + BUNDLED_THEMES
1224 + .iter()
1225 + .filter_map(|(id, content)| {
1226 + let table: toml::Table = content.parse().ok()?;
1227 + Some(makeover::parse_meta(id, &table, false))
1228 + })
1229 + .collect()
1230 + }
1231 +
1232 + #[test]
1233 + fn the_named_defaults_are_actually_bundled() {
1234 + // A typo here degrades quietly: `resolve` falls through to "any theme
1235 + // of that variant", so the app would come up themed but not its own.
1236 + let available = bundled_metas();
1237 + for id in [DEFAULT_THEME_ID, DEFAULT_DARK_THEME_ID, "high-contrast"] {
1238 + assert!(
1239 + available.iter().any(|meta| meta.id == id),
1240 + "default `{id}` is not in the bundled set"
1241 + );
1242 + }
1243 + }
1244 +
1245 + #[test]
1246 + fn following_the_system_lands_on_this_app_in_light_and_its_counterpart_in_dark() {
1247 + let available = bundled_metas();
1248 + let follow = ThemeSelection::Follow;
1249 + assert_eq!(
1250 + follow.resolve(Variant::Light, &defaults(), &available),
1251 + DEFAULT_THEME_ID
1252 + );
1253 + assert_eq!(
1254 + follow.resolve(Variant::Dark, &defaults(), &available),
1255 + DEFAULT_DARK_THEME_ID
1256 + );
1257 + assert_eq!(
1258 + follow.resolve(Variant::HighContrast, &defaults(), &available),
1259 + "high-contrast"
1260 + );
1261 + }
1262 +
1263 + #[test]
1264 + fn a_fixed_choice_wins_over_the_ambient_mode() {
1265 + let available = bundled_metas();
1266 + let fixed = ThemeSelection::Fixed("nord".to_string());
1267 + // A dark theme chosen explicitly stays chosen on a light desktop.
1268 + assert_eq!(
1269 + fixed.resolve(Variant::Light, &defaults(), &available),
1270 + "nord"
1271 + );
1272 + }
1273 +
1274 + #[test]
1275 + fn a_fixed_choice_that_was_deleted_falls_back_rather_than_failing() {
1276 + let available = bundled_metas();
1277 + let gone = ThemeSelection::Fixed("a-theme-the-user-deleted".to_string());
1278 + assert_eq!(
1279 + gone.resolve(Variant::Light, &defaults(), &available),
1280 + DEFAULT_THEME_ID
1281 + );
1282 + }
1283 +
1284 + #[test]
1285 + fn stored_selections_round_trip() {
1286 + // What `init` reads out of the synced config table, and what
1287 + // `save_theme_preference` writes back into it.
1288 + assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow);
1289 + assert_eq!(ThemeSelection::parse(Some("")), ThemeSelection::Follow);
1290 + assert_eq!(
1291 + ThemeSelection::parse(Some("system")),
1292 + ThemeSelection::Follow
1293 + );
1294 + assert_eq!(ThemeSelection::Follow.as_str(), "system");
1295 +
1296 + let fixed = ThemeSelection::parse(Some("nord"));
1297 + assert_eq!(fixed, ThemeSelection::Fixed("nord".to_string()));
1298 + assert_eq!(fixed.as_str(), "nord");
1299 + }
1300 +
1301 + #[test]
1302 + fn an_embedded_theme_is_reachable_by_id() {
1303 + // The bundled tier is embedded, not on disk, so every lookup path goes
1304 + // through `theme_source` rather than `makeover::load_theme`.
1305 + assert!(theme_source(DEFAULT_THEME_ID).is_some());
1306 + assert!(theme_source(DEFAULT_DARK_THEME_ID).is_some());
1307 + assert!(theme_source("a-theme-that-does-not-exist").is_none());
1308 + assert!(theme_preview_colors(DEFAULT_THEME_ID).is_some());
1309 + assert!(export_theme_content(DEFAULT_THEME_ID).is_some());
1310 + }
1311 +
1186 1312 #[test]
1187 1313 fn bundled_theme_colors_are_not_all_black() {
1188 1314 // Sanity check: a properly parsed theme shouldn't have all-black fields