//! Shared theme loading + intent resolution for TOML-based theme files. //! //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the //! MNW web server. Themes are authored by **intent** ("human design"): colors are //! declared by role (surface / content / action / status / line / category), not //! by hue. This crate is the single place that resolves an authored theme into a //! full set of intent tokens — including the derived interactive states //! (hover/active/selection/row-stripe/contrast) that each app used to recompute //! itself — and emits them as CSS variables or RGB tuples. //! //! Theme file shape: //! ```text //! [meta] //! name = "Nord" //! variant = "dark" # or "light" //! //! [surface] # container backgrounds by role/elevation //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252" //! //! [content] # text/ink by emphasis //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88" //! //! [action] # interactive / brand color //! primary = "#81a1c1" //! //! [status] # state semantics //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0" //! //! [line] //! border = "#4c566a" //! //! [category] # distinct decorative colors for tags/badges/charts //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1" //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0" //! ``` // Color-space math: single-letter channel names (r/g/b/l/m/s) and the published // high-precision OKLab/sRGB matrix constants are the domain vocabulary here. #![allow(clippy::many_single_char_names, clippy::unreadable_literal)] use serde::Serialize; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; /// The color sections an authored theme may declare. pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"]; /// Theme metadata parsed from the `[meta]` section. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeMeta { pub id: String, pub name: String, pub variant: String, pub is_custom: bool, } /// A loaded theme: metadata plus the authored colors, flattened to dotted keys /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub meta: ThemeMeta, pub colors: HashMap, } // ============================================================================ // Color math — perceptual (OKLab) derivations + WCAG contrast. // // Interactive states (hover/active/selection/surfaces) are derived in OKLab so // equal steps look equal across every theme's hues (Ottosson 2020; the modern // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive // luminance threshold, so the choice actually meets AA where achievable. // This is the single source of truth shared by every product. // ============================================================================ /// An sRGB color. Hex round-trips losslessly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rgb { pub r: u8, pub g: u8, pub b: u8, } impl Rgb { /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise. pub fn from_hex(s: &str) -> Option { let h = s.strip_prefix('#')?; let (r, g, b) = match h.len() { 6 => ( u8::from_str_radix(&h[0..2], 16).ok()?, u8::from_str_radix(&h[2..4], 16).ok()?, u8::from_str_radix(&h[4..6], 16).ok()?, ), 3 => { let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17); (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?) } _ => return None, }; Some(Rgb { r, g, b }) } /// Lowercase `#rrggbb`. pub fn to_hex(self) -> String { format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) } pub fn tuple(self) -> (u8, u8, u8) { (self.r, self.g, self.b) } } /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes. #[derive(Clone, Copy, Debug)] pub struct Oklab { pub l: f32, pub a: f32, pub b: f32, } fn srgb_to_linear(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn linear_to_srgb(c: f32) -> u8 { let c = c.clamp(0.0, 1.0); let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round().clamp(0.0, 255.0) as u8 } impl Rgb { /// Convert to OKLab (Ottosson's sRGB matrices). /// /// The matrix coefficients are quoted at their published precision so they /// can be diffed against the reference. `f32` rounds them at compile time; /// truncating the literals would only make them harder to check. #[allow(clippy::excessive_precision)] pub fn to_oklab(self) -> Oklab { let (r, g, b) = ( srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b), ); let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); Oklab { l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, } } /// Convert from OKLab back to the nearest in-gamut sRGB. /// /// Published precision, as in [`Rgb::to_oklab`]. #[allow(clippy::excessive_precision)] pub fn from_oklab(c: Oklab) -> Rgb { let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b; let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b; let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b; let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); Rgb { r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), } } } /// WCAG 2.x relative luminance of an sRGB color. fn rel_luminance(c: Rgb) -> f32 { 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) } /// WCAG 2.x contrast ratio between two colors, in [1, 21]. pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 { let (la, lb) = (rel_luminance(a), rel_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Pick black or white for legible text on `bg`, by the higher WCAG contrast /// ratio (so the choice meets AA wherever the background allows it). pub fn readable_on(bg: Rgb) -> Rgb { let white = Rgb { r: 255, g: 255, b: 255, }; let black = Rgb { r: 0, g: 0, b: 0 }; if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black } } /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens. pub fn lighten(c: Rgb, delta: f32) -> Rgb { let mut lab = c.to_oklab(); lab.l = (lab.l + delta).clamp(0.0, 1.0); Rgb::from_oklab(lab) } /// Shift OKLab lightness down by `delta` (perceptually uniform). pub fn darken(c: Rgb, delta: f32) -> Rgb { lighten(c, -delta) } /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend). pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { let (x, y) = (a.to_oklab(), b.to_oklab()); Rgb::from_oklab(Oklab { l: x.l + (y.l - x.l) * t, a: x.a + (y.a - x.a) * t, b: x.b + (y.b - x.b) * t, }) } // ============================================================================ // Low-color terminals // ============================================================================ /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA /// arrangement the Linux console and most emulators start from. /// /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray /// rather than white, which is the entry a themed surface usually lands on, and /// index 15 is the true white. /// /// Emulators let the user repaint all sixteen, so this is the standard /// arrangement rather than a promise about any one terminal. The Linux console /// keeps it, which is the case that matters: a console app cannot fall back to /// 24-bit color there. pub const ANSI_16: [Rgb; 16] = [ Rgb { r: 0x00, g: 0x00, b: 0x00, }, Rgb { r: 0xaa, g: 0x00, b: 0x00, }, Rgb { r: 0x00, g: 0xaa, b: 0x00, }, Rgb { r: 0xaa, g: 0x55, b: 0x00, }, Rgb { r: 0x00, g: 0x00, b: 0xaa, }, Rgb { r: 0xaa, g: 0x00, b: 0xaa, }, Rgb { r: 0x00, g: 0xaa, b: 0xaa, }, Rgb { r: 0xaa, g: 0xaa, b: 0xaa, }, Rgb { r: 0x55, g: 0x55, b: 0x55, }, Rgb { r: 0xff, g: 0x55, b: 0x55, }, Rgb { r: 0x55, g: 0xff, b: 0x55, }, Rgb { r: 0xff, g: 0xff, b: 0x55, }, Rgb { r: 0x55, g: 0x55, b: 0xff, }, Rgb { r: 0xff, g: 0x55, b: 0xff, }, Rgb { r: 0x55, g: 0xff, b: 0xff, }, Rgb { r: 0xff, g: 0xff, b: 0xff, }, ]; /// The contrast ratio two colors must clear to read as separate areas. /// /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing /// text can ask for more by checking [`wcag_contrast`] itself. pub const DISTINCT: f32 = 3.0; /// Perceptual distance between two colors, for choosing the closest of a set. fn oklab_distance(a: Rgb, b: Rgb) -> f32 { let (x, y) = (a.to_oklab(), b.to_oklab()); ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt() } /// Index of the entry in `palette` that looks most like `c`. /// /// OKLab distance rather than distance in sRGB, for the same reason [`mix`] /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a /// nearest match computed in it picks visibly wrong entries in the mid tones. /// /// # Panics /// /// If `palette` is empty. pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize { assert!(!palette.is_empty(), "a palette needs at least one color"); let mut best = 0; let mut best_distance = f32::INFINITY; for (index, entry) in palette.iter().enumerate() { let distance = oklab_distance(c, *entry); if distance < best_distance { best = index; best_distance = distance; } } best } /// Index of the entry in `palette` closest to `fg` that still reads against /// `bg`. /// /// [`quantize`] answers about one color at a time, and two colors that differ /// can quantize to the same entry: a themed page and a border drawn on it are /// often a few steps apart in a 24-bit theme and land together on a 16-color /// terminal, leaving one flat area where there was a frame. Alloy's console /// showed exactly this, and it is not a contrived pairing: a light page and the /// mid-tone border derived from it both land on index 7. /// /// So the background is quantized first, because what the border must be /// distinguished from is the entry the terminal will actually paint, not the /// color the theme asked for. Then the nearest entry to `fg` clearing /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets /// furthest does: at that point the palette cannot honor the design, and the /// most legible approximation beats the closest invisible one. /// /// Only for colors whose whole job is to be told apart from their background. /// Applied to every token it would push a deliberately quiet one until it /// shouted. /// /// # Panics /// /// If `palette` is empty. pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize { assert!(!palette.is_empty(), "a palette needs at least one color"); let shown = palette[quantize(bg, palette)]; let mut order: Vec = (0..palette.len()).collect(); order.sort_by(|a, b| { oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b])) }); order .iter() .copied() .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT) .unwrap_or_else(|| { order .iter() .copied() .max_by(|a, b| { wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown)) }) .expect("the palette is not empty") }) } // ============================================================================ // Intent resolution // ============================================================================ /// Authored base intents: (TOML dotted source key, canonical token key). /// These are read straight from the theme; the token key is the CSS-var stem /// (`--{token}`) and the `rgb()` lookup key. pub const BASE_INTENTS: &[(&str, &str)] = &[ ("surface.page", "surface-page"), ("surface.raised", "surface-raised"), ("surface.sunken", "surface-sunken"), ("surface.overlay", "surface-overlay"), ("content.primary", "content"), ("content.secondary", "content-secondary"), ("content.muted", "content-muted"), ("action.primary", "action"), ("status.danger", "danger"), ("status.success", "success"), ("status.warning", "warning"), ("status.info", "info"), ("line.border", "border"), ("category.one", "category-one"), ("category.two", "category-two"), ("category.three", "category-three"), ("category.four", "category-four"), ("category.five", "category-five"), ("category.six", "category-six"), ]; /// A fully resolved intent layer: every token key → concrete `#rrggbb`. /// Includes both authored base intents and the computed derived intents. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SemanticTokens { pub meta: ThemeMeta, /// token-key → resolved hex. Stable, deterministic ordering. pub intents: BTreeMap, } impl SemanticTokens { /// Resolved hex for a token key, if present. pub fn hex(&self, key: &str) -> Option<&str> { self.intents.get(key).map(String::as_str) } /// Resolved RGB tuple for a token key (for egui / native consumers). pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> { self.intents .get(key) .and_then(|h| Rgb::from_hex(h)) .map(Rgb::tuple) } } /// Resolve an authored theme into the full intent token set. /// /// 1. Copy each present base intent from the authored colors. /// 2. Compute the derived interactive states from the base intents, using the /// same math the apps used to apply individually (so output is identical). /// /// Each derived token is emitted only when its source intents exist, mirroring /// the skip-missing behavior of the rest of the crate. pub fn resolve(theme: &ThemeColors) -> SemanticTokens { let mut intents: BTreeMap = BTreeMap::new(); // 1. Base intents (authored). Copy only values that parse as a hex color and // re-emit them in canonical `#rrggbb` form, so an authored value can never // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined // raw into a `\"\n[content]\nprimary = \"#111111\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!( t.hex("surface-page").is_none(), "non-hex base intent leaked" ); assert_eq!(t.hex("content").unwrap(), "#111111"); // The injected markup appears in no resolved value. assert!(!t.intents.values().any(|v| v.contains('<'))); } #[test] fn resolve_skips_derived_when_source_missing() { // No [action] => no action-derived tokens. let theme = parse_theme_str( "x", "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!(t.hex("action").is_none()); assert!(t.hex("action-hover").is_none()); assert!(t.hex("selection").is_none()); assert_eq!( t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex() ); } #[test] fn rgb_accessor_for_native_consumers() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let t = resolve(&theme); assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1))); assert_eq!(t.rgb("nonexistent"), None); } // ---- css emit ---- #[test] fn intent_css_vars_wraps_root_and_includes_tokens() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let css = intent_css_vars(&resolve(&theme)); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --surface-page: #2e3440;\n")); assert!(css.contains(" --danger: #bf616a;\n")); assert!(css.contains(" --action-hover: ")); assert!(css.trim_end().ends_with('}')); } // ---- loading / fs ---- #[test] fn load_and_resolve_round_trip() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let t = load_semantic(&dirs, "nord").unwrap(); assert_eq!(t.meta.name, "Nord"); assert_eq!(t.hex("action"), Some("#81a1c1")); } #[test] fn load_theme_rejects_invalid_id() { assert!(load_theme(&[], "../evil").is_err()); } fn meta(id: &str, variant: &str) -> ThemeMeta { ThemeMeta { id: id.to_string(), name: id.to_string(), variant: variant.to_string(), is_custom: false, } } fn defaults() -> ThemeDefaults { ThemeDefaults::new("flatwhite", "nord") } // The three the shipped themes actually declare. #[test] fn every_shipped_variant_parses() { assert_eq!(Variant::parse("light"), Some(Variant::Light)); assert_eq!(Variant::parse("dark"), Some(Variant::Dark)); assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast)); assert_eq!(Variant::parse("sepia"), None); } // parse_meta already defaults a *missing* variant to dark, so an // unrecognized one reading as light would have the crate disagreeing with // itself. alloy_tui did exactly that before this existed. #[test] fn an_unrecognized_variant_reads_the_way_a_missing_one_does() { assert_eq!(Variant::from("sepia"), Variant::Dark); assert_eq!(Variant::from(""), Variant::Dark); let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap(); assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark); } #[test] fn a_selection_round_trips_through_any_store() { for (stored, expect) in [ (Some("system"), ThemeSelection::Follow), (None, ThemeSelection::Follow), (Some(""), ThemeSelection::Follow), (Some(" "), ThemeSelection::Follow), (Some("nord"), ThemeSelection::Fixed("nord".into())), ] { let parsed = ThemeSelection::parse(stored); assert_eq!(parsed, expect, "{stored:?}"); assert_eq!( ThemeSelection::parse(Some(parsed.as_str())), expect, "what is written reads back as what was meant", ); } } // Nothing saved is follow-the-system, which is what Balanced Breakfast // expressed as an absent value and GoingsOn as a sentinel. Both are now the // same thing. #[test] fn nothing_chosen_yet_is_follow() { assert_eq!(ThemeSelection::default(), ThemeSelection::Follow); } #[test] fn a_fixed_selection_wins_when_its_theme_is_installed() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("nord".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "nord", "a chosen theme is not overridden by the ambient mode", ); } // Themes are deletable in three of the four apps. Handing back an id that // will fail to load only moves the error somewhere less helpful. #[test] fn a_fixed_selection_whose_theme_is_gone_falls_back() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("deleted".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } #[test] fn follow_picks_the_apps_default_for_the_ambient_mode() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let follow = ThemeSelection::Follow; assert_eq!( follow.resolve(Variant::Dark, &defaults(), &available), "nord", ); assert_eq!( follow.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } // The behaviour Balanced Breakfast could not have: following the system // into a theme the user installed, when the app's own default is absent. #[test] fn follow_uses_any_installed_theme_of_the_right_variant() { let available = [meta("solarized-light", "light"), meta("mine", "dark")]; assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), "mine", "the app's `nord` is not installed, but a dark theme is", ); } // Always returns something: an app with no theme directory gets the id it // ships with, and the load error it would have had anyway. #[test] fn an_empty_catalog_still_names_the_apps_default() { assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]), "nord", ); } #[test] fn high_contrast_falls_back_to_dark_unless_named() { let plain = defaults(); assert_eq!(plain.for_variant(Variant::HighContrast), "nord"); let named = defaults().high_contrast("sharp"); assert_eq!(named.for_variant(Variant::HighContrast), "sharp"); } // The bug this builder exists to prevent: the Alloy console pushed the // user's directory first under a comment reading "highest precedence // first", when both consumers of this vector resolve last-wins. A custom // theme lost to the packaged one of the same id. #[test] fn the_users_own_themes_outrank_everything() { let root = tempfile::tempdir().unwrap(); let make = |name: &str| { let dir = root.path().join(name); std::fs::create_dir_all(&dir).unwrap(); dir }; let (bundled, system, custom) = (make("bundled"), make("system"), make("custom")); let dirs = ThemeDirs::new() .custom(Some(custom.clone())) .bundled(Some(bundled.clone())) .system(Some(system.clone())) .build(); assert_eq!( dirs, vec![(bundled, false), (system, false), (custom.clone(), true)], "lowest precedence first, whatever order the tiers were added in", ); assert!(dirs.last().unwrap().1, "only the user's tier is custom"); // And the ordering means what the consumers think it means. for dir in dirs.iter().map(|(dir, _)| dir) { std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap(); } assert_eq!( find_theme_path(&dirs, "shared").unwrap().0, custom.join("shared.toml"), "the user's copy is the one that loads", ); } #[test] fn a_directory_that_does_not_exist_is_dropped() { let root = tempfile::tempdir().unwrap(); let real = root.path().join("real"); std::fs::create_dir_all(&real).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(root.path().join("nope"))) .system(None) .custom(Some(real.clone())) .build(); assert_eq!(dirs, vec![(real, true)]); } // A Tauri app has two bundled tiers: the resource dir in production and the // tree build.rs materialized for a dev run with no resource dir. #[test] fn more_than_one_bundled_tier_is_allowed() { let root = tempfile::tempdir().unwrap(); let (first, second) = (root.path().join("a"), root.path().join("b")); std::fs::create_dir_all(&first).unwrap(); std::fs::create_dir_all(&second).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(first.clone())) .bundled(Some(second.clone())) .build(); assert_eq!(dirs, vec![(first, false), (second, false)]); } #[test] fn list_themes_from_dirs_finds_toml_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap(); fs::write(dir.path().join("x.txt"), "ignored").unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let themes = list_themes_from_dirs(&dirs); assert_eq!(themes.len(), 1); assert_eq!(themes[0].id, "t"); } #[test] fn find_theme_path_reverse_priority() { let d1 = tempfile::tempdir().unwrap(); let d2 = tempfile::tempdir().unwrap(); fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap(); fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap(); let dirs = vec![ (d1.path().to_path_buf(), false), (d2.path().to_path_buf(), true), ]; let (path, is_custom) = find_theme_path(&dirs, "s").unwrap(); assert!(is_custom); assert_eq!(path, d2.path().join("s.toml")); } #[test] fn import_theme_valid_and_rejects_empty() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let good = src_dir.path().join("my-theme.toml"); fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap(); let meta = import_theme(&good, custom_dir.path()).unwrap(); assert_eq!(meta.id, "my-theme"); assert!(custom_dir.path().join("my-theme.toml").exists()); let empty = src_dir.path().join("empty.toml"); fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap(); assert!(import_theme(&empty, custom_dir.path()).is_err()); } #[test] fn import_theme_rejects_invalid_toml() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let src = src_dir.path().join("bad.toml"); fs::write(&src, "this is not [valid toml [[[").unwrap(); assert!(import_theme(&src, custom_dir.path()).is_err()); } #[test] fn delete_theme_removes_and_guards() { let custom = tempfile::tempdir().unwrap(); let path = custom.path().join("doomed.toml"); fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap(); delete_theme(custom.path(), "doomed").unwrap(); assert!(!path.exists()); assert!(delete_theme(custom.path(), "../etc/passwd").is_err()); assert!(delete_theme(custom.path(), "ghost").is_err()); } #[test] fn export_theme_copies_file() { let src_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap(); let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n"; fs::write(src_dir.path().join("e.toml"), content).unwrap(); let dirs = vec![(src_dir.path().to_path_buf(), false)]; let dest = dest_dir.path().join("out.toml"); export_theme(&dirs, "e", &dest).unwrap(); assert_eq!(fs::read_to_string(&dest).unwrap(), content); assert!(export_theme(&dirs, "missing", &dest).is_err()); } #[test] fn load_theme_preview_returns_role_swatches() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let p = load_theme_preview(&dirs, "nord").unwrap(); assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border } #[test] fn bundled_themes_dir_resolves_to_shipped_themes() { // The crate ships its themes, so this must resolve in-tree and the // Akari defaults the console falls back to must be present. let dir = bundled_themes_dir().expect("makeover ships a themes/ directory"); assert!(dir.join("akari-dawn.toml").is_file()); assert!(dir.join("akari-night.toml").is_file()); } #[test] fn every_theme_is_accounted_for_in_third_party_notices() { // Attribution is a redistribution obligation, not a nicety: adding a // theme without a notice entry silently ships someone's work // uncredited. Fail here instead. let notices = std::fs::read_to_string( Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"), ) .expect("THIRD-PARTY-NOTICES.md must exist"); let missing: Vec<&str> = embedded_themes() .map(|(id, _)| id) .filter(|id| !notices.contains(*id)) .collect(); assert!( missing.is_empty(), "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}" ); } #[test] fn adapted_themes_carry_inline_attribution() { // Each adapted file must name its upstream in-file, so the credit // survives someone copying a single .toml out of the crate. const ORIGINALS: [&str; 5] = [ "makenotwork", "goingson", "audiofiles", "high-contrast", "neobrute", ]; for (id, source) in embedded_themes() { if ORIGINALS.contains(&id) { continue; } assert!( source.contains("adapted from"), "adapted theme `{id}` is missing its inline attribution header" ); } } #[test] fn embedded_themes_match_the_directory() { // The embedded copy and themes/ are two views of one source. If they // ever disagree, path-based and path-free consumers render different // theme sets, which is exactly the drift shipping the data was meant // to prevent. let dir = bundled_themes_dir().unwrap(); let mut on_disk: Vec = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| { let path = e.ok()?.path(); if path.extension()? != "toml" { return None; } Some(path.file_stem()?.to_str()?.to_string()) }) .collect(); let mut embedded: Vec = embedded_themes().map(|(id, _)| id.to_string()).collect(); on_disk.sort(); embedded.sort(); assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/"); } #[test] fn every_embedded_theme_parses() { // Guards the path-free consumers (MNW server, the Tauri build steps) // the same way every_shipped_theme_loads guards the path-based ones. let mut count = 0; for (id, source) in embedded_themes() { parse_theme_str(id, source, false) .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}")); count += 1; } assert!(count >= 30, "expected the full theme set, got {count}"); } #[test] fn every_shipped_theme_loads() { // Guards the data, not just the loader: a malformed or truncated // .toml in themes/ is a shipping bug, and it should fail here rather // than at a user's first launch. let dir = bundled_themes_dir().unwrap(); let dirs = vec![(dir.clone(), false)]; let themes = list_themes_from_dirs(&dirs); assert!( themes.len() >= 30, "expected the full theme set, got {}", themes.len() ); for meta in &themes { load_theme(&dirs, &meta.id) .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id)); } } }