Skip to main content

max / audiofiles

36.9 KB · 990 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(0x70, 0x70, 0x70),
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 Color32::from_rgba_premultiplied(0, 0, 0, 160)
248 }
249
250 // --- Theme discovery ---
251
252 /// Return the custom themes directory (`<config>/audiofiles/themes/`).
253 pub fn custom_themes_dir() -> Option<PathBuf> {
254 dirs::config_dir().map(|c| c.join("audiofiles").join("themes"))
255 }
256
257 /// List all available themes (bundled + custom). Custom themes override bundled by ID.
258 pub fn list_themes() -> Vec<ThemeMeta> {
259 let mut themes: Vec<ThemeMeta> = Vec::new();
260 let mut seen = std::collections::HashSet::new();
261
262 // Bundled themes first
263 for (id, content) in BUNDLED_THEMES {
264 let table: toml::Table = match content.parse() {
265 Ok(t) => t,
266 Err(_) => continue,
267 };
268 seen.insert(id.to_string());
269 themes.push(theme_common::parse_meta(id, &table, false));
270 }
271
272 // Custom themes from config dir (override bundled by ID)
273 if let Some(dir) = custom_themes_dir() {
274 if let Ok(entries) = std::fs::read_dir(&dir) {
275 for entry in entries.flatten() {
276 let path = entry.path();
277 if path.extension().is_some_and(|e| e == "toml") {
278 let id = path.file_stem()
279 .unwrap_or_default()
280 .to_string_lossy()
281 .to_string();
282 if let Ok(content) = std::fs::read_to_string(&path) {
283 let table: toml::Table = match content.parse() {
284 Ok(t) => t,
285 Err(_) => continue,
286 };
287 let meta = theme_common::parse_meta(&id, &table, true);
288 if seen.contains(&id) {
289 if let Some(existing) = themes.iter_mut().find(|t| t.id == id) {
290 existing.name = meta.name;
291 existing.variant = meta.variant;
292 existing.is_custom = true;
293 }
294 } else {
295 seen.insert(id.clone());
296 themes.push(meta);
297 }
298 }
299 }
300 }
301 }
302 }
303
304 themes
305 }
306
307 // --- TOML loading ---
308
309 /// Parse a `#RRGGBB` hex string into an egui `Color32`. Returns `None` for
310 /// malformed input (missing `#`, wrong length, non-hex digits).
311 fn parse_hex(s: &str) -> Option<Color32> {
312 let h = s.strip_prefix('#')?;
313 if h.len() != 6 {
314 return None;
315 }
316 let r = u8::from_str_radix(&h[0..2], 16).ok()?;
317 let g = u8::from_str_radix(&h[2..4], 16).ok()?;
318 let b = u8::from_str_radix(&h[4..6], 16).ok()?;
319 Some(Color32::from_rgb(r, g, b))
320 }
321
322 /// Look up a dot-notation key (e.g., `"accent.blue"`) in the parsed TOML color map
323 /// and convert it to `Color32`. Returns `None` if the key is missing or unparseable.
324 fn get_color(colors: &HashMap<String, String>, key: &str) -> Option<Color32> {
325 colors.get(key).and_then(|v| parse_hex(v))
326 }
327
328 /// Parse TOML content string into `ThemeColors`.
329 fn parse_theme(content: &str) -> Result<ThemeColors, toml::de::Error> {
330 let table: toml::Table = content.parse()?;
331
332 let colors = theme_common::extract_colors(&table);
333
334 // Parse optional [spacing] section
335 let spacing = table.get("spacing").and_then(|s| s.as_table());
336 let get_f32 = |key: &str, default: f32| -> f32 {
337 spacing
338 .and_then(|s| s.get(key))
339 .and_then(|v| v.as_float().map(|f| f as f32).or_else(|| v.as_integer().map(|i| i as f32)))
340 .unwrap_or(default)
341 };
342
343 Ok(ThemeColors {
344 bg_primary: get_color(&colors, "background.primary").unwrap_or(Color32::BLACK),
345 bg_secondary: get_color(&colors, "background.secondary").unwrap_or(Color32::BLACK),
346 bg_tertiary: get_color(&colors, "background.tertiary").unwrap_or(Color32::BLACK),
347 bg_surface: get_color(&colors, "background.surface").unwrap_or(Color32::BLACK),
348 fg_primary: get_color(&colors, "foreground.primary").unwrap_or(Color32::WHITE),
349 fg_secondary: get_color(&colors, "foreground.secondary").unwrap_or(Color32::WHITE),
350 fg_muted: get_color(&colors, "foreground.muted").unwrap_or(Color32::GRAY),
351 accent_red: get_color(&colors, "accent.red").unwrap_or(Color32::RED),
352 accent_green: get_color(&colors, "accent.green").unwrap_or(Color32::GREEN),
353 accent_blue: get_color(&colors, "accent.blue").unwrap_or(Color32::BLUE),
354 accent_yellow: get_color(&colors, "accent.yellow").unwrap_or(Color32::YELLOW),
355 accent_purple: get_color(&colors, "accent.purple").unwrap_or(Color32::from_rgb(0xBD, 0x93, 0xF9)),
356 accent_cyan: get_color(&colors, "accent.cyan").unwrap_or(Color32::from_rgb(0x88, 0xC0, 0xD0)),
357 border_default: get_color(&colors, "border.default").unwrap_or(Color32::DARK_GRAY),
358 rounding: get_f32("rounding", 4.0),
359 item_spacing_x: get_f32("item_spacing_x", 8.0),
360 item_spacing_y: get_f32("item_spacing_y", 5.0),
361 section_spacing: get_f32("section_spacing", 16.0),
362 grid_row_spacing: get_f32("grid_row_spacing", 6.0),
363 button_padding_x: get_f32("button_padding_x", 8.0),
364 button_padding_y: get_f32("button_padding_y", 4.0),
365 window_margin: get_f32("window_margin", 10.0),
366 indent: get_f32("indent", 18.0),
367 })
368 }
369
370 /// Load a theme from a TOML file path. Returns the parsed ThemeColors.
371 pub fn load_theme(path: &Path) -> Result<ThemeColors, crate::error::ThemeError> {
372 let content = std::fs::read_to_string(path).map_err(|e| crate::error::ThemeError::Read {
373 path: path.to_path_buf(),
374 source: e,
375 })?;
376 parse_theme(&content).map_err(|e| crate::error::ThemeError::Parse {
377 path: path.to_path_buf(),
378 source: e,
379 })
380 }
381
382 /// Initialise the active theme. If `id` is provided, loads that theme;
383 /// otherwise falls back to "tokyonight".
384 pub fn init(id: Option<&str>) {
385 set_theme(id.unwrap_or("audiofiles"));
386 }
387
388 /// Switch the active theme. Checks bundled themes first, then custom directory.
389 pub fn set_theme(id: &str) {
390 // Try bundled themes first
391 for (bundled_id, content) in BUNDLED_THEMES {
392 if *bundled_id == id {
393 match parse_theme(content) {
394 Ok(colors) => {
395 *THEME.write() = colors;
396 return;
397 }
398 Err(e) => {
399 error!("Failed to parse bundled theme '{id}': {e}");
400 return;
401 }
402 }
403 }
404 }
405
406 // Try custom themes directory
407 if let Some(dir) = custom_themes_dir() {
408 let path = dir.join(format!("{id}.toml"));
409 if path.exists() {
410 match load_theme(&path) {
411 Ok(colors) => {
412 *THEME.write() = colors;
413 return;
414 }
415 Err(e) => {
416 error!("Failed to load custom theme '{id}': {e}");
417 return;
418 }
419 }
420 }
421 }
422
423 warn!("Theme '{id}' not found; keeping current theme");
424 }
425
426 /// Get preview colors (background, accent, foreground) for a theme by ID.
427 /// Returns (bg_primary, accent_blue, fg_primary) or None if theme can't be loaded.
428 pub fn theme_preview_colors(id: &str) -> Option<(Color32, Color32, Color32)> {
429 let content = {
430 // Check bundled first
431 let mut found = None;
432 for (bundled_id, c) in BUNDLED_THEMES {
433 if *bundled_id == id {
434 found = Some(c.to_string());
435 break;
436 }
437 }
438 if found.is_none() {
439 if let Some(dir) = custom_themes_dir() {
440 let path = dir.join(format!("{id}.toml"));
441 found = std::fs::read_to_string(&path).ok();
442 }
443 }
444 found?
445 };
446
447 let table: toml::Table = content.parse().ok()?;
448 let colors = theme_common::extract_colors(&table);
449 let bg = get_color(&colors, "background.primary").unwrap_or(Color32::from_rgb(30, 30, 30));
450 let accent = get_color(&colors, "accent.blue").unwrap_or(Color32::from_rgb(100, 100, 255));
451 let fg = get_color(&colors, "foreground.primary").unwrap_or(Color32::from_rgb(220, 220, 220));
452 Some((bg, accent, fg))
453 }
454
455 /// Export a theme's TOML content by ID. Checks custom directory first, then
456 /// bundled themes. Returns the raw TOML string or `None` if not found.
457 pub fn export_theme_content(id: &str) -> Option<String> {
458 // Check custom directory first
459 if let Some(dir) = custom_themes_dir() {
460 let path = dir.join(format!("{id}.toml"));
461 if let Ok(content) = std::fs::read_to_string(&path) {
462 return Some(content);
463 }
464 }
465
466 // Check bundled themes
467 for (bundled_id, content) in BUNDLED_THEMES {
468 if *bundled_id == id {
469 return Some(content.to_string());
470 }
471 }
472
473 None
474 }
475
476 // --- Classification colors (domain-specific, not from theme TOML) ---
477
478 /// Map a sample classification name to a distinct display color.
479 ///
480 /// These are hardcoded rather than theme-driven because they represent semantic
481 /// categories (kick=red, bass=blue, vocal=cyan, etc.) that should stay consistent
482 /// across themes for muscle-memory recognition.
483 pub fn classification_color(class: &str) -> Color32 {
484 match class {
485 "kick" => Color32::from_rgb(0xE0, 0x50, 0x50),
486 "snare" => Color32::from_rgb(0xE0, 0x90, 0x40),
487 "hihat" => Color32::from_rgb(0xE0, 0xD0, 0x40),
488 "cymbal" => Color32::from_rgb(0xC8, 0xC8, 0x50),
489 "percussion" => Color32::from_rgb(0xD0, 0x70, 0xB0),
490 "bass" => Color32::from_rgb(0x50, 0x80, 0xE0),
491 "vocal" => Color32::from_rgb(0x50, 0xC0, 0xE0),
492 "synth" => Color32::from_rgb(0xA0, 0x60, 0xE0),
493 "pad" => Color32::from_rgb(0x60, 0xB0, 0x60),
494 "misc" => Color32::from_rgb(0xE0, 0x60, 0x80),
495 "noise" => Color32::from_rgb(0x90, 0x90, 0x90),
496 "music" => Color32::from_rgb(0x70, 0xC0, 0xA0),
497 _ => text_secondary(),
498 }
499 }
500
501 /// Register the logo font with egui. Must be called once before the first frame
502 /// (e.g. from the eframe `CreationContext` or nih-plug init callback), because
503 /// `ctx.set_fonts()` only takes effect on the next frame.
504 pub fn setup_fonts(ctx: &egui::Context) {
505 let mut fonts = egui::FontDefinitions::default();
506 fonts.font_data.insert(
507 LOGO_FONT_FAMILY.to_owned(),
508 egui::FontData::from_static(LOGO_FONT).into(),
509 );
510 fonts.families.insert(
511 egui::FontFamily::Name(LOGO_FONT_FAMILY.into()),
512 vec![LOGO_FONT_FAMILY.to_owned(), "Hack".to_owned()],
513 );
514 ctx.set_fonts(fonts);
515 }
516
517 /// Apply the current theme's visuals to the egui context.
518 pub fn apply_theme(ctx: &egui::Context) {
519 let t = THEME.read();
520 let mut visuals = egui::Visuals::dark();
521
522 visuals.panel_fill = t.bg_secondary;
523 visuals.window_fill = t.bg_secondary;
524 visuals.extreme_bg_color = t.bg_primary;
525 visuals.faint_bg_color = lerp_color(t.bg_primary, t.bg_secondary, 0.3);
526
527 visuals.selection.bg_fill = lerp_color(t.bg_primary, t.accent_blue, 0.3);
528 visuals.selection.stroke = egui::Stroke::new(1.0, contrast_color(lerp_color(t.bg_primary, t.accent_blue, 0.3)));
529
530 visuals.widgets.noninteractive.bg_fill = t.bg_secondary;
531 visuals.widgets.inactive.bg_fill = lerp_color(t.bg_secondary, t.bg_tertiary, 0.3);
532 visuals.widgets.hovered.bg_fill = t.bg_tertiary;
533 visuals.widgets.active.bg_fill = t.accent_blue;
534
535 visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, t.fg_secondary);
536 visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
537 visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
538 visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, contrast_color(t.accent_blue));
539 visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, t.fg_primary);
540
541 visuals.window_stroke = egui::Stroke::new(1.0, t.border_default);
542 visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border_default);
543
544 // Softer edges on all widgets
545 let rounding = egui::CornerRadius::same(t.rounding as u8);
546 visuals.widgets.noninteractive.corner_radius = rounding;
547 visuals.widgets.inactive.corner_radius = rounding;
548 visuals.widgets.hovered.corner_radius = rounding;
549 visuals.widgets.active.corner_radius = rounding;
550 visuals.widgets.open.corner_radius = rounding;
551
552 // Softer widget borders: thinner strokes on inactive/hover states
553 visuals.widgets.inactive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.3));
554 visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, t.border_default);
555 visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, t.accent_blue);
556
557 // Softer separator color
558 visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(0.5, lerp_color(t.border_default, t.bg_secondary, 0.4));
559
560 // Widget expansion on hover for tactile feedback
561 visuals.widgets.hovered.expansion = 1.0;
562 visuals.widgets.active.expansion = 0.0;
563
564 let spacing_x = t.item_spacing_x;
565 let spacing_y = t.item_spacing_y;
566 let btn_pad_x = t.button_padding_x;
567 let btn_pad_y = t.button_padding_y;
568 let win_margin = t.window_margin;
569 let indent = t.indent;
570 drop(t);
571 ctx.set_visuals(visuals);
572
573 // Apply theme spacing
574 let mut style = (*ctx.style()).clone();
575 style.spacing.item_spacing = egui::vec2(spacing_x, spacing_y);
576 style.spacing.button_padding = egui::vec2(btn_pad_x, btn_pad_y);
577 style.spacing.window_margin = egui::vec2(win_margin, win_margin).into();
578 style.spacing.indent = indent;
579 ctx.set_style(style);
580 }
581
582 #[cfg(test)]
583 mod tests {
584 use super::*;
585 use std::collections::HashMap;
586
587 // ---------------------------------------------------------------
588 // lerp_color
589 // ---------------------------------------------------------------
590
591 #[test]
592 fn contrast_color_black_bg_returns_white() {
593 assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE);
594 }
595
596 #[test]
597 fn contrast_color_white_bg_returns_black() {
598 assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK);
599 }
600
601 #[test]
602 fn contrast_color_dark_blue_returns_white() {
603 // Dark blue (accent_blue from tokyonight: #7aa2f7 → luminance ~0.62)
604 // Actually 0x0a84ff is the default accent_blue (very dark blue)
605 assert_eq!(contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)), Color32::WHITE);
606 }
607
608 #[test]
609 fn contrast_color_bright_yellow_returns_black() {
610 assert_eq!(contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)), Color32::BLACK);
611 }
612
613 // ---------------------------------------------------------------
614 // lerp_color
615 // ---------------------------------------------------------------
616
617 #[test]
618 fn lerp_color_t0_returns_a() {
619 let a = Color32::from_rgb(100, 150, 200);
620 let b = Color32::from_rgb(200, 50, 0);
621 assert_eq!(lerp_color(a, b, 0.0), a);
622 }
623
624 #[test]
625 fn lerp_color_t1_returns_b() {
626 let a = Color32::from_rgb(100, 150, 200);
627 let b = Color32::from_rgb(200, 50, 0);
628 assert_eq!(lerp_color(a, b, 1.0), b);
629 }
630
631 #[test]
632 fn lerp_color_midpoint() {
633 let a = Color32::from_rgb(0, 0, 0);
634 let b = Color32::from_rgb(100, 200, 50);
635 let mid = lerp_color(a, b, 0.5);
636 assert_eq!(mid, Color32::from_rgb(50, 100, 25));
637 }
638
639 #[test]
640 fn lerp_color_quarter() {
641 let a = Color32::from_rgb(0, 0, 0);
642 let b = Color32::from_rgb(100, 200, 40);
643 let result = lerp_color(a, b, 0.25);
644 assert_eq!(result, Color32::from_rgb(25, 50, 10));
645 }
646
647 #[test]
648 fn lerp_color_identical_returns_same() {
649 let c = Color32::from_rgb(42, 42, 42);
650 assert_eq!(lerp_color(c, c, 0.5), c);
651 }
652
653 #[test]
654 fn lerp_color_black_to_white() {
655 let black = Color32::from_rgb(0, 0, 0);
656 let white = Color32::from_rgb(255, 255, 255);
657 let mid = lerp_color(black, white, 0.5);
658 assert_eq!(mid, Color32::from_rgb(128, 128, 128));
659 }
660
661 #[test]
662 fn lerp_color_white_to_black() {
663 let black = Color32::from_rgb(0, 0, 0);
664 let white = Color32::from_rgb(255, 255, 255);
665 let mid = lerp_color(white, black, 0.5);
666 assert_eq!(mid, Color32::from_rgb(128, 128, 128));
667 }
668
669 // ---------------------------------------------------------------
670 // parse_hex
671 // ---------------------------------------------------------------
672
673 #[test]
674 fn parse_hex_valid() {
675 assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0)));
676 assert_eq!(parse_hex("#00ff00"), Some(Color32::from_rgb(0, 255, 0)));
677 assert_eq!(parse_hex("#0000ff"), Some(Color32::from_rgb(0, 0, 255)));
678 }
679
680 #[test]
681 fn parse_hex_black_and_white() {
682 assert_eq!(parse_hex("#000000"), Some(Color32::from_rgb(0, 0, 0)));
683 assert_eq!(parse_hex("#ffffff"), Some(Color32::from_rgb(255, 255, 255)));
684 }
685
686 #[test]
687 fn parse_hex_uppercase() {
688 assert_eq!(parse_hex("#FF8800"), Some(Color32::from_rgb(255, 136, 0)));
689 }
690
691 #[test]
692 fn parse_hex_mixed_case() {
693 assert_eq!(parse_hex("#aAbBcC"), Some(Color32::from_rgb(0xAA, 0xBB, 0xCC)));
694 }
695
696 #[test]
697 fn parse_hex_missing_hash() {
698 assert_eq!(parse_hex("ff0000"), None);
699 }
700
701 #[test]
702 fn parse_hex_too_short() {
703 assert_eq!(parse_hex("#fff"), None);
704 }
705
706 #[test]
707 fn parse_hex_too_long() {
708 assert_eq!(parse_hex("#ff00ff00"), None);
709 }
710
711 #[test]
712 fn parse_hex_invalid_digits() {
713 assert_eq!(parse_hex("#gggggg"), None);
714 }
715
716 #[test]
717 fn parse_hex_empty_string() {
718 assert_eq!(parse_hex(""), None);
719 }
720
721 #[test]
722 fn parse_hex_just_hash() {
723 assert_eq!(parse_hex("#"), None);
724 }
725
726 #[test]
727 fn parse_hex_specific_theme_color() {
728 // Tokyo Night bg_primary
729 assert_eq!(parse_hex("#1a1b26"), Some(Color32::from_rgb(0x1a, 0x1b, 0x26)));
730 }
731
732 // ---------------------------------------------------------------
733 // get_color
734 // ---------------------------------------------------------------
735
736 #[test]
737 fn get_color_found() {
738 let mut map = HashMap::new();
739 map.insert("accent.blue".to_string(), "#7aa2f7".to_string());
740 assert_eq!(
741 get_color(&map, "accent.blue"),
742 Some(Color32::from_rgb(0x7a, 0xa2, 0xf7))
743 );
744 }
745
746 #[test]
747 fn get_color_missing_key() {
748 let map = HashMap::new();
749 assert_eq!(get_color(&map, "accent.blue"), None);
750 }
751
752 #[test]
753 fn get_color_invalid_value() {
754 let mut map = HashMap::new();
755 map.insert("accent.blue".to_string(), "not-a-color".to_string());
756 assert_eq!(get_color(&map, "accent.blue"), None);
757 }
758
759 #[test]
760 fn get_color_empty_value() {
761 let mut map = HashMap::new();
762 map.insert("bg.primary".to_string(), String::new());
763 assert_eq!(get_color(&map, "bg.primary"), None);
764 }
765
766 // ---------------------------------------------------------------
767 // parse_theme
768 // ---------------------------------------------------------------
769
770 fn full_theme_toml() -> &'static str {
771 r##"
772 [meta]
773 name = "Test Theme"
774 variant = "dark"
775
776 [background]
777 primary = "#1a1b26"
778 secondary = "#16161e"
779 tertiary = "#283457"
780 surface = "#1a1b26"
781
782 [foreground]
783 primary = "#c0caf5"
784 secondary = "#a9b1d6"
785 muted = "#565f89"
786
787 [accent]
788 red = "#f7768e"
789 green = "#9ece6a"
790 blue = "#7aa2f7"
791 yellow = "#e0af68"
792 purple = "#9d7cd8"
793 cyan = "#7dcfff"
794
795 [border]
796 default = "#3b4261"
797 "##
798 }
799
800 #[test]
801 fn parse_theme_full() {
802 let theme = parse_theme(full_theme_toml()).unwrap();
803 assert_eq!(theme.bg_primary, Color32::from_rgb(0x1a, 0x1b, 0x26));
804 assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e));
805 assert_eq!(theme.bg_tertiary, Color32::from_rgb(0x28, 0x34, 0x57));
806 assert_eq!(theme.bg_surface, Color32::from_rgb(0x1a, 0x1b, 0x26));
807 assert_eq!(theme.fg_primary, Color32::from_rgb(0xc0, 0xca, 0xf5));
808 assert_eq!(theme.fg_secondary, Color32::from_rgb(0xa9, 0xb1, 0xd6));
809 assert_eq!(theme.fg_muted, Color32::from_rgb(0x56, 0x5f, 0x89));
810 assert_eq!(theme.accent_red, Color32::from_rgb(0xf7, 0x76, 0x8e));
811 assert_eq!(theme.accent_green, Color32::from_rgb(0x9e, 0xce, 0x6a));
812 assert_eq!(theme.accent_blue, Color32::from_rgb(0x7a, 0xa2, 0xf7));
813 assert_eq!(theme.accent_yellow, Color32::from_rgb(0xe0, 0xaf, 0x68));
814 assert_eq!(theme.accent_purple, Color32::from_rgb(0x9d, 0x7c, 0xd8));
815 assert_eq!(theme.accent_cyan, Color32::from_rgb(0x7d, 0xcf, 0xff));
816 assert_eq!(theme.border_default, Color32::from_rgb(0x3b, 0x42, 0x61));
817 }
818
819 #[test]
820 fn parse_theme_missing_sections_uses_defaults() {
821 // Only background section; other sections should get fallback colors
822 let toml = r##"
823 [background]
824 primary = "#112233"
825 "##;
826 let theme = parse_theme(toml).unwrap();
827 assert_eq!(theme.bg_primary, Color32::from_rgb(0x11, 0x22, 0x33));
828 // Missing background keys fall back to BLACK
829 assert_eq!(theme.bg_secondary, Color32::BLACK);
830 // Missing foreground falls back to WHITE
831 assert_eq!(theme.fg_primary, Color32::WHITE);
832 assert_eq!(theme.fg_secondary, Color32::WHITE);
833 // Missing muted falls back to GRAY
834 assert_eq!(theme.fg_muted, Color32::GRAY);
835 // Missing accents fall back to named colors
836 assert_eq!(theme.accent_red, Color32::RED);
837 assert_eq!(theme.accent_green, Color32::GREEN);
838 assert_eq!(theme.accent_blue, Color32::BLUE);
839 assert_eq!(theme.accent_yellow, Color32::YELLOW);
840 assert_eq!(theme.border_default, Color32::DARK_GRAY);
841 }
842
843 #[test]
844 fn parse_theme_empty_toml() {
845 // No sections at all: everything falls back to defaults
846 let theme = parse_theme("").unwrap();
847 assert_eq!(theme.bg_primary, Color32::BLACK);
848 assert_eq!(theme.fg_primary, Color32::WHITE);
849 assert_eq!(theme.accent_red, Color32::RED);
850 }
851
852 #[test]
853 fn parse_theme_invalid_toml() {
854 assert!(parse_theme("this is not [valid toml [[[").is_err());
855 }
856
857 #[test]
858 fn parse_theme_partial_accent() {
859 let toml = r##"
860 [accent]
861 red = "#ff0000"
862 blue = "#0000ff"
863 "##;
864 let theme = parse_theme(toml).unwrap();
865 assert_eq!(theme.accent_red, Color32::from_rgb(255, 0, 0));
866 assert_eq!(theme.accent_blue, Color32::from_rgb(0, 0, 255));
867 // green missing, falls back
868 assert_eq!(theme.accent_green, Color32::GREEN);
869 }
870
871 #[test]
872 fn parse_theme_ignores_extra_sections() {
873 let toml = r##"
874 [meta]
875 name = "Extra"
876 variant = "dark"
877
878 [background]
879 primary = "#aabbcc"
880
881 [custom_section]
882 foo = "bar"
883 "##;
884 let theme = parse_theme(toml).unwrap();
885 assert_eq!(theme.bg_primary, Color32::from_rgb(0xaa, 0xbb, 0xcc));
886 }
887
888 #[test]
889 fn parse_theme_invalid_hex_in_field_uses_fallback() {
890 let toml = r##"
891 [background]
892 primary = "not-a-color"
893 secondary = "#16161e"
894 "##;
895 let theme = parse_theme(toml).unwrap();
896 // Invalid hex falls back to BLACK for bg
897 assert_eq!(theme.bg_primary, Color32::BLACK);
898 // Valid hex parses correctly
899 assert_eq!(theme.bg_secondary, Color32::from_rgb(0x16, 0x16, 0x1e));
900 }
901
902 // ---------------------------------------------------------------
903 // classification_color
904 // ---------------------------------------------------------------
905
906 #[test]
907 fn classification_color_known_classes() {
908 assert_eq!(classification_color("kick"), Color32::from_rgb(0xE0, 0x50, 0x50));
909 assert_eq!(classification_color("snare"), Color32::from_rgb(0xE0, 0x90, 0x40));
910 assert_eq!(classification_color("hihat"), Color32::from_rgb(0xE0, 0xD0, 0x40));
911 assert_eq!(classification_color("cymbal"), Color32::from_rgb(0xC8, 0xC8, 0x50));
912 assert_eq!(classification_color("percussion"), Color32::from_rgb(0xD0, 0x70, 0xB0));
913 assert_eq!(classification_color("bass"), Color32::from_rgb(0x50, 0x80, 0xE0));
914 assert_eq!(classification_color("vocal"), Color32::from_rgb(0x50, 0xC0, 0xE0));
915 assert_eq!(classification_color("synth"), Color32::from_rgb(0xA0, 0x60, 0xE0));
916 assert_eq!(classification_color("pad"), Color32::from_rgb(0x60, 0xB0, 0x60));
917 assert_eq!(classification_color("misc"), Color32::from_rgb(0xE0, 0x60, 0x80));
918 assert_eq!(classification_color("noise"), Color32::from_rgb(0x90, 0x90, 0x90));
919 assert_eq!(classification_color("music"), Color32::from_rgb(0x70, 0xC0, 0xA0));
920 }
921
922 #[test]
923 fn classification_color_unknown_falls_back_to_text_secondary() {
924 // The fallback calls text_secondary() which reads the global THEME.
925 let fallback = classification_color("unknown_class");
926 assert_eq!(fallback, text_secondary());
927 }
928
929 #[test]
930 fn classification_color_empty_string_falls_back() {
931 let fallback = classification_color("");
932 assert_eq!(fallback, text_secondary());
933 }
934
935 #[test]
936 fn classification_color_case_sensitive() {
937 // "Kick" != "kick" -- should fall back
938 let result = classification_color("Kick");
939 assert_eq!(result, text_secondary());
940 }
941
942 // ---------------------------------------------------------------
943 // ThemeColors::default
944 // ---------------------------------------------------------------
945
946 #[test]
947 fn theme_colors_default_is_audiofiles() {
948 let d = ThemeColors::default();
949 assert_eq!(d.bg_primary, Color32::from_rgb(0x00, 0x00, 0x00));
950 assert_eq!(d.fg_primary, Color32::from_rgb(0xff, 0xff, 0xff));
951 assert_eq!(d.accent_blue, Color32::from_rgb(0x0a, 0x84, 0xff));
952 assert_eq!(d.border_default, Color32::from_rgb(0x33, 0x33, 0x33));
953 }
954
955 // ---------------------------------------------------------------
956 // Bundled theme parsing (round-trip all embedded themes)
957 // ---------------------------------------------------------------
958
959 #[test]
960 fn all_bundled_themes_parse_successfully() {
961 for (id, content) in BUNDLED_THEMES {
962 let result = parse_theme(content);
963 assert!(result.is_ok(), "Bundled theme '{id}' failed to parse: {:?}", result.err());
964 }
965 }
966
967 #[test]
968 fn all_bundled_themes_have_valid_meta() {
969 for (id, content) in BUNDLED_THEMES {
970 let table: toml::Table = content.parse()
971 .unwrap_or_else(|e| panic!("Bundled theme '{id}' is invalid TOML: {e}"));
972 let meta = theme_common::parse_meta(id, &table, false);
973 assert!(!meta.name.is_empty(), "Bundled theme '{id}' has empty name");
974 assert!(!meta.variant.is_empty(), "Bundled theme '{id}' has empty variant");
975 }
976 }
977
978 #[test]
979 fn bundled_theme_colors_are_not_all_black() {
980 // Sanity check: a properly parsed theme shouldn't have all-black fields
981 for (id, content) in BUNDLED_THEMES {
982 let theme = parse_theme(content).unwrap();
983 let all_black = theme.bg_primary == Color32::BLACK
984 && theme.fg_primary == Color32::BLACK
985 && theme.accent_blue == Color32::BLACK;
986 assert!(!all_black, "Bundled theme '{id}' parsed to all-black colors");
987 }
988 }
989 }
990