Skip to main content

max / audiofiles

39.7 KB · 1067 lines History Blame Raw
1 //! Theme system: bundled themes + optional custom themes from config directory.
2 //!
3 //! Provides a global `ThemeColors` behind a `RwLock`, with accessor functions
4 //! that return `Color32` values. Derived colors (row stripes, selection highlight)
5 //! are computed from the base palette.
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.
10
11 use egui::Color32;
12 use parking_lot::RwLock;
13 use std::collections::HashMap;
14 use std::path::{Path, PathBuf};
15 use std::sync::LazyLock;
16 use tracing::{error, warn};
17
18 // --- Logo font (embedded at compile time) ---
19
20 /// Recursive Mono Linear Bold — used for the "af/" logo.
21 static LOGO_FONT: &[u8] = include_bytes!("../../fonts/RecursiveMonoLnrSt-Bold.ttf");
22
23 /// The egui font family name for the logo font.
24 pub const LOGO_FONT_FAMILY: &str = "RecursiveMono";
25
26 // --- Spacing and stroke tokens ---
27 //
28 // Every UI call site reads `add_space` and stroke widths from these constants,
29 // not raw float literals. See `docs/design-system.md` and
30 // `docs/ux-audit/remediation-plan.md` (#R-00, #R-07).
31
32 /// Spacing constants used by `ui.add_space(...)` and inter-control gaps.
33 /// These are theme-independent — themes tune `item_spacing` and
34 /// `button_padding` via TOML, but the `space::*` ladder stays fixed so
35 /// every panel reads the same vocabulary.
36 pub mod space {
37 /// Tight inline: button↔icon, chip internal padding.
38 pub const XS: f32 = 2.0;
39 /// After a label, before its control.
40 pub const SM: f32 = 4.0;
41 /// Default gap between unrelated controls in a row.
42 pub const MD: f32 = 8.0;
43 /// Between minor sections within a panel.
44 pub const LG: f32 = 12.0;
45 /// Between major sections (also reachable via `theme::section_spacing()`).
46 pub const SECTION: f32 = 16.0;
47 /// Headline padding inside empty states.
48 pub const XL: f32 = 20.0;
49 }
50
51 /// Stroke widths used by widgets and separators.
52 pub mod stroke {
53 pub const THIN: f32 = 0.5;
54 pub const DEFAULT: f32 = 1.0;
55 pub const FOCUS: f32 = 1.5;
56 }
57
58 // --- Bundled themes (embedded at compile time) ---
59
60 static BUNDLED_THEMES: &[(&str, &str)] = &[
61 ("audiofiles", include_str!("../../themes/audiofiles.toml")),
62 ("tokyonight", include_str!("../../themes/tokyonight.toml")),
63 ("catppuccin-mocha", include_str!("../../themes/catppuccin-mocha.toml")),
64 ("catppuccin-macchiato", include_str!("../../themes/catppuccin-macchiato.toml")),
65 ("catppuccin-frappe", include_str!("../../themes/catppuccin-frappe.toml")),
66 ("catppuccin-latte", include_str!("../../themes/catppuccin-latte.toml")),
67 ("one-dark", include_str!("../../themes/one-dark.toml")),
68 ("palenight", include_str!("../../themes/palenight.toml")),
69 ("dracula", include_str!("../../themes/dracula.toml")),
70 ("nightfox", include_str!("../../themes/nightfox.toml")),
71 ("carbonfox", include_str!("../../themes/carbonfox.toml")),
72 ("oxocarbon-dark", include_str!("../../themes/oxocarbon-dark.toml")),
73 ("poimandres", include_str!("../../themes/poimandres.toml")),
74 ("ayu-mirage", include_str!("../../themes/ayu-mirage.toml")),
75 ("nord", include_str!("../../themes/nord.toml")),
76 ("ayu-light", include_str!("../../themes/ayu-light.toml")),
77 ("flatwhite", include_str!("../../themes/flatwhite.toml")),
78 ("dawnfox", include_str!("../../themes/dawnfox.toml")),
79 ("oxocarbon-light", include_str!("../../themes/oxocarbon-light.toml")),
80 ("neobrute", include_str!("../../themes/neobrute.toml")),
81 ("high-contrast", include_str!("../../themes/high-contrast.toml")),
82 ("gruvbox-dark", include_str!("../../themes/gruvbox-dark.toml")),
83 ("gruvbox-light", include_str!("../../themes/gruvbox-light.toml")),
84 ("rosepine", include_str!("../../themes/rosepine.toml")),
85 ("rosepine-dawn", include_str!("../../themes/rosepine-dawn.toml")),
86 ("everforest", include_str!("../../themes/everforest.toml")),
87 ("solarized-dark", include_str!("../../themes/solarized-dark.toml")),
88 ("kanagawa", include_str!("../../themes/kanagawa.toml")),
89 ];
90
91 /// The 15-slot universal theme palette.
92 #[derive(Debug, Clone)]
93 pub struct ThemeColors {
94 // Background
95 pub bg_primary: Color32,
96 pub bg_secondary: Color32,
97 pub bg_tertiary: Color32,
98 pub bg_surface: Color32,
99 // Foreground
100 pub fg_primary: Color32,
101 pub fg_secondary: Color32,
102 pub fg_muted: Color32,
103 // Accent
104 pub accent_red: Color32,
105 pub accent_green: Color32,
106 pub accent_blue: Color32,
107 pub accent_yellow: Color32,
108 pub accent_purple: Color32,
109 pub accent_cyan: Color32,
110 // Border
111 pub border_default: Color32,
112 // Spacing (optional TOML overrides, with sensible defaults)
113 pub rounding: f32,
114 pub item_spacing_x: f32,
115 pub item_spacing_y: f32,
116 // Detail panel layout
117 pub section_spacing: f32,
118 pub grid_row_spacing: f32,
119 pub button_padding_x: f32,
120 pub button_padding_y: f32,
121 pub window_margin: f32,
122 pub indent: f32,
123 }
124
125 impl Default for ThemeColors {
126 fn default() -> Self {
127 // audiofiles default: bold black and white
128 Self {
129 bg_primary: Color32::from_rgb(0x00, 0x00, 0x00),
130 bg_secondary: Color32::from_rgb(0x0a, 0x0a, 0x0a),
131 bg_tertiary: Color32::from_rgb(0x1a, 0x1a, 0x1a),
132 bg_surface: Color32::from_rgb(0x05, 0x05, 0x05),
133 fg_primary: Color32::from_rgb(0xff, 0xff, 0xff),
134 fg_secondary: Color32::from_rgb(0xd0, 0xd0, 0xd0),
135 fg_muted: Color32::from_rgb(0x85, 0x85, 0x85),
136 accent_red: Color32::from_rgb(0xff, 0x3b, 0x30),
137 accent_green: Color32::from_rgb(0x30, 0xd1, 0x58),
138 accent_blue: Color32::from_rgb(0x0a, 0x84, 0xff),
139 accent_yellow: Color32::from_rgb(0xff, 0xd6, 0x0a),
140 accent_purple: Color32::from_rgb(0xbf, 0x5a, 0xf2),
141 accent_cyan: Color32::from_rgb(0x64, 0xd2, 0xff),
142 border_default: Color32::from_rgb(0x33, 0x33, 0x33),
143 rounding: 4.0,
144 item_spacing_x: 8.0,
145 item_spacing_y: 5.0,
146 section_spacing: 16.0,
147 grid_row_spacing: 6.0,
148 button_padding_x: 8.0,
149 button_padding_y: 4.0,
150 window_margin: 10.0,
151 indent: 18.0,
152 }
153 }
154 }
155
156 pub use theme_common::ThemeMeta;
157
158 static THEME: LazyLock<RwLock<ThemeColors>> = LazyLock::new(|| RwLock::new(ThemeColors::default()));
159
160 // --- Derived color helpers ---
161
162 /// Pick white or black text depending on which contrasts better against `bg`.
163 /// Uses the ITU-R BT.601 luminance formula.
164 fn contrast_color(bg: Color32) -> Color32 {
165 let luminance = (0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32) / 255.0;
166 if luminance > 0.5 { Color32::BLACK } else { Color32::WHITE }
167 }
168
169 /// Linearly interpolate between two colors channel-by-channel.
170 /// `t=0.0` returns `a`, `t=1.0` returns `b`. Used to derive row stripes,
171 /// selection highlights, and hover states from the base palette.
172 fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
173 let mix = |a: u8, b: u8| -> u8 {
174 (a as f32 + (b as f32 - a as f32) * t).round() as u8
175 };
176 Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
177 }
178
179 // --- Public accessors ---
180
181 /// Primary background color (deepest layer).
182 pub fn bg_primary() -> Color32 { THEME.read().bg_primary }
183 /// Secondary background color (panels, sidebars).
184 pub fn bg_secondary() -> Color32 { THEME.read().bg_secondary }
185 /// Tertiary background color (active/highlighted regions).
186 pub fn bg_tertiary() -> Color32 { THEME.read().bg_tertiary }
187
188 /// Even-row background, derived by blending primary and secondary.
189 pub fn bg_row_even() -> Color32 {
190 let t = THEME.read();
191 lerp_color(t.bg_primary, t.bg_secondary, 0.3)
192 }
193 /// Odd-row background (same as primary).
194 pub fn bg_row_odd() -> Color32 { THEME.read().bg_primary }
195 /// Row hover background.
196 pub fn bg_hover() -> Color32 { THEME.read().bg_tertiary }
197 /// Selected-row background, derived by blending primary with accent blue.
198 pub fn bg_selected() -> Color32 {
199 let t = THEME.read();
200 lerp_color(t.bg_primary, t.accent_blue, 0.3)
201 }
202
203 /// Primary text color.
204 pub fn text_primary() -> Color32 { THEME.read().fg_primary }
205 /// Secondary text color (labels, less emphasis).
206 pub fn text_secondary() -> Color32 { THEME.read().fg_secondary }
207 /// Muted text color (placeholders, disabled items).
208 pub fn text_muted() -> Color32 { THEME.read().fg_muted }
209
210 /// Surface background color (cards, popups).
211 pub fn bg_surface() -> Color32 { THEME.read().bg_surface }
212
213 /// Red accent color.
214 pub fn accent_red() -> Color32 { THEME.read().accent_red }
215 /// Green accent color.
216 pub fn accent_green() -> Color32 { THEME.read().accent_green }
217 /// Blue accent color.
218 pub fn accent_blue() -> Color32 { THEME.read().accent_blue }
219 /// Yellow accent color.
220 pub fn accent_yellow() -> Color32 { THEME.read().accent_yellow }
221 /// Purple accent color.
222 pub fn accent_purple() -> Color32 { THEME.read().accent_purple }
223 /// Cyan accent color.
224 pub fn accent_cyan() -> Color32 { THEME.read().accent_cyan }
225
226 /// Default border/separator color.
227 pub fn border_default() -> Color32 { THEME.read().border_default }
228
229 /// Section spacing for detail panel (between waveform, metadata, tags, actions).
230 pub fn section_spacing() -> f32 { THEME.read().section_spacing }
231 /// Grid row spacing for metadata grid.
232 pub fn grid_row_spacing() -> f32 { THEME.read().grid_row_spacing }
233
234 /// Piano white key — always a light shade regardless of theme variant.
235 pub fn piano_white_key() -> Color32 {
236 lerp_color(THEME.read().bg_surface, Color32::WHITE, 0.7)
237 }
238 /// Piano black key — always a dark shade regardless of theme variant.
239 pub fn piano_black_key() -> Color32 {
240 lerp_color(THEME.read().bg_surface, Color32::BLACK, 0.7)
241 }
242
243 /// Semi-opaque overlay used by the edit panel's trim preview (C-1) to mark
244 /// regions that will be removed. Painted on top of the rendered waveform; the
245 /// underlying peaks stay partly visible so the user retains spatial reference.
246 pub fn trim_mute_overlay() -> Color32 {
247 // Dim toward the theme's own background rather than hardcoded black, so the
248 // trimmed-region wash reads correctly on light themes too (a black overlay
249 // assumes a dark base).
250 let bg = THEME.read().bg_primary;
251 Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 160)
252 }
253
254 // --- Theme discovery ---
255
256 /// Return the custom themes directory (`<config>/audiofiles/themes/`).
257 pub fn custom_themes_dir() -> Option<PathBuf> {
258 dirs::config_dir().map(|c| c.join("audiofiles").join("themes"))
259 }
260
261 /// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of
262 /// `fg_muted` against the theme's darkest and lightest panel backgrounds. We
263 /// surface this in the picker rather than overriding curated palettes' colors,
264 /// so users can choose informed while the themes keep their identity.
265 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
266 pub enum ContrastTier {
267 /// Muted text below the 3:1 WCAG floor for large text / UI components.
268 Low,
269 /// Muted text meets 3:1 but not the 4.5:1 normal-text bar.
270 Standard,
271 /// Muted text meets WCAG AA (>= 4.5:1) on every panel surface.
272 High,
273 }
274
275 impl ContrastTier {
276 /// Short badge shown next to the theme name.
277 pub fn badge(self) -> &'static str {
278 match self {
279 ContrastTier::High => "AA",
280 ContrastTier::Standard => "OK",
281 ContrastTier::Low => "low",
282 }
283 }
284 }
285
286 fn relative_luminance(c: Color32) -> f64 {
287 fn lin(ch: u8) -> f64 {
288 let c = ch as f64 / 255.0;
289 if c <= 0.03928 {
290 c / 12.92
291 } else {
292 ((c + 0.055) / 1.055).powf(2.4)
293 }
294 }
295 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
296 }
297
298 fn contrast_ratio(a: Color32, b: Color32) -> f64 {
299 let (la, lb) = (relative_luminance(a), relative_luminance(b));
300 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
301 (hi + 0.05) / (lo + 0.05)
302 }
303
304 /// Load a theme's full color set by id (bundled or custom on disk).
305 fn theme_colors_for(id: &str) -> Option<ThemeColors> {
306 for (bundled_id, content) in BUNDLED_THEMES {
307 if *bundled_id == id {
308 return parse_theme(content).ok();
309 }
310 }
311 let dir = custom_themes_dir()?;
312 let content = std::fs::read_to_string(dir.join(format!("{id}.toml"))).ok()?;
313 parse_theme(&content).ok()
314 }
315
316 /// Compute the muted-text contrast tier for a theme id. Defaults to `Standard`
317 /// if the theme can't be loaded.
318 pub fn theme_contrast_tier(id: &str) -> ContrastTier {
319 let Some(c) = theme_colors_for(id) else {
320 return ContrastTier::Standard;
321 };
322 let worst = contrast_ratio(c.fg_muted, c.bg_primary)
323 .min(contrast_ratio(c.fg_muted, c.bg_tertiary));
324 if worst >= 4.5 {
325 ContrastTier::High
326 } else if worst >= 3.0 {
327 ContrastTier::Standard
328 } else {
329 ContrastTier::Low
330 }
331 }
332
333 /// List all available themes (bundled + custom). Custom themes override bundled by ID.
334 pub fn list_themes() -> Vec<ThemeMeta> {
335 let mut themes: Vec<ThemeMeta> = Vec::new();
336 let mut seen = std::collections::HashSet::new();
337
338 // Bundled themes first
339 for (id, content) in BUNDLED_THEMES {
340 let table: toml::Table = match content.parse() {
341 Ok(t) => t,
342 Err(_) => continue,
343 };
344 seen.insert(id.to_string());
345 themes.push(theme_common::parse_meta(id, &table, false));
346 }
347
348 // Custom themes from config dir (override bundled by ID)
349 if let Some(dir) = custom_themes_dir()
350 && let Ok(entries) = std::fs::read_dir(&dir) {
351 for entry in entries.flatten() {
352 let path = entry.path();
353 if path.extension().is_some_and(|e| e == "toml") {
354 let id = path.file_stem()
355 .unwrap_or_default()
356 .to_string_lossy()
357 .to_string();
358 if let Ok(content) = std::fs::read_to_string(&path) {
359 let table: toml::Table = match content.parse() {
360 Ok(t) => t,
361 Err(_) => continue,
362 };
363 let meta = theme_common::parse_meta(&id, &table, true);
364 if seen.contains(&id) {
365 if let Some(existing) = themes.iter_mut().find(|t| t.id == id) {
366 existing.name = meta.name;
367 existing.variant = meta.variant;
368 existing.is_custom = true;
369 }
370 } else {
371 seen.insert(id.clone());
372 themes.push(meta);
373 }
374 }
375 }
376 }
377 }
378
379 themes
380 }
381
382 // --- TOML loading ---
383
384 /// Parse a `#RRGGBB` hex string into an egui `Color32`. Returns `None` for
385 /// malformed input (missing `#`, wrong length, non-hex digits).
386 fn parse_hex(s: &str) -> Option<Color32> {
387 let h = s.strip_prefix('#')?;
388 if h.len() != 6 {
389 return None;
390 }
391 let r = u8::from_str_radix(&h[0..2], 16).ok()?;
392 let g = u8::from_str_radix(&h[2..4], 16).ok()?;
393 let b = u8::from_str_radix(&h[4..6], 16).ok()?;
394 Some(Color32::from_rgb(r, g, b))
395 }
396
397 /// Look up a dot-notation key (e.g., `"accent.blue"`) in the parsed TOML color map
398 /// and convert it to `Color32`. Returns `None` if the key is missing or unparseable.
399 fn get_color(colors: &HashMap<String, String>, key: &str) -> Option<Color32> {
400 colors.get(key).and_then(|v| parse_hex(v))
401 }
402
403 /// Parse TOML content string into `ThemeColors`.
404 fn parse_theme(content: &str) -> Result<ThemeColors, toml::de::Error> {
405 let table: toml::Table = content.parse()?;
406
407 let colors = theme_common::extract_colors(&table);
408
409 // Parse optional [spacing] section
410 let spacing = table.get("spacing").and_then(|s| s.as_table());
411 let get_f32 = |key: &str, default: f32| -> f32 {
412 spacing
413 .and_then(|s| s.get(key))
414 .and_then(|v| v.as_float().map(|f| f as f32).or_else(|| v.as_integer().map(|i| i as f32)))
415 .unwrap_or(default)
416 };
417
418 Ok(ThemeColors {
419 bg_primary: get_color(&colors, "background.primary").unwrap_or(Color32::BLACK),
420 bg_secondary: get_color(&colors, "background.secondary").unwrap_or(Color32::BLACK),
421 bg_tertiary: get_color(&colors, "background.tertiary").unwrap_or(Color32::BLACK),
422 bg_surface: get_color(&colors, "background.surface").unwrap_or(Color32::BLACK),
423 fg_primary: get_color(&colors, "foreground.primary").unwrap_or(Color32::WHITE),
424 fg_secondary: get_color(&colors, "foreground.secondary").unwrap_or(Color32::WHITE),
425 fg_muted: get_color(&colors, "foreground.muted").unwrap_or(Color32::GRAY),
426 accent_red: get_color(&colors, "accent.red").unwrap_or(Color32::RED),
427 accent_green: get_color(&colors, "accent.green").unwrap_or(Color32::GREEN),
428 accent_blue: get_color(&colors, "accent.blue").unwrap_or(Color32::BLUE),
429 accent_yellow: get_color(&colors, "accent.yellow").unwrap_or(Color32::YELLOW),
430 accent_purple: get_color(&colors, "accent.purple").unwrap_or(Color32::from_rgb(0xBD, 0x93, 0xF9)),
431 accent_cyan: get_color(&colors, "accent.cyan").unwrap_or(Color32::from_rgb(0x88, 0xC0, 0xD0)),
432 border_default: get_color(&colors, "border.default").unwrap_or(Color32::DARK_GRAY),
433 rounding: get_f32("rounding", 4.0),
434 item_spacing_x: get_f32("item_spacing_x", 8.0),
435 item_spacing_y: get_f32("item_spacing_y", 5.0),
436 section_spacing: get_f32("section_spacing", 16.0),
437 grid_row_spacing: get_f32("grid_row_spacing", 6.0),
438 button_padding_x: get_f32("button_padding_x", 8.0),
439 button_padding_y: get_f32("button_padding_y", 4.0),
440 window_margin: get_f32("window_margin", 10.0),
441 indent: get_f32("indent", 18.0),
442 })
443 }
444
445 /// Load a theme from a TOML file path. Returns the parsed ThemeColors.
446 pub fn load_theme(path: &Path) -> Result<ThemeColors, crate::error::ThemeError> {
447 let content = std::fs::read_to_string(path).map_err(|e| crate::error::ThemeError::Read {
448 path: path.to_path_buf(),
449 source: e,
450 })?;
451 parse_theme(&content).map_err(|e| crate::error::ThemeError::Parse {
452 path: path.to_path_buf(),
453 source: e,
454 })
455 }
456
457 /// Initialise the active theme. If `id` is provided, loads that theme;
458 /// otherwise falls back to "tokyonight".
459 pub fn init(id: Option<&str>) {
460 set_theme(id.unwrap_or("audiofiles"));
461 }
462
463 /// Switch the active theme. Checks bundled themes first, then custom directory.
464 pub fn set_theme(id: &str) {
465 // Try bundled themes first
466 for (bundled_id, content) in BUNDLED_THEMES {
467 if *bundled_id == id {
468 match parse_theme(content) {
469 Ok(colors) => {
470 *THEME.write() = colors;
471 return;
472 }
473 Err(e) => {
474 error!("Failed to parse bundled theme '{id}': {e}");
475 return;
476 }
477 }
478 }
479 }
480
481 // Try custom themes directory
482 if let Some(dir) = custom_themes_dir() {
483 let path = dir.join(format!("{id}.toml"));
484 if path.exists() {
485 match load_theme(&path) {
486 Ok(colors) => {
487 *THEME.write() = colors;
488 return;
489 }
490 Err(e) => {
491 error!("Failed to load custom theme '{id}': {e}");
492 return;
493 }
494 }
495 }
496 }
497
498 warn!("Theme '{id}' not found; keeping current theme");
499 }
500
501 /// Get preview colors (background, accent, foreground) for a theme by ID.
502 /// Returns (bg_primary, accent_blue, fg_primary) or None if theme can't be loaded.
503 pub fn theme_preview_colors(id: &str) -> Option<(Color32, Color32, Color32)> {
504 let content = {
505 // Check bundled first
506 let mut found = None;
507 for (bundled_id, c) in BUNDLED_THEMES {
508 if *bundled_id == id {
509 found = Some(c.to_string());
510 break;
511 }
512 }
513 if found.is_none()
514 && let Some(dir) = custom_themes_dir() {
515 let path = dir.join(format!("{id}.toml"));
516 found = std::fs::read_to_string(&path).ok();
517 }
518 found?
519 };
520
521 let table: toml::Table = content.parse().ok()?;
522 let colors = theme_common::extract_colors(&table);
523 let bg = get_color(&colors, "background.primary").unwrap_or(Color32::from_rgb(30, 30, 30));
524 let accent = get_color(&colors, "accent.blue").unwrap_or(Color32::from_rgb(100, 100, 255));
525 let fg = get_color(&colors, "foreground.primary").unwrap_or(Color32::from_rgb(220, 220, 220));
526 Some((bg, accent, fg))
527 }
528
529 /// Export a theme's TOML content by ID. Checks custom directory first, then
530 /// bundled themes. Returns the raw TOML string or `None` if not found.
531 pub fn export_theme_content(id: &str) -> Option<String> {
532 // Check custom directory first
533 if let Some(dir) = custom_themes_dir() {
534 let path = dir.join(format!("{id}.toml"));
535 if let Ok(content) = std::fs::read_to_string(&path) {
536 return Some(content);
537 }
538 }
539
540 // Check bundled themes
541 for (bundled_id, content) in BUNDLED_THEMES {
542 if *bundled_id == id {
543 return Some(content.to_string());
544 }
545 }
546
547 None
548 }
549
550 // --- Classification colors (domain-specific, not from theme TOML) ---
551
552 /// Map a sample classification name to a distinct display color.
553 ///
554 /// These are hardcoded rather than theme-driven because they represent semantic
555 /// categories (kick=red, bass=blue, vocal=cyan, etc.) that should stay consistent
556 /// across themes for muscle-memory recognition.
557 pub fn classification_color(class: &str) -> Color32 {
558 match class {
559 "kick" => Color32::from_rgb(0xE0, 0x50, 0x50),
560 "snare" => Color32::from_rgb(0xE0, 0x90, 0x40),
561 "hihat" => Color32::from_rgb(0xE0, 0xD0, 0x40),
562 "cymbal" => Color32::from_rgb(0xC8, 0xC8, 0x50),
563 "percussion" => Color32::from_rgb(0xD0, 0x70, 0xB0),
564 "bass" => Color32::from_rgb(0x50, 0x80, 0xE0),
565 "vocal" => Color32::from_rgb(0x50, 0xC0, 0xE0),
566 "synth" => Color32::from_rgb(0xA0, 0x60, 0xE0),
567 "pad" => Color32::from_rgb(0x60, 0xB0, 0x60),
568 "misc" => Color32::from_rgb(0xE0, 0x60, 0x80),
569 "noise" => Color32::from_rgb(0x90, 0x90, 0x90),
570 "music" => Color32::from_rgb(0x70, 0xC0, 0xA0),
571 _ => text_secondary(),
572 }
573 }
574
575 /// Register the logo font with egui. Must be called once before the first frame
576 /// (e.g. from the eframe `CreationContext` or nih-plug init callback), because
577 /// `ctx.set_fonts()` only takes effect on the next frame.
578 pub fn setup_fonts(ctx: &egui::Context) {
579 let mut fonts = egui::FontDefinitions::default();
580 fonts.font_data.insert(
581 LOGO_FONT_FAMILY.to_owned(),
582 egui::FontData::from_static(LOGO_FONT).into(),
583 );
584 fonts.families.insert(
585 egui::FontFamily::Name(LOGO_FONT_FAMILY.into()),
586 vec![LOGO_FONT_FAMILY.to_owned(), "Hack".to_owned()],
587 );
588 ctx.set_fonts(fonts);
589 }
590
591 /// Apply the current theme's visuals to the egui context.
592 pub fn apply_theme(ctx: &egui::Context) {
593 let t = THEME.read();
594 let mut visuals = egui::Visuals::dark();
595
596 visuals.panel_fill = t.bg_secondary;
597 visuals.window_fill = t.bg_secondary;
598 visuals.extreme_bg_color = t.bg_primary;
599 visuals.faint_bg_color = lerp_color(t.bg_primary, t.bg_secondary, 0.3);
600
601 visuals.selection.bg_fill = lerp_color(t.bg_primary, t.accent_blue, 0.3);
602 // Keyboard-focus / selection outline: use the dedicated FOCUS stroke width
603 // (1.5) and the saturated accent so focused widgets are clearly ringed,
604 // rather than egui's near-invisible default 1px contrast hairline.
605 visuals.selection.stroke = egui::Stroke::new(stroke::FOCUS, t.accent_blue);
606
607 visuals.widgets.noninteractive.bg_fill = t.bg_secondary;
608 visuals.widgets.inactive.bg_fill = lerp_color(t.bg_secondary, t.bg_tertiary, 0.3);
609 visuals.widgets.hovered.bg_fill = t.bg_tertiary;
610 visuals.widgets.active.bg_fill = t.accent_blue;
611
612 visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, t.fg_secondary);
613 visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
614 visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
615 visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, contrast_color(t.accent_blue));
616 visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
617
618 visuals.window_stroke = egui::Stroke::new(1.0, t.border_default);
619 visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border_default);
620
621 // Softer edges on all widgets
622 let rounding = egui::CornerRadius::same(t.rounding as u8);
623 visuals.widgets.noninteractive.corner_radius = rounding;
624 visuals.widgets.inactive.corner_radius = rounding;
625 visuals.widgets.hovered.corner_radius = rounding;
626 visuals.widgets.active.corner_radius = rounding;
627 visuals.widgets.open.corner_radius = rounding;
628
629 // Softer widget borders: thinner strokes on inactive/hover states
630 visuals.widgets.inactive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.3));
631 visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, t.border_default);
632 visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, t.accent_blue);
633
634 // Softer separator color
635 visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.4));
636
637 // Widget expansion on hover for tactile feedback
638 visuals.widgets.hovered.expansion = 1.0;
639 visuals.widgets.active.expansion = 0.0;
640
641 let spacing_x = t.item_spacing_x;
642 let spacing_y = t.item_spacing_y;
643 let btn_pad_x = t.button_padding_x;
644 let btn_pad_y = t.button_padding_y;
645 let win_margin = t.window_margin;
646 let indent = t.indent;
647 drop(t);
648 ctx.set_visuals(visuals);
649
650 // Apply theme spacing
651 let mut style = (*ctx.global_style()).clone();
652 style.spacing.item_spacing = egui::vec2(spacing_x, spacing_y);
653 style.spacing.button_padding = egui::vec2(btn_pad_x, btn_pad_y);
654 style.spacing.window_margin = egui::vec2(win_margin, win_margin).into();
655 style.spacing.indent = indent;
656 ctx.set_global_style(style);
657 }
658
659 #[cfg(test)]
660 mod tests {
661 use super::*;
662 use std::collections::HashMap;
663
664 // ---------------------------------------------------------------
665 // lerp_color
666 // ---------------------------------------------------------------
667
668 #[test]
669 fn contrast_color_black_bg_returns_white() {
670 assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE);
671 }
672
673 #[test]
674 fn contrast_color_white_bg_returns_black() {
675 assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK);
676 }
677
678 #[test]
679 fn contrast_color_dark_blue_returns_white() {
680 // Dark blue (accent_blue from tokyonight: #7aa2f7 → luminance ~0.62)
681 // Actually 0x0a84ff is the default accent_blue (very dark blue)
682 assert_eq!(contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)), Color32::WHITE);
683 }
684
685 #[test]
686 fn contrast_color_bright_yellow_returns_black() {
687 assert_eq!(contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)), Color32::BLACK);
688 }
689
690 // ---------------------------------------------------------------
691 // lerp_color
692 // ---------------------------------------------------------------
693
694 #[test]
695 fn lerp_color_t0_returns_a() {
696 let a = Color32::from_rgb(100, 150, 200);
697 let b = Color32::from_rgb(200, 50, 0);
698 assert_eq!(lerp_color(a, b, 0.0), a);
699 }
700
701 #[test]
702 fn lerp_color_t1_returns_b() {
703 let a = Color32::from_rgb(100, 150, 200);
704 let b = Color32::from_rgb(200, 50, 0);
705 assert_eq!(lerp_color(a, b, 1.0), b);
706 }
707
708 #[test]
709 fn lerp_color_midpoint() {
710 let a = Color32::from_rgb(0, 0, 0);
711 let b = Color32::from_rgb(100, 200, 50);
712 let mid = lerp_color(a, b, 0.5);
713 assert_eq!(mid, Color32::from_rgb(50, 100, 25));
714 }
715
716 #[test]
717 fn lerp_color_quarter() {
718 let a = Color32::from_rgb(0, 0, 0);
719 let b = Color32::from_rgb(100, 200, 40);
720 let result = lerp_color(a, b, 0.25);
721 assert_eq!(result, Color32::from_rgb(25, 50, 10));
722 }
723
724 #[test]
725 fn lerp_color_identical_returns_same() {
726 let c = Color32::from_rgb(42, 42, 42);
727 assert_eq!(lerp_color(c, c, 0.5), c);
728 }
729
730 #[test]
731 fn lerp_color_black_to_white() {
732 let black = Color32::from_rgb(0, 0, 0);
733 let white = Color32::from_rgb(255, 255, 255);
734 let mid = lerp_color(black, white, 0.5);
735 assert_eq!(mid, Color32::from_rgb(128, 128, 128));
736 }
737
738 #[test]
739 fn lerp_color_white_to_black() {
740 let black = Color32::from_rgb(0, 0, 0);
741 let white = Color32::from_rgb(255, 255, 255);
742 let mid = lerp_color(white, black, 0.5);
743 assert_eq!(mid, Color32::from_rgb(128, 128, 128));
744 }
745
746 // ---------------------------------------------------------------
747 // parse_hex
748 // ---------------------------------------------------------------
749
750 #[test]
751 fn parse_hex_valid() {
752 assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0)));
753 assert_eq!(parse_hex("#00ff00"), Some(Color32::from_rgb(0, 255, 0)));
754 assert_eq!(parse_hex("#0000ff"), Some(Color32::from_rgb(0, 0, 255)));
755 }
756
757 #[test]
758 fn parse_hex_black_and_white() {
759 assert_eq!(parse_hex("#000000"), Some(Color32::from_rgb(0, 0, 0)));
760 assert_eq!(parse_hex("#ffffff"), Some(Color32::from_rgb(255, 255, 255)));
761 }
762
763 #[test]
764 fn parse_hex_uppercase() {
765 assert_eq!(parse_hex("#FF8800"), Some(Color32::from_rgb(255, 136, 0)));
766 }
767
768 #[test]
769 fn parse_hex_mixed_case() {
770 assert_eq!(parse_hex("#aAbBcC"), Some(Color32::from_rgb(0xAA, 0xBB, 0xCC)));
771 }
772
773 #[test]
774 fn parse_hex_missing_hash() {
775 assert_eq!(parse_hex("ff0000"), None);
776 }
777
778 #[test]
779 fn parse_hex_too_short() {
780 assert_eq!(parse_hex("#fff"), None);
781 }
782
783 #[test]
784 fn parse_hex_too_long() {
785 assert_eq!(parse_hex("#ff00ff00"), None);
786 }
787
788 #[test]
789 fn parse_hex_invalid_digits() {
790 assert_eq!(parse_hex("#gggggg"), None);
791 }
792
793 #[test]
794 fn parse_hex_empty_string() {
795 assert_eq!(parse_hex(""), None);
796 }
797
798 #[test]
799 fn parse_hex_just_hash() {
800 assert_eq!(parse_hex("#"), None);
801 }
802
803 #[test]
804 fn parse_hex_specific_theme_color() {
805 // Tokyo Night bg_primary
806 assert_eq!(parse_hex("#1a1b26"), Some(Color32::from_rgb(0x1a, 0x1b, 0x26)));
807 }
808
809 // ---------------------------------------------------------------
810 // get_color
811 // ---------------------------------------------------------------
812
813 #[test]
814 fn get_color_found() {
815 let mut map = HashMap::new();
816 map.insert("accent.blue".to_string(), "#7aa2f7".to_string());
817 assert_eq!(
818 get_color(&map, "accent.blue"),
819 Some(Color32::from_rgb(0x7a, 0xa2, 0xf7))
820 );
821 }
822
823 #[test]
824 fn get_color_missing_key() {
825 let map = HashMap::new();
826 assert_eq!(get_color(&map, "accent.blue"), None);
827 }
828
829 #[test]
830 fn get_color_invalid_value() {
831 let mut map = HashMap::new();
832 map.insert("accent.blue".to_string(), "not-a-color".to_string());
833 assert_eq!(get_color(&map, "accent.blue"), None);
834 }
835
836 #[test]
837 fn get_color_empty_value() {
838 let mut map = HashMap::new();
839 map.insert("bg.primary".to_string(), String::new());
840 assert_eq!(get_color(&map, "bg.primary"), None);
841 }
842
843 // ---------------------------------------------------------------
844 // parse_theme
845 // ---------------------------------------------------------------
846
847 fn full_theme_toml() -> &'static str {
848 r##"
849 [meta]
850 name = "Test Theme"
851 variant = "dark"
852
853 [background]
854 primary = "#1a1b26"
855 secondary = "#16161e"
856 tertiary = "#283457"
857 surface = "#1a1b26"
858
859 [foreground]
860 primary = "#c0caf5"
861 secondary = "#a9b1d6"
862 muted = "#565f89"
863
864 [accent]
865 red = "#f7768e"
866 green = "#9ece6a"
867 blue = "#7aa2f7"
868 yellow = "#e0af68"
869 purple = "#9d7cd8"
870 cyan = "#7dcfff"
871
872 [border]
873 default = "#3b4261"
874 "##
875 }
876
877 #[test]
878 fn parse_theme_full() {
879 let theme = parse_theme(full_theme_toml()).unwrap();
880 assert_eq!(theme.bg_primary, Color32::from_rgb(0x1a, 0x1b, 0x26));
881 assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e));
882 assert_eq!(theme.bg_tertiary, Color32::from_rgb(0x28, 0x34, 0x57));
883 assert_eq!(theme.bg_surface, Color32::from_rgb(0x1a, 0x1b, 0x26));
884 assert_eq!(theme.fg_primary, Color32::from_rgb(0xc0, 0xca, 0xf5));
885 assert_eq!(theme.fg_secondary, Color32::from_rgb(0xa9, 0xb1, 0xd6));
886 assert_eq!(theme.fg_muted, Color32::from_rgb(0x56, 0x5f, 0x89));
887 assert_eq!(theme.accent_red, Color32::from_rgb(0xf7, 0x76, 0x8e));
888 assert_eq!(theme.accent_green, Color32::from_rgb(0x9e, 0xce, 0x6a));
889 assert_eq!(theme.accent_blue, Color32::from_rgb(0x7a, 0xa2, 0xf7));
890 assert_eq!(theme.accent_yellow, Color32::from_rgb(0xe0, 0xaf, 0x68));
891 assert_eq!(theme.accent_purple, Color32::from_rgb(0x9d, 0x7c, 0xd8));
892 assert_eq!(theme.accent_cyan, Color32::from_rgb(0x7d, 0xcf, 0xff));
893 assert_eq!(theme.border_default, Color32::from_rgb(0x3b, 0x42, 0x61));
894 }
895
896 #[test]
897 fn parse_theme_missing_sections_uses_defaults() {
898 // Only background section; other sections should get fallback colors
899 let toml = r##"
900 [background]
901 primary = "#112233"
902 "##;
903 let theme = parse_theme(toml).unwrap();
904 assert_eq!(theme.bg_primary, Color32::from_rgb(0x11, 0x22, 0x33));
905 // Missing background keys fall back to BLACK
906 assert_eq!(theme.bg_secondary, Color32::BLACK);
907 // Missing foreground falls back to WHITE
908 assert_eq!(theme.fg_primary, Color32::WHITE);
909 assert_eq!(theme.fg_secondary, Color32::WHITE);
910 // Missing muted falls back to GRAY
911 assert_eq!(theme.fg_muted, Color32::GRAY);
912 // Missing accents fall back to named colors
913 assert_eq!(theme.accent_red, Color32::RED);
914 assert_eq!(theme.accent_green, Color32::GREEN);
915 assert_eq!(theme.accent_blue, Color32::BLUE);
916 assert_eq!(theme.accent_yellow, Color32::YELLOW);
917 assert_eq!(theme.border_default, Color32::DARK_GRAY);
918 }
919
920 #[test]
921 fn parse_theme_empty_toml() {
922 // No sections at all: everything falls back to defaults
923 let theme = parse_theme("").unwrap();
924 assert_eq!(theme.bg_primary, Color32::BLACK);
925 assert_eq!(theme.fg_primary, Color32::WHITE);
926 assert_eq!(theme.accent_red, Color32::RED);
927 }
928
929 #[test]
930 fn parse_theme_invalid_toml() {
931 assert!(parse_theme("this is not [valid toml [[[").is_err());
932 }
933
934 #[test]
935 fn parse_theme_partial_accent() {
936 let toml = r##"
937 [accent]
938 red = "#ff0000"
939 blue = "#0000ff"
940 "##;
941 let theme = parse_theme(toml).unwrap();
942 assert_eq!(theme.accent_red, Color32::from_rgb(255, 0, 0));
943 assert_eq!(theme.accent_blue, Color32::from_rgb(0, 0, 255));
944 // green missing, falls back
945 assert_eq!(theme.accent_green, Color32::GREEN);
946 }
947
948 #[test]
949 fn parse_theme_ignores_extra_sections() {
950 let toml = r##"
951 [meta]
952 name = "Extra"
953 variant = "dark"
954
955 [background]
956 primary = "#aabbcc"
957
958 [custom_section]
959 foo = "bar"
960 "##;
961 let theme = parse_theme(toml).unwrap();
962 assert_eq!(theme.bg_primary, Color32::from_rgb(0xaa, 0xbb, 0xcc));
963 }
964
965 #[test]
966 fn parse_theme_invalid_hex_in_field_uses_fallback() {
967 let toml = r##"
968 [background]
969 primary = "not-a-color"
970 secondary = "#16161e"
971 "##;
972 let theme = parse_theme(toml).unwrap();
973 // Invalid hex falls back to BLACK for bg
974 assert_eq!(theme.bg_primary, Color32::BLACK);
975 // Valid hex parses correctly
976 assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e));
977 }
978
979 // ---------------------------------------------------------------
980 // classification_color
981 // ---------------------------------------------------------------
982
983 #[test]
984 fn classification_color_known_classes() {
985 assert_eq!(classification_color("kick"), Color32::from_rgb(0xE0, 0x50, 0x50));
986 assert_eq!(classification_color("snare"), Color32::from_rgb(0xE0, 0x90, 0x40));
987 assert_eq!(classification_color("hihat"), Color32::from_rgb(0xE0, 0xD0, 0x40));
988 assert_eq!(classification_color("cymbal"), Color32::from_rgb(0xC8, 0xC8, 0x50));
989 assert_eq!(classification_color("percussion"), Color32::from_rgb(0xD0, 0x70, 0xB0));
990 assert_eq!(classification_color("bass"), Color32::from_rgb(0x50, 0x80, 0xE0));
991 assert_eq!(classification_color("vocal"), Color32::from_rgb(0x50, 0xC0, 0xE0));
992 assert_eq!(classification_color("synth"), Color32::from_rgb(0xA0, 0x60, 0xE0));
993 assert_eq!(classification_color("pad"), Color32::from_rgb(0x60, 0xB0, 0x60));
994 assert_eq!(classification_color("misc"), Color32::from_rgb(0xE0, 0x60, 0x80));
995 assert_eq!(classification_color("noise"), Color32::from_rgb(0x90, 0x90, 0x90));
996 assert_eq!(classification_color("music"), Color32::from_rgb(0x70, 0xC0, 0xA0));
997 }
998
999 #[test]
1000 fn classification_color_unknown_falls_back_to_text_secondary() {
1001 // The fallback calls text_secondary() which reads the global THEME.
1002 let fallback = classification_color("unknown_class");
1003 assert_eq!(fallback, text_secondary());
1004 }
1005
1006 #[test]
1007 fn classification_color_empty_string_falls_back() {
1008 let fallback = classification_color("");
1009 assert_eq!(fallback, text_secondary());
1010 }
1011
1012 #[test]
1013 fn classification_color_case_sensitive() {
1014 // "Kick" != "kick" -- should fall back
1015 let result = classification_color("Kick");
1016 assert_eq!(result, text_secondary());
1017 }
1018
1019 // ---------------------------------------------------------------
1020 // ThemeColors::default
1021 // ---------------------------------------------------------------
1022
1023 #[test]
1024 fn theme_colors_default_is_audiofiles() {
1025 let d = ThemeColors::default();
1026 assert_eq!(d.bg_primary, Color32::from_rgb(0x00, 0x00, 0x00));
1027 assert_eq!(d.fg_primary, Color32::from_rgb(0xff, 0xff, 0xff));
1028 assert_eq!(d.accent_blue, Color32::from_rgb(0x0a, 0x84, 0xff));
1029 assert_eq!(d.border_default, Color32::from_rgb(0x33, 0x33, 0x33));
1030 }
1031
1032 // ---------------------------------------------------------------
1033 // Bundled theme parsing (round-trip all embedded themes)
1034 // ---------------------------------------------------------------
1035
1036 #[test]
1037 fn all_bundled_themes_parse_successfully() {
1038 for (id, content) in BUNDLED_THEMES {
1039 let result = parse_theme(content);
1040 assert!(result.is_ok(), "Bundled theme '{id}' failed to parse: {:?}", result.err());
1041 }
1042 }
1043
1044 #[test]
1045 fn all_bundled_themes_have_valid_meta() {
1046 for (id, content) in BUNDLED_THEMES {
1047 let table: toml::Table = content.parse()
1048 .unwrap_or_else(|e| panic!("Bundled theme '{id}' is invalid TOML: {e}"));
1049 let meta = theme_common::parse_meta(id, &table, false);
1050 assert!(!meta.name.is_empty(), "Bundled theme '{id}' has empty name");
1051 assert!(!meta.variant.is_empty(), "Bundled theme '{id}' has empty variant");
1052 }
1053 }
1054
1055 #[test]
1056 fn bundled_theme_colors_are_not_all_black() {
1057 // Sanity check: a properly parsed theme shouldn't have all-black fields
1058 for (id, content) in BUNDLED_THEMES {
1059 let theme = parse_theme(content).unwrap();
1060 let all_black = theme.bg_primary == Color32::BLACK
1061 && theme.fg_primary == Color32::BLACK
1062 && theme.accent_blue == Color32::BLACK;
1063 assert!(!all_black, "Bundled theme '{id}' parsed to all-black colors");
1064 }
1065 }
1066 }
1067