//! Theme system: bundled themes + optional custom themes from config directory. //! //! Provides a global `ThemeColors` behind a `RwLock`, with accessor functions //! that return `Color32` values. Derived colors (row stripes, selection highlight) //! are computed from the base palette. //! //! Themes are embedded at compile time from `themes/` within this crate. Users can also //! drop custom `.toml` files into their platform config directory //! (`/audiofiles/themes/`) which override bundled themes by ID. use egui::Color32; use parking_lot::RwLock; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use tracing::{error, warn}; // --- Logo font (embedded at compile time) --- /// Recursive Mono Linear Bold — used for the "af/" logo. static LOGO_FONT: &[u8] = include_bytes!("../../fonts/RecursiveMonoLnrSt-Bold.ttf"); /// The egui font family name for the logo font. pub const LOGO_FONT_FAMILY: &str = "RecursiveMono"; // --- Spacing and stroke tokens --- // // Every UI call site reads `add_space` and stroke widths from these constants, // not raw float literals. See `docs/design-system.md` and // `docs/ux-audit/remediation-plan.md` (#R-00, #R-07). /// Spacing constants used by `ui.add_space(...)` and inter-control gaps. /// These are theme-independent — themes tune `item_spacing` and /// `button_padding` via TOML, but the `space::*` ladder stays fixed so /// every panel reads the same vocabulary. pub mod space { /// Tight inline: button↔icon, chip internal padding. pub const XS: f32 = 2.0; /// After a label, before its control. pub const SM: f32 = 4.0; /// Default gap between unrelated controls in a row. pub const MD: f32 = 8.0; /// Between minor sections within a panel. pub const LG: f32 = 12.0; /// Between major sections (also reachable via `theme::section_spacing()`). pub const SECTION: f32 = 16.0; /// Headline padding inside empty states. pub const XL: f32 = 20.0; } /// Stroke widths used by widgets and separators. pub mod stroke { pub const THIN: f32 = 0.5; pub const DEFAULT: f32 = 1.0; pub const FOCUS: f32 = 1.5; } // --- Bundled themes (embedded at compile time) --- static BUNDLED_THEMES: &[(&str, &str)] = &[ ("audiofiles", include_str!("../../themes/audiofiles.toml")), ("tokyonight", include_str!("../../themes/tokyonight.toml")), ("catppuccin-mocha", include_str!("../../themes/catppuccin-mocha.toml")), ("catppuccin-macchiato", include_str!("../../themes/catppuccin-macchiato.toml")), ("catppuccin-frappe", include_str!("../../themes/catppuccin-frappe.toml")), ("catppuccin-latte", include_str!("../../themes/catppuccin-latte.toml")), ("one-dark", include_str!("../../themes/one-dark.toml")), ("palenight", include_str!("../../themes/palenight.toml")), ("dracula", include_str!("../../themes/dracula.toml")), ("nightfox", include_str!("../../themes/nightfox.toml")), ("carbonfox", include_str!("../../themes/carbonfox.toml")), ("oxocarbon-dark", include_str!("../../themes/oxocarbon-dark.toml")), ("poimandres", include_str!("../../themes/poimandres.toml")), ("ayu-mirage", include_str!("../../themes/ayu-mirage.toml")), ("nord", include_str!("../../themes/nord.toml")), ("ayu-light", include_str!("../../themes/ayu-light.toml")), ("flatwhite", include_str!("../../themes/flatwhite.toml")), ("dawnfox", include_str!("../../themes/dawnfox.toml")), ("oxocarbon-light", include_str!("../../themes/oxocarbon-light.toml")), ("neobrute", include_str!("../../themes/neobrute.toml")), ("high-contrast", include_str!("../../themes/high-contrast.toml")), ("gruvbox-dark", include_str!("../../themes/gruvbox-dark.toml")), ("gruvbox-light", include_str!("../../themes/gruvbox-light.toml")), ("rosepine", include_str!("../../themes/rosepine.toml")), ("rosepine-dawn", include_str!("../../themes/rosepine-dawn.toml")), ("everforest", include_str!("../../themes/everforest.toml")), ("solarized-dark", include_str!("../../themes/solarized-dark.toml")), ("kanagawa", include_str!("../../themes/kanagawa.toml")), ]; /// The 15-slot universal theme palette. #[derive(Debug, Clone)] pub struct ThemeColors { // Background pub bg_primary: Color32, pub bg_secondary: Color32, pub bg_tertiary: Color32, pub bg_surface: Color32, // Foreground pub fg_primary: Color32, pub fg_secondary: Color32, pub fg_muted: Color32, // Accent pub accent_red: Color32, pub accent_green: Color32, pub accent_blue: Color32, pub accent_yellow: Color32, pub accent_purple: Color32, pub accent_cyan: Color32, // Border pub border_default: Color32, // Spacing (optional TOML overrides, with sensible defaults) pub rounding: f32, pub item_spacing_x: f32, pub item_spacing_y: f32, // Detail panel layout pub section_spacing: f32, pub grid_row_spacing: f32, pub button_padding_x: f32, pub button_padding_y: f32, pub window_margin: f32, pub indent: f32, } impl Default for ThemeColors { fn default() -> Self { // audiofiles default: bold black and white Self { bg_primary: Color32::from_rgb(0x00, 0x00, 0x00), bg_secondary: Color32::from_rgb(0x0a, 0x0a, 0x0a), bg_tertiary: Color32::from_rgb(0x1a, 0x1a, 0x1a), bg_surface: Color32::from_rgb(0x05, 0x05, 0x05), fg_primary: Color32::from_rgb(0xff, 0xff, 0xff), fg_secondary: Color32::from_rgb(0xd0, 0xd0, 0xd0), fg_muted: Color32::from_rgb(0x85, 0x85, 0x85), accent_red: Color32::from_rgb(0xff, 0x3b, 0x30), accent_green: Color32::from_rgb(0x30, 0xd1, 0x58), accent_blue: Color32::from_rgb(0x0a, 0x84, 0xff), accent_yellow: Color32::from_rgb(0xff, 0xd6, 0x0a), accent_purple: Color32::from_rgb(0xbf, 0x5a, 0xf2), accent_cyan: Color32::from_rgb(0x64, 0xd2, 0xff), border_default: Color32::from_rgb(0x33, 0x33, 0x33), rounding: 4.0, item_spacing_x: 8.0, item_spacing_y: 5.0, section_spacing: 16.0, grid_row_spacing: 6.0, button_padding_x: 8.0, button_padding_y: 4.0, window_margin: 10.0, indent: 18.0, } } } pub use theme_common::ThemeMeta; static THEME: LazyLock> = LazyLock::new(|| RwLock::new(ThemeColors::default())); // --- Derived color helpers --- /// Pick white or black text depending on which contrasts better against `bg`. /// Uses the ITU-R BT.601 luminance formula. fn contrast_color(bg: Color32) -> Color32 { let luminance = (0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32) / 255.0; if luminance > 0.5 { Color32::BLACK } else { Color32::WHITE } } /// Linearly interpolate between two colors channel-by-channel. /// `t=0.0` returns `a`, `t=1.0` returns `b`. Used to derive row stripes, /// selection highlights, and hover states from the base palette. fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { let mix = |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * t).round() as u8 }; Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) } // --- Public accessors --- /// Primary background color (deepest layer). pub fn bg_primary() -> Color32 { THEME.read().bg_primary } /// Secondary background color (panels, sidebars). pub fn bg_secondary() -> Color32 { THEME.read().bg_secondary } /// Tertiary background color (active/highlighted regions). pub fn bg_tertiary() -> Color32 { THEME.read().bg_tertiary } /// Even-row background, derived by blending primary and secondary. pub fn bg_row_even() -> Color32 { let t = THEME.read(); lerp_color(t.bg_primary, t.bg_secondary, 0.3) } /// Odd-row background (same as primary). pub fn bg_row_odd() -> Color32 { THEME.read().bg_primary } /// Row hover background. pub fn bg_hover() -> Color32 { THEME.read().bg_tertiary } /// Selected-row background, derived by blending primary with accent blue. pub fn bg_selected() -> Color32 { let t = THEME.read(); lerp_color(t.bg_primary, t.accent_blue, 0.3) } /// Primary text color. pub fn text_primary() -> Color32 { THEME.read().fg_primary } /// Secondary text color (labels, less emphasis). pub fn text_secondary() -> Color32 { THEME.read().fg_secondary } /// Muted text color (placeholders, disabled items). pub fn text_muted() -> Color32 { THEME.read().fg_muted } /// Surface background color (cards, popups). pub fn bg_surface() -> Color32 { THEME.read().bg_surface } /// Red accent color. pub fn accent_red() -> Color32 { THEME.read().accent_red } /// Green accent color. pub fn accent_green() -> Color32 { THEME.read().accent_green } /// Blue accent color. pub fn accent_blue() -> Color32 { THEME.read().accent_blue } /// Yellow accent color. pub fn accent_yellow() -> Color32 { THEME.read().accent_yellow } /// Purple accent color. pub fn accent_purple() -> Color32 { THEME.read().accent_purple } /// Cyan accent color. pub fn accent_cyan() -> Color32 { THEME.read().accent_cyan } /// Default border/separator color. pub fn border_default() -> Color32 { THEME.read().border_default } /// Section spacing for detail panel (between waveform, metadata, tags, actions). pub fn section_spacing() -> f32 { THEME.read().section_spacing } /// Grid row spacing for metadata grid. pub fn grid_row_spacing() -> f32 { THEME.read().grid_row_spacing } /// Piano white key — always a light shade regardless of theme variant. pub fn piano_white_key() -> Color32 { lerp_color(THEME.read().bg_surface, Color32::WHITE, 0.7) } /// Piano black key — always a dark shade regardless of theme variant. pub fn piano_black_key() -> Color32 { lerp_color(THEME.read().bg_surface, Color32::BLACK, 0.7) } /// Semi-opaque overlay used by the edit panel's trim preview (C-1) to mark /// regions that will be removed. Painted on top of the rendered waveform; the /// underlying peaks stay partly visible so the user retains spatial reference. pub fn trim_mute_overlay() -> Color32 { // Dim toward the theme's own background rather than hardcoded black, so the // trimmed-region wash reads correctly on light themes too (a black overlay // assumes a dark base). let bg = THEME.read().bg_primary; Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 160) } // --- Theme discovery --- /// Return the custom themes directory (`/audiofiles/themes/`). pub fn custom_themes_dir() -> Option { dirs::config_dir().map(|c| c.join("audiofiles").join("themes")) } /// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of /// `fg_muted` against the theme's darkest and lightest panel backgrounds. We /// surface this in the picker rather than overriding curated palettes' colors, /// so users can choose informed while the themes keep their identity. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ContrastTier { /// Muted text below the 3:1 WCAG floor for large text / UI components. Low, /// Muted text meets 3:1 but not the 4.5:1 normal-text bar. Standard, /// Muted text meets WCAG AA (>= 4.5:1) on every panel surface. High, } impl ContrastTier { /// Short badge shown next to the theme name. pub fn badge(self) -> &'static str { match self { ContrastTier::High => "AA", ContrastTier::Standard => "OK", ContrastTier::Low => "low", } } } fn relative_luminance(c: Color32) -> f64 { fn lin(ch: u8) -> f64 { let c = ch as f64 / 255.0; if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) } fn contrast_ratio(a: Color32, b: Color32) -> f64 { let (la, lb) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Load a theme's full color set by id (bundled or custom on disk). fn theme_colors_for(id: &str) -> Option { for (bundled_id, content) in BUNDLED_THEMES { if *bundled_id == id { return parse_theme(content).ok(); } } let dir = custom_themes_dir()?; let content = std::fs::read_to_string(dir.join(format!("{id}.toml"))).ok()?; parse_theme(&content).ok() } /// Compute the muted-text contrast tier for a theme id. Defaults to `Standard` /// if the theme can't be loaded. pub fn theme_contrast_tier(id: &str) -> ContrastTier { let Some(c) = theme_colors_for(id) else { return ContrastTier::Standard; }; let worst = contrast_ratio(c.fg_muted, c.bg_primary) .min(contrast_ratio(c.fg_muted, c.bg_tertiary)); if worst >= 4.5 { ContrastTier::High } else if worst >= 3.0 { ContrastTier::Standard } else { ContrastTier::Low } } /// List all available themes (bundled + custom). Custom themes override bundled by ID. pub fn list_themes() -> Vec { let mut themes: Vec = Vec::new(); let mut seen = std::collections::HashSet::new(); // Bundled themes first for (id, content) in BUNDLED_THEMES { let table: toml::Table = match content.parse() { Ok(t) => t, Err(_) => continue, }; seen.insert(id.to_string()); themes.push(theme_common::parse_meta(id, &table, false)); } // Custom themes from config dir (override bundled by ID) if let Some(dir) = custom_themes_dir() && let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().is_some_and(|e| e == "toml") { let id = path.file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); if let Ok(content) = std::fs::read_to_string(&path) { let table: toml::Table = match content.parse() { Ok(t) => t, Err(_) => continue, }; let meta = theme_common::parse_meta(&id, &table, true); if seen.contains(&id) { if let Some(existing) = themes.iter_mut().find(|t| t.id == id) { existing.name = meta.name; existing.variant = meta.variant; existing.is_custom = true; } } else { seen.insert(id.clone()); themes.push(meta); } } } } } themes } // --- TOML loading --- /// Parse a `#RRGGBB` hex string into an egui `Color32`. Returns `None` for /// malformed input (missing `#`, wrong length, non-hex digits). fn parse_hex(s: &str) -> Option { let h = s.strip_prefix('#')?; if h.len() != 6 { return None; } let r = u8::from_str_radix(&h[0..2], 16).ok()?; let g = u8::from_str_radix(&h[2..4], 16).ok()?; let b = u8::from_str_radix(&h[4..6], 16).ok()?; Some(Color32::from_rgb(r, g, b)) } /// Look up a dot-notation key (e.g., `"accent.blue"`) in the parsed TOML color map /// and convert it to `Color32`. Returns `None` if the key is missing or unparseable. fn get_color(colors: &HashMap, key: &str) -> Option { colors.get(key).and_then(|v| parse_hex(v)) } /// Parse TOML content string into `ThemeColors`. fn parse_theme(content: &str) -> Result { let table: toml::Table = content.parse()?; let colors = theme_common::extract_colors(&table); // Parse optional [spacing] section let spacing = table.get("spacing").and_then(|s| s.as_table()); let get_f32 = |key: &str, default: f32| -> f32 { spacing .and_then(|s| s.get(key)) .and_then(|v| v.as_float().map(|f| f as f32).or_else(|| v.as_integer().map(|i| i as f32))) .unwrap_or(default) }; Ok(ThemeColors { bg_primary: get_color(&colors, "background.primary").unwrap_or(Color32::BLACK), bg_secondary: get_color(&colors, "background.secondary").unwrap_or(Color32::BLACK), bg_tertiary: get_color(&colors, "background.tertiary").unwrap_or(Color32::BLACK), bg_surface: get_color(&colors, "background.surface").unwrap_or(Color32::BLACK), fg_primary: get_color(&colors, "foreground.primary").unwrap_or(Color32::WHITE), fg_secondary: get_color(&colors, "foreground.secondary").unwrap_or(Color32::WHITE), fg_muted: get_color(&colors, "foreground.muted").unwrap_or(Color32::GRAY), accent_red: get_color(&colors, "accent.red").unwrap_or(Color32::RED), accent_green: get_color(&colors, "accent.green").unwrap_or(Color32::GREEN), accent_blue: get_color(&colors, "accent.blue").unwrap_or(Color32::BLUE), accent_yellow: get_color(&colors, "accent.yellow").unwrap_or(Color32::YELLOW), accent_purple: get_color(&colors, "accent.purple").unwrap_or(Color32::from_rgb(0xBD, 0x93, 0xF9)), accent_cyan: get_color(&colors, "accent.cyan").unwrap_or(Color32::from_rgb(0x88, 0xC0, 0xD0)), border_default: get_color(&colors, "border.default").unwrap_or(Color32::DARK_GRAY), rounding: get_f32("rounding", 4.0), item_spacing_x: get_f32("item_spacing_x", 8.0), item_spacing_y: get_f32("item_spacing_y", 5.0), section_spacing: get_f32("section_spacing", 16.0), grid_row_spacing: get_f32("grid_row_spacing", 6.0), button_padding_x: get_f32("button_padding_x", 8.0), button_padding_y: get_f32("button_padding_y", 4.0), window_margin: get_f32("window_margin", 10.0), indent: get_f32("indent", 18.0), }) } /// Load a theme from a TOML file path. Returns the parsed ThemeColors. pub fn load_theme(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| crate::error::ThemeError::Read { path: path.to_path_buf(), source: e, })?; parse_theme(&content).map_err(|e| crate::error::ThemeError::Parse { path: path.to_path_buf(), source: e, }) } /// Initialise the active theme. If `id` is provided, loads that theme; /// otherwise falls back to "tokyonight". pub fn init(id: Option<&str>) { set_theme(id.unwrap_or("audiofiles")); } /// Switch the active theme. Checks bundled themes first, then custom directory. pub fn set_theme(id: &str) { // Try bundled themes first for (bundled_id, content) in BUNDLED_THEMES { if *bundled_id == id { match parse_theme(content) { Ok(colors) => { *THEME.write() = colors; return; } Err(e) => { error!("Failed to parse bundled theme '{id}': {e}"); return; } } } } // Try custom themes directory if let Some(dir) = custom_themes_dir() { let path = dir.join(format!("{id}.toml")); if path.exists() { match load_theme(&path) { Ok(colors) => { *THEME.write() = colors; return; } Err(e) => { error!("Failed to load custom theme '{id}': {e}"); return; } } } } warn!("Theme '{id}' not found; keeping current theme"); } /// Get preview colors (background, accent, foreground) for a theme by ID. /// Returns (bg_primary, accent_blue, fg_primary) or None if theme can't be loaded. pub fn theme_preview_colors(id: &str) -> Option<(Color32, Color32, Color32)> { let content = { // Check bundled first let mut found = None; for (bundled_id, c) in BUNDLED_THEMES { if *bundled_id == id { found = Some(c.to_string()); break; } } if found.is_none() && let Some(dir) = custom_themes_dir() { let path = dir.join(format!("{id}.toml")); found = std::fs::read_to_string(&path).ok(); } found? }; let table: toml::Table = content.parse().ok()?; let colors = theme_common::extract_colors(&table); let bg = get_color(&colors, "background.primary").unwrap_or(Color32::from_rgb(30, 30, 30)); let accent = get_color(&colors, "accent.blue").unwrap_or(Color32::from_rgb(100, 100, 255)); let fg = get_color(&colors, "foreground.primary").unwrap_or(Color32::from_rgb(220, 220, 220)); Some((bg, accent, fg)) } /// Export a theme's TOML content by ID. Checks custom directory first, then /// bundled themes. Returns the raw TOML string or `None` if not found. pub fn export_theme_content(id: &str) -> Option { // Check custom directory first if let Some(dir) = custom_themes_dir() { let path = dir.join(format!("{id}.toml")); if let Ok(content) = std::fs::read_to_string(&path) { return Some(content); } } // Check bundled themes for (bundled_id, content) in BUNDLED_THEMES { if *bundled_id == id { return Some(content.to_string()); } } None } // --- Classification colors (domain-specific, not from theme TOML) --- /// Map a sample classification name to a distinct display color. /// /// These are hardcoded rather than theme-driven because they represent semantic /// categories (kick=red, bass=blue, vocal=cyan, etc.) that should stay consistent /// across themes for muscle-memory recognition. pub fn classification_color(class: &str) -> Color32 { match class { "kick" => Color32::from_rgb(0xE0, 0x50, 0x50), "snare" => Color32::from_rgb(0xE0, 0x90, 0x40), "hihat" => Color32::from_rgb(0xE0, 0xD0, 0x40), "cymbal" => Color32::from_rgb(0xC8, 0xC8, 0x50), "percussion" => Color32::from_rgb(0xD0, 0x70, 0xB0), "bass" => Color32::from_rgb(0x50, 0x80, 0xE0), "vocal" => Color32::from_rgb(0x50, 0xC0, 0xE0), "synth" => Color32::from_rgb(0xA0, 0x60, 0xE0), "pad" => Color32::from_rgb(0x60, 0xB0, 0x60), "misc" => Color32::from_rgb(0xE0, 0x60, 0x80), "noise" => Color32::from_rgb(0x90, 0x90, 0x90), "music" => Color32::from_rgb(0x70, 0xC0, 0xA0), _ => text_secondary(), } } /// Register the logo font with egui. Must be called once before the first frame /// (e.g. from the eframe `CreationContext` or nih-plug init callback), because /// `ctx.set_fonts()` only takes effect on the next frame. pub fn setup_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( LOGO_FONT_FAMILY.to_owned(), egui::FontData::from_static(LOGO_FONT).into(), ); fonts.families.insert( egui::FontFamily::Name(LOGO_FONT_FAMILY.into()), vec![LOGO_FONT_FAMILY.to_owned(), "Hack".to_owned()], ); ctx.set_fonts(fonts); } /// Apply the current theme's visuals to the egui context. pub fn apply_theme(ctx: &egui::Context) { let t = THEME.read(); let mut visuals = egui::Visuals::dark(); visuals.panel_fill = t.bg_secondary; visuals.window_fill = t.bg_secondary; visuals.extreme_bg_color = t.bg_primary; visuals.faint_bg_color = lerp_color(t.bg_primary, t.bg_secondary, 0.3); visuals.selection.bg_fill = lerp_color(t.bg_primary, t.accent_blue, 0.3); // Keyboard-focus / selection outline: use the dedicated FOCUS stroke width // (1.5) and the saturated accent so focused widgets are clearly ringed, // rather than egui's near-invisible default 1px contrast hairline. visuals.selection.stroke = egui::Stroke::new(stroke::FOCUS, t.accent_blue); visuals.widgets.noninteractive.bg_fill = t.bg_secondary; visuals.widgets.inactive.bg_fill = lerp_color(t.bg_secondary, t.bg_tertiary, 0.3); visuals.widgets.hovered.bg_fill = t.bg_tertiary; visuals.widgets.active.bg_fill = t.accent_blue; visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, t.fg_secondary); visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.fg_primary); visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, t.fg_primary); visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, contrast_color(t.accent_blue)); visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, t.fg_primary); visuals.window_stroke = egui::Stroke::new(1.0, t.border_default); visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border_default); // Softer edges on all widgets let rounding = egui::CornerRadius::same(t.rounding as u8); visuals.widgets.noninteractive.corner_radius = rounding; visuals.widgets.inactive.corner_radius = rounding; visuals.widgets.hovered.corner_radius = rounding; visuals.widgets.active.corner_radius = rounding; visuals.widgets.open.corner_radius = rounding; // Softer widget borders: thinner strokes on inactive/hover states visuals.widgets.inactive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.3)); visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, t.border_default); visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, t.accent_blue); // Softer separator color visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.4)); // Widget expansion on hover for tactile feedback visuals.widgets.hovered.expansion = 1.0; visuals.widgets.active.expansion = 0.0; let spacing_x = t.item_spacing_x; let spacing_y = t.item_spacing_y; let btn_pad_x = t.button_padding_x; let btn_pad_y = t.button_padding_y; let win_margin = t.window_margin; let indent = t.indent; drop(t); ctx.set_visuals(visuals); // Apply theme spacing let mut style = (*ctx.global_style()).clone(); style.spacing.item_spacing = egui::vec2(spacing_x, spacing_y); style.spacing.button_padding = egui::vec2(btn_pad_x, btn_pad_y); style.spacing.window_margin = egui::vec2(win_margin, win_margin).into(); style.spacing.indent = indent; ctx.set_global_style(style); } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; // --------------------------------------------------------------- // lerp_color // --------------------------------------------------------------- #[test] fn contrast_color_black_bg_returns_white() { assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE); } #[test] fn contrast_color_white_bg_returns_black() { assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK); } #[test] fn contrast_color_dark_blue_returns_white() { // Dark blue (accent_blue from tokyonight: #7aa2f7 → luminance ~0.62) // Actually 0x0a84ff is the default accent_blue (very dark blue) assert_eq!(contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)), Color32::WHITE); } #[test] fn contrast_color_bright_yellow_returns_black() { assert_eq!(contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)), Color32::BLACK); } // --------------------------------------------------------------- // lerp_color // --------------------------------------------------------------- #[test] fn lerp_color_t0_returns_a() { let a = Color32::from_rgb(100, 150, 200); let b = Color32::from_rgb(200, 50, 0); assert_eq!(lerp_color(a, b, 0.0), a); } #[test] fn lerp_color_t1_returns_b() { let a = Color32::from_rgb(100, 150, 200); let b = Color32::from_rgb(200, 50, 0); assert_eq!(lerp_color(a, b, 1.0), b); } #[test] fn lerp_color_midpoint() { let a = Color32::from_rgb(0, 0, 0); let b = Color32::from_rgb(100, 200, 50); let mid = lerp_color(a, b, 0.5); assert_eq!(mid, Color32::from_rgb(50, 100, 25)); } #[test] fn lerp_color_quarter() { let a = Color32::from_rgb(0, 0, 0); let b = Color32::from_rgb(100, 200, 40); let result = lerp_color(a, b, 0.25); assert_eq!(result, Color32::from_rgb(25, 50, 10)); } #[test] fn lerp_color_identical_returns_same() { let c = Color32::from_rgb(42, 42, 42); assert_eq!(lerp_color(c, c, 0.5), c); } #[test] fn lerp_color_black_to_white() { let black = Color32::from_rgb(0, 0, 0); let white = Color32::from_rgb(255, 255, 255); let mid = lerp_color(black, white, 0.5); assert_eq!(mid, Color32::from_rgb(128, 128, 128)); } #[test] fn lerp_color_white_to_black() { let black = Color32::from_rgb(0, 0, 0); let white = Color32::from_rgb(255, 255, 255); let mid = lerp_color(white, black, 0.5); assert_eq!(mid, Color32::from_rgb(128, 128, 128)); } // --------------------------------------------------------------- // parse_hex // --------------------------------------------------------------- #[test] fn parse_hex_valid() { assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0))); assert_eq!(parse_hex("#00ff00"), Some(Color32::from_rgb(0, 255, 0))); assert_eq!(parse_hex("#0000ff"), Some(Color32::from_rgb(0, 0, 255))); } #[test] fn parse_hex_black_and_white() { assert_eq!(parse_hex("#000000"), Some(Color32::from_rgb(0, 0, 0))); assert_eq!(parse_hex("#ffffff"), Some(Color32::from_rgb(255, 255, 255))); } #[test] fn parse_hex_uppercase() { assert_eq!(parse_hex("#FF8800"), Some(Color32::from_rgb(255, 136, 0))); } #[test] fn parse_hex_mixed_case() { assert_eq!(parse_hex("#aAbBcC"), Some(Color32::from_rgb(0xAA, 0xBB, 0xCC))); } #[test] fn parse_hex_missing_hash() { assert_eq!(parse_hex("ff0000"), None); } #[test] fn parse_hex_too_short() { assert_eq!(parse_hex("#fff"), None); } #[test] fn parse_hex_too_long() { assert_eq!(parse_hex("#ff00ff00"), None); } #[test] fn parse_hex_invalid_digits() { assert_eq!(parse_hex("#gggggg"), None); } #[test] fn parse_hex_empty_string() { assert_eq!(parse_hex(""), None); } #[test] fn parse_hex_just_hash() { assert_eq!(parse_hex("#"), None); } #[test] fn parse_hex_specific_theme_color() { // Tokyo Night bg_primary assert_eq!(parse_hex("#1a1b26"), Some(Color32::from_rgb(0x1a, 0x1b, 0x26))); } // --------------------------------------------------------------- // get_color // --------------------------------------------------------------- #[test] fn get_color_found() { let mut map = HashMap::new(); map.insert("accent.blue".to_string(), "#7aa2f7".to_string()); assert_eq!( get_color(&map, "accent.blue"), Some(Color32::from_rgb(0x7a, 0xa2, 0xf7)) ); } #[test] fn get_color_missing_key() { let map = HashMap::new(); assert_eq!(get_color(&map, "accent.blue"), None); } #[test] fn get_color_invalid_value() { let mut map = HashMap::new(); map.insert("accent.blue".to_string(), "not-a-color".to_string()); assert_eq!(get_color(&map, "accent.blue"), None); } #[test] fn get_color_empty_value() { let mut map = HashMap::new(); map.insert("bg.primary".to_string(), String::new()); assert_eq!(get_color(&map, "bg.primary"), None); } // --------------------------------------------------------------- // parse_theme // --------------------------------------------------------------- fn full_theme_toml() -> &'static str { r##" [meta] name = "Test Theme" variant = "dark" [background] primary = "#1a1b26" secondary = "#16161e" tertiary = "#283457" surface = "#1a1b26" [foreground] primary = "#c0caf5" secondary = "#a9b1d6" muted = "#565f89" [accent] red = "#f7768e" green = "#9ece6a" blue = "#7aa2f7" yellow = "#e0af68" purple = "#9d7cd8" cyan = "#7dcfff" [border] default = "#3b4261" "## } #[test] fn parse_theme_full() { let theme = parse_theme(full_theme_toml()).unwrap(); assert_eq!(theme.bg_primary, Color32::from_rgb(0x1a, 0x1b, 0x26)); assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e)); assert_eq!(theme.bg_tertiary, Color32::from_rgb(0x28, 0x34, 0x57)); assert_eq!(theme.bg_surface, Color32::from_rgb(0x1a, 0x1b, 0x26)); assert_eq!(theme.fg_primary, Color32::from_rgb(0xc0, 0xca, 0xf5)); assert_eq!(theme.fg_secondary, Color32::from_rgb(0xa9, 0xb1, 0xd6)); assert_eq!(theme.fg_muted, Color32::from_rgb(0x56, 0x5f, 0x89)); assert_eq!(theme.accent_red, Color32::from_rgb(0xf7, 0x76, 0x8e)); assert_eq!(theme.accent_green, Color32::from_rgb(0x9e, 0xce, 0x6a)); assert_eq!(theme.accent_blue, Color32::from_rgb(0x7a, 0xa2, 0xf7)); assert_eq!(theme.accent_yellow, Color32::from_rgb(0xe0, 0xaf, 0x68)); assert_eq!(theme.accent_purple, Color32::from_rgb(0x9d, 0x7c, 0xd8)); assert_eq!(theme.accent_cyan, Color32::from_rgb(0x7d, 0xcf, 0xff)); assert_eq!(theme.border_default, Color32::from_rgb(0x3b, 0x42, 0x61)); } #[test] fn parse_theme_missing_sections_uses_defaults() { // Only background section; other sections should get fallback colors let toml = r##" [background] primary = "#112233" "##; let theme = parse_theme(toml).unwrap(); assert_eq!(theme.bg_primary, Color32::from_rgb(0x11, 0x22, 0x33)); // Missing background keys fall back to BLACK assert_eq!(theme.bg_secondary, Color32::BLACK); // Missing foreground falls back to WHITE assert_eq!(theme.fg_primary, Color32::WHITE); assert_eq!(theme.fg_secondary, Color32::WHITE); // Missing muted falls back to GRAY assert_eq!(theme.fg_muted, Color32::GRAY); // Missing accents fall back to named colors assert_eq!(theme.accent_red, Color32::RED); assert_eq!(theme.accent_green, Color32::GREEN); assert_eq!(theme.accent_blue, Color32::BLUE); assert_eq!(theme.accent_yellow, Color32::YELLOW); assert_eq!(theme.border_default, Color32::DARK_GRAY); } #[test] fn parse_theme_empty_toml() { // No sections at all: everything falls back to defaults let theme = parse_theme("").unwrap(); assert_eq!(theme.bg_primary, Color32::BLACK); assert_eq!(theme.fg_primary, Color32::WHITE); assert_eq!(theme.accent_red, Color32::RED); } #[test] fn parse_theme_invalid_toml() { assert!(parse_theme("this is not [valid toml [[[").is_err()); } #[test] fn parse_theme_partial_accent() { let toml = r##" [accent] red = "#ff0000" blue = "#0000ff" "##; let theme = parse_theme(toml).unwrap(); assert_eq!(theme.accent_red, Color32::from_rgb(255, 0, 0)); assert_eq!(theme.accent_blue, Color32::from_rgb(0, 0, 255)); // green missing, falls back assert_eq!(theme.accent_green, Color32::GREEN); } #[test] fn parse_theme_ignores_extra_sections() { let toml = r##" [meta] name = "Extra" variant = "dark" [background] primary = "#aabbcc" [custom_section] foo = "bar" "##; let theme = parse_theme(toml).unwrap(); assert_eq!(theme.bg_primary, Color32::from_rgb(0xaa, 0xbb, 0xcc)); } #[test] fn parse_theme_invalid_hex_in_field_uses_fallback() { let toml = r##" [background] primary = "not-a-color" secondary = "#16161e" "##; let theme = parse_theme(toml).unwrap(); // Invalid hex falls back to BLACK for bg assert_eq!(theme.bg_primary, Color32::BLACK); // Valid hex parses correctly assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e)); } // --------------------------------------------------------------- // classification_color // --------------------------------------------------------------- #[test] fn classification_color_known_classes() { assert_eq!(classification_color("kick"), Color32::from_rgb(0xE0, 0x50, 0x50)); assert_eq!(classification_color("snare"), Color32::from_rgb(0xE0, 0x90, 0x40)); assert_eq!(classification_color("hihat"), Color32::from_rgb(0xE0, 0xD0, 0x40)); assert_eq!(classification_color("cymbal"), Color32::from_rgb(0xC8, 0xC8, 0x50)); assert_eq!(classification_color("percussion"), Color32::from_rgb(0xD0, 0x70, 0xB0)); assert_eq!(classification_color("bass"), Color32::from_rgb(0x50, 0x80, 0xE0)); assert_eq!(classification_color("vocal"), Color32::from_rgb(0x50, 0xC0, 0xE0)); assert_eq!(classification_color("synth"), Color32::from_rgb(0xA0, 0x60, 0xE0)); assert_eq!(classification_color("pad"), Color32::from_rgb(0x60, 0xB0, 0x60)); assert_eq!(classification_color("misc"), Color32::from_rgb(0xE0, 0x60, 0x80)); assert_eq!(classification_color("noise"), Color32::from_rgb(0x90, 0x90, 0x90)); assert_eq!(classification_color("music"), Color32::from_rgb(0x70, 0xC0, 0xA0)); } #[test] fn classification_color_unknown_falls_back_to_text_secondary() { // The fallback calls text_secondary() which reads the global THEME. let fallback = classification_color("unknown_class"); assert_eq!(fallback, text_secondary()); } #[test] fn classification_color_empty_string_falls_back() { let fallback = classification_color(""); assert_eq!(fallback, text_secondary()); } #[test] fn classification_color_case_sensitive() { // "Kick" != "kick" -- should fall back let result = classification_color("Kick"); assert_eq!(result, text_secondary()); } // --------------------------------------------------------------- // ThemeColors::default // --------------------------------------------------------------- #[test] fn theme_colors_default_is_audiofiles() { let d = ThemeColors::default(); assert_eq!(d.bg_primary, Color32::from_rgb(0x00, 0x00, 0x00)); assert_eq!(d.fg_primary, Color32::from_rgb(0xff, 0xff, 0xff)); assert_eq!(d.accent_blue, Color32::from_rgb(0x0a, 0x84, 0xff)); assert_eq!(d.border_default, Color32::from_rgb(0x33, 0x33, 0x33)); } // --------------------------------------------------------------- // Bundled theme parsing (round-trip all embedded themes) // --------------------------------------------------------------- #[test] fn all_bundled_themes_parse_successfully() { for (id, content) in BUNDLED_THEMES { let result = parse_theme(content); assert!(result.is_ok(), "Bundled theme '{id}' failed to parse: {:?}", result.err()); } } #[test] fn all_bundled_themes_have_valid_meta() { for (id, content) in BUNDLED_THEMES { let table: toml::Table = content.parse() .unwrap_or_else(|e| panic!("Bundled theme '{id}' is invalid TOML: {e}")); let meta = theme_common::parse_meta(id, &table, false); assert!(!meta.name.is_empty(), "Bundled theme '{id}' has empty name"); assert!(!meta.variant.is_empty(), "Bundled theme '{id}' has empty variant"); } } #[test] fn bundled_theme_colors_are_not_all_black() { // Sanity check: a properly parsed theme shouldn't have all-black fields for (id, content) in BUNDLED_THEMES { let theme = parse_theme(content).unwrap(); let all_black = theme.bg_primary == Color32::BLACK && theme.fg_primary == Color32::BLACK && theme.accent_blue == Color32::BLACK; assert!(!all_black, "Bundled theme '{id}' parsed to all-black colors"); } } }