valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

commit aa3754980d69c830681aef68e45ee7a37a6eeb48
parent 50199c69e1068614cec5a8dbfa5ed2e158a79f82
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 22:46:55 -0500

feat: sync 10 themes with Hydra + transparency toggle; separate Navi themes

- Adopt Hydra's theme file format verbatim (top-level name/transparent +
  [palette] of 10 keys) so the SAME .toml files work in both apps.
- Bundle Hydra's 10 themes: default, transparent, nord, gruvbox, dracula,
  rose-pine, tokyonight, catppuccin-mocha, solarized-dark, monochrome.
- Valentine's own ember/slate/mono moved to themes/navi/ (separate; load only
  if copied to ~/.config/valentine/themes/). Repo no longer auto-bundles them.
- Transparency: Theme.transparent + surface()=Color::Reset; 'T' toggles live,
  --transparent CLI flag, main bg uses surface(). Mirrors Hydra exactly.
- Derive Valentine-only roles (border_focus/bg_selected/armed/meter_*) from the
  shared palette so any Hydra theme just works. 69 tests, 0 warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Athemes/catppuccin-mocha.toml | 13+++++++++++++
Mthemes/default.toml | 50+++++++++++++++++++++-----------------------------
Athemes/dracula.toml | 13+++++++++++++
Athemes/gruvbox.toml | 13+++++++++++++
Athemes/monochrome.toml | 15+++++++++++++++
Cthemes/default.toml -> themes/navi/ember.toml | 0
Rthemes/mono.toml -> themes/navi/mono.toml | 0
Rthemes/slate.toml -> themes/navi/slate.toml | 0
Athemes/nord.toml | 13+++++++++++++
Athemes/rose-pine.toml | 13+++++++++++++
Athemes/solarized-dark.toml | 13+++++++++++++
Athemes/tokyonight.toml | 13+++++++++++++
Athemes/transparent.toml | 16++++++++++++++++
Mvalentine/src/main.rs | 32+++++++++++++++++++++++++-------
Mvalentine/src/theme.rs | 247+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
15 files changed, 310 insertions(+), 141 deletions(-)

diff --git a/themes/catppuccin-mocha.toml b/themes/catppuccin-mocha.toml @@ -0,0 +1,13 @@ +name = "catppuccin-mocha" +transparent = false +[palette] +bg = "#1e1e2e" +bg_elevated = "#313244" +fg = "#cdd6f4" +fg_dim = "#9399b2" +accent = "#fab387" +ghost = "#89b4fa" +border = "#45475a" +success = "#a6e3a1" +warning = "#f9e2af" +danger = "#f38ba8" diff --git a/themes/default.toml b/themes/default.toml @@ -1,31 +1,23 @@ -# Ember — Valentine's bundled default theme. +# Hydra theme — the built-in neutral default, written out as an editable example. # -# A tasteful dark plum / rose / coral palette: warm and a little romantic -# (fitting for "Valentine"), readable, and distinct from the usual blue dev TUI. -# This is what ships and what every user gets out of the box. -# -# Override it by dropping a TOML with these same keys at -# ~/.config/valentine/theme.toml -# -# Role meanings (shared by every theme): -# accent = selection / cursor / titles -# armed = a control that is engaged / live / changed -# good = locked / signal present -# danger = mute / error / clip -# meter_* = level-bar gradient (low → mid → high/clip) +# To add your own: copy any .toml into ~/.config/hydra/themes/ and it appears in the TUI +# theme picker (press `t`). Every key is optional — unspecified keys keep the default below. +# Colors are #rrggbb (the leading # is optional). btop-style: drop a file, it just works. + +name = "default" + +# Let the terminal's own background show through (e.g. Ghostty transparency/vibrancy). +# Also toggleable live in the TUI with `T`. UI chrome stays opaque for legibility. +transparent = false -bg = "#170e15" # plum-black — main background -bg_elevated = "#251524" # raised panel background -bg_selected = "#3a2035" # selected row / focused cell -border = "#5e2c4e" # wine — idle panel border -border_focus = "#ff5fa2" # rose — focused panel border -fg = "#f3dce7" # warm off-white -fg_dim = "#a3768f" # mauve-grey — labels / inactive -accent = "#ff5fa2" # rose — selection / cursor / titles -armed = "#ff8a5b" # coral ember — engaged / live / changed -good = "#4cc7a4" # jade — locked / present (cool counterpoint) -warn = "#ffb454" # amber -danger = "#ff3b5c" # crimson — mute / error / clip -meter_low = "#4cc7a4" # jade -meter_mid = "#ffb454" # amber -meter_high = "#ff3b5c" # crimson (clip) +[palette] +bg = "#16181d" # main background (ignored when transparent = true) +bg_elevated = "#22262e" # overlays / selected rows +fg = "#e6e8ea" # primary text +fg_dim = "#8a9099" # secondary text, hints +accent = "#e09b3e" # sparingly: selected output, marks +ghost = "#4cc2b0" # highlight: titles, cursor, active selection +border = "#3a404a" # pane borders +success = "#6cc66c" # connected, active route +warning = "#e09b3e" # approaching clip +danger = "#e06c6c" # disconnected, clip, recording diff --git a/themes/dracula.toml b/themes/dracula.toml @@ -0,0 +1,13 @@ +name = "dracula" +transparent = false +[palette] +bg = "#282a36" +bg_elevated = "#44475a" +fg = "#f8f8f2" +fg_dim = "#6272a4" +accent = "#ffb86c" +ghost = "#bd93f9" +border = "#44475a" +success = "#50fa7b" +warning = "#f1fa8c" +danger = "#ff5555" diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml @@ -0,0 +1,13 @@ +name = "gruvbox" +transparent = false +[palette] +bg = "#282828" +bg_elevated = "#3c3836" +fg = "#ebdbb2" +fg_dim = "#a89984" +accent = "#fe8019" +ghost = "#83a598" +border = "#504945" +success = "#b8bb26" +warning = "#fabd2f" +danger = "#fb4934" diff --git a/themes/monochrome.toml b/themes/monochrome.toml @@ -0,0 +1,15 @@ +# A deliberately minimal greyscale theme — a strong "different look" for testing: almost no +# colour, so the layout/typography carry the UI. +name = "monochrome" +transparent = false +[palette] +bg = "#0c0c0c" +bg_elevated = "#1c1c1c" +fg = "#e8e8e8" +fg_dim = "#707070" +accent = "#ffffff" +ghost = "#bdbdbd" +border = "#333333" +success = "#d0d0d0" +warning = "#a0a0a0" +danger = "#ffffff" diff --git a/themes/default.toml b/themes/navi/ember.toml diff --git a/themes/mono.toml b/themes/navi/mono.toml diff --git a/themes/slate.toml b/themes/navi/slate.toml diff --git a/themes/nord.toml b/themes/nord.toml @@ -0,0 +1,13 @@ +name = "nord" +transparent = false +[palette] +bg = "#2e3440" +bg_elevated = "#3b4252" +fg = "#eceff4" +fg_dim = "#81a1c1" +accent = "#ebcb8b" +ghost = "#88c0d0" +border = "#4c566a" +success = "#a3be8c" +warning = "#ebcb8b" +danger = "#bf616a" diff --git a/themes/rose-pine.toml b/themes/rose-pine.toml @@ -0,0 +1,13 @@ +name = "rose-pine" +transparent = false +[palette] +bg = "#191724" +bg_elevated = "#1f1d2e" +fg = "#e0def4" +fg_dim = "#908caa" +accent = "#ebbcba" +ghost = "#9ccfd8" +border = "#26233a" +success = "#31748f" +warning = "#f6c177" +danger = "#eb6f92" diff --git a/themes/solarized-dark.toml b/themes/solarized-dark.toml @@ -0,0 +1,13 @@ +name = "solarized-dark" +transparent = false +[palette] +bg = "#002b36" +bg_elevated = "#073642" +fg = "#93a1a1" +fg_dim = "#586e75" +accent = "#b58900" +ghost = "#2aa198" +border = "#073642" +success = "#859900" +warning = "#cb4b16" +danger = "#dc322f" diff --git a/themes/tokyonight.toml b/themes/tokyonight.toml @@ -0,0 +1,13 @@ +name = "tokyonight" +transparent = false +[palette] +bg = "#1a1b26" +bg_elevated = "#24283b" +fg = "#c0caf5" +fg_dim = "#565f89" +accent = "#ff9e64" +ghost = "#7aa2f7" +border = "#3b4261" +success = "#9ece6a" +warning = "#e0af68" +danger = "#f7768e" diff --git a/themes/transparent.toml b/themes/transparent.toml @@ -0,0 +1,16 @@ +# Hydra theme — transparent: inherits the terminal background (Ghostty vibrancy, etc.). +# Same palette as default but with the background painted-through. Foreground colors are +# kept slightly brighter so text stays readable over a translucent backdrop. + +name = "transparent" +transparent = true + +[palette] +fg = "#f0f2f4" +fg_dim = "#9aa0a9" +accent = "#f0a850" +ghost = "#5cd2c0" +border = "#4a505a" +success = "#78d278" +warning = "#f0a850" +danger = "#f07878" diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -237,7 +237,7 @@ impl App { self.save_to_nvram(); return; } - KeyCode::Char('t') | KeyCode::Char('T') => { + KeyCode::Char('t') => { let names = theme::available_themes(); let sel = names.iter().position(|n| *n == self.theme_name).unwrap_or(0); self.modal = Modal::ThemePicker { @@ -247,6 +247,15 @@ impl App { }; return; } + // Shift-T toggles transparency live (terminal background shows through). + KeyCode::Char('T') => { + self.theme.transparent = !self.theme.transparent; + self.status = Some(format!( + "transparency {}", + if self.theme.transparent { "on" } else { "off" } + )); + return; + } KeyCode::Char('?') => { self.show_help = !self.show_help; return; @@ -960,9 +969,10 @@ fn restore_terminal() { } fn main() -> Result<()> { - // CLI: --theme <name|path>, --list-themes, --help. + // CLI: --theme <name|path>, --transparent, --list-themes, --help. let args: Vec<String> = std::env::args().skip(1).collect(); let mut theme_arg: Option<String> = None; + let mut transparent_flag = false; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -974,6 +984,10 @@ fn main() -> Result<()> { theme_arg = Some(s["--theme=".len()..].to_string()); i += 1; } + "--transparent" => { + transparent_flag = true; + i += 1; + } "--list-themes" => { for n in theme::available_themes() { println!("{n}"); @@ -983,9 +997,9 @@ fn main() -> Result<()> { "--help" | "-h" => { println!( "valentine — Focusrite Scarlett 18i20 control panel\n\n\ - usage: valentine [--theme <name|path.toml>] [--list-themes]\n\n\ - themes are TOML; bundled: {}. user themes live in\n\ - ~/.config/valentine/themes/. in-app: press 't' to switch.", + usage: valentine [--theme <name|path.toml>] [--transparent] [--list-themes]\n\n\ + themes are TOML (Hydra-compatible); bundled: {}. user themes live\n\ + in ~/.config/valentine/themes/. in-app: 't' switch, 'T' transparency.", theme::available_themes().join(", ") ); return Ok(()); @@ -1010,6 +1024,9 @@ fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::new(theme_arg.as_deref()); + if transparent_flag { + app.theme.transparent = true; + } let res = run(&mut terminal, &mut app); restore_terminal(); @@ -1047,7 +1064,7 @@ fn run<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> { fn ui(f: &mut Frame, app: &App) { let t = &app.theme; let area = f.area(); - f.render_widget(Block::default().style(Style::default().bg(t.bg)), area); + f.render_widget(Block::default().style(Style::default().bg(t.surface())), area); let chunks = Layout::vertical([ Constraint::Length(1), @@ -1335,6 +1352,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) { key("S", "save current config as a named preset"), key("p", "preset picker — choose, then Y/N confirm to apply"), key("t", "theme switcher — ↑↓ live-preview, Enter keep, Esc revert"), + key("T", "toggle transparency (terminal background shows through)"), key("W", "write current config to device NVRAM"), key("r", "reconnect to the device"), Line::from(""), @@ -1402,7 +1420,7 @@ fn status_bar(app: &App) -> Paragraph<'_> { } spans.push(Span::styled( - " Tab panels · S save · p presets · t theme · W →NVRAM · ? help · q quit", + " Tab panels · S save · p presets · t/T theme · W →NVRAM · ? help · q quit", Style::default().fg(t.fg_dim).bg(t.bg_elevated), )); diff --git a/valentine/src/theme.rs b/valentine/src/theme.rs @@ -1,64 +1,79 @@ -//! Theming. The palette is data, not code. +//! Theming. The palette is data, not code — and the format is **shared with +//! Hydra**: top-level `name` + `transparent`, then a `[palette]` of 10 keys +//! (bg, bg_elevated, fg, fg_dim, accent, ghost, border, success, warning, +//! danger). The same `.toml` files drop into both apps' theme pickers. //! -//! Valentine ships several themes (Ember default + Slate, Mono) compiled in, and -//! also loads full TOML themes from disk: -//! * `--theme <name>` — a bundled theme, or a stem in `~/.config/valentine/themes/` -//! * `--theme <path>` — any `.toml` file -//! * else `~/.config/valentine/theme.toml`, else the Ember default. -//! Personal palettes (e.g. Navi) live in the user's config, never in the repo. +//! Valentine ships Hydra's 10 themes (default, transparent, nord, gruvbox, +//! dracula, rose-pine, tokyonight, catppuccin-mocha, solarized-dark, +//! monochrome). The author's personal "Navi" themes live in `themes/navi/`, +//! separate, and are loaded only if copied to `~/.config/valentine/themes/`. +//! +//! Loading order: `--theme <name|path>`, else `~/.config/valentine/theme.toml`, +//! else the bundled default. Press `t` to switch, `T` to toggle transparency. use ratatui::style::Color; use serde::Deserialize; -/// The compiled-in default (Ember). Parsed once at startup. -const DEFAULT_THEME_TOML: &str = include_str!("../../themes/default.toml"); - -/// All themes bundled with the binary: `(name, toml)`. Ember is the default. +/// Bundled themes `(name, toml)` — Hydra's set, verbatim, so the two apps stay +/// in sync. `default` is first/default. pub const BUILTIN_THEMES: &[(&str, &str)] = &[ - ("ember", include_str!("../../themes/default.toml")), - ("slate", include_str!("../../themes/slate.toml")), - ("mono", include_str!("../../themes/mono.toml")), + ("default", include_str!("../../themes/default.toml")), + ("transparent", include_str!("../../themes/transparent.toml")), + ("nord", include_str!("../../themes/nord.toml")), + ("gruvbox", include_str!("../../themes/gruvbox.toml")), + ("dracula", include_str!("../../themes/dracula.toml")), + ("rose-pine", include_str!("../../themes/rose-pine.toml")), + ("tokyonight", include_str!("../../themes/tokyonight.toml")), + ("catppuccin-mocha", include_str!("../../themes/catppuccin-mocha.toml")), + ("solarized-dark", include_str!("../../themes/solarized-dark.toml")), + ("monochrome", include_str!("../../themes/monochrome.toml")), ]; -/// A hex color string from the theme file, e.g. "#ff5fa2". -#[derive(Debug, Clone, Deserialize)] -struct Hex(String); +const DEFAULT_THEME_TOML: &str = include_str!("../../themes/default.toml"); -impl Hex { - fn color(&self) -> Color { - let s = self.0.trim_start_matches('#'); - let parse = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).unwrap_or(0); - if s.len() == 6 { - Color::Rgb(parse(0), parse(2), parse(4)) - } else { - Color::Reset - } +fn parse_hex(s: &str) -> Option<Color> { + let s = s.trim().trim_start_matches('#'); + if s.len() == 6 { + let h = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok(); + Some(Color::Rgb(h(0)?, h(2)?, h(4)?)) + } else { + None } } -/// Raw theme as read from TOML (every field a hex string). -#[derive(Debug, Clone, Deserialize)] -struct RawTheme { - bg: Hex, - bg_elevated: Hex, - bg_selected: Hex, - border: Hex, - border_focus: Hex, - fg: Hex, - fg_dim: Hex, - accent: Hex, - armed: Hex, - good: Hex, - warn: Hex, - danger: Hex, - meter_low: Hex, - meter_mid: Hex, - meter_high: Hex, +/// File schema — matches Hydra: top-level `name`/`transparent` + `[palette]`. +/// Every field optional; unspecified keys keep the default below. +#[derive(Debug, Default, Deserialize)] +struct ThemeFile { + #[allow(dead_code)] + name: Option<String>, + transparent: Option<bool>, + #[serde(default)] + palette: Palette, +} + +#[derive(Debug, Default, Deserialize)] +struct Palette { + bg: Option<String>, + bg_elevated: Option<String>, + fg: Option<String>, + fg_dim: Option<String>, + accent: Option<String>, + ghost: Option<String>, + border: Option<String>, + success: Option<String>, + warning: Option<String>, + danger: Option<String>, } -/// Resolved palette, ready for use in widgets. +/// Resolved palette for widgets. Valentine needs a few roles Hydra's 10 keys +/// don't name directly (focus border, selection bg, "armed", meter gradient); +/// those are derived from the shared keys so any Hydra theme just works. #[derive(Debug, Clone, Copy)] pub struct Theme { + /// When true, background fills use [`Color::Reset`] so the terminal shows + /// through (Ghostty vibrancy, etc.). Toggle live with `T`. + pub transparent: bool, pub bg: Color, pub bg_elevated: Color, pub bg_selected: Color, @@ -76,67 +91,79 @@ pub struct Theme { pub meter_high: Color, } -impl From<RawTheme> for Theme { - fn from(r: RawTheme) -> Self { +impl Theme { + /// The background fill for content surfaces — `Color::Reset` in transparent + /// mode (terminal shows through), else the solid `bg`. Mirrors Hydra. + pub fn surface(&self) -> Color { + if self.transparent { + Color::Reset + } else { + self.bg + } + } + + /// Build from a parsed file, filling unspecified keys from the neutral base + /// and deriving Valentine-specific roles from the shared palette. + fn from_file(file: &ThemeFile) -> Self { + // Neutral fallbacks (Hydra's default palette). + let bg = pick(&file.palette.bg, Color::Rgb(0x16, 0x18, 0x1d)); + let bg_elevated = pick(&file.palette.bg_elevated, Color::Rgb(0x22, 0x26, 0x2e)); + let fg = pick(&file.palette.fg, Color::Rgb(0xe6, 0xe8, 0xea)); + let fg_dim = pick(&file.palette.fg_dim, Color::Rgb(0x8a, 0x90, 0x99)); + let accent = pick(&file.palette.accent, Color::Rgb(0xe0, 0x9b, 0x3e)); + let ghost = pick(&file.palette.ghost, Color::Rgb(0x4c, 0xc2, 0xb0)); + let border = pick(&file.palette.border, Color::Rgb(0x3a, 0x40, 0x4a)); + let good = pick(&file.palette.success, Color::Rgb(0x6c, 0xc6, 0x6c)); + let warn = pick(&file.palette.warning, Color::Rgb(0xe0, 0x9b, 0x3e)); + let danger = pick(&file.palette.danger, Color::Rgb(0xe0, 0x6c, 0x6c)); Theme { - bg: r.bg.color(), - bg_elevated: r.bg_elevated.color(), - bg_selected: r.bg_selected.color(), - border: r.border.color(), - border_focus: r.border_focus.color(), - fg: r.fg.color(), - fg_dim: r.fg_dim.color(), - accent: r.accent.color(), - armed: r.armed.color(), - good: r.good.color(), - warn: r.warn.color(), - danger: r.danger.color(), - meter_low: r.meter_low.color(), - meter_mid: r.meter_mid.color(), - meter_high: r.meter_high.color(), + transparent: file.transparent.unwrap_or(false), + bg, + bg_elevated, + bg_selected: bg_elevated, // selection = elevated surface + border, + border_focus: ghost, // focused pane uses the highlight colour + fg, + fg_dim, + accent: ghost, // selection/cursor/titles = ghost highlight + armed: accent, // engaged/changed controls = accent + good, + warn, + danger, + meter_low: good, + meter_mid: warn, + meter_high: danger, } } -} -impl Theme { - /// Load the user override if present, else the bundled Ember default. - /// - /// Checks `~/.config/valentine/theme.toml` first (XDG-style, where the rest of - /// the user's configs live), then the platform config dir (on macOS that's - /// `~/Library/Application Support`). The first that parses wins. + /// Load the user override if present, else the bundled default. pub fn load() -> Self { for path in theme_paths() { if let Ok(text) = std::fs::read_to_string(&path) { - if let Ok(raw) = toml::from_str::<RawTheme>(&text) { - return raw.into(); + if let Some(t) = parse_theme(&text) { + return t; } } } Self::default() } - /// Resolve a theme from an optional `--theme` argument: - /// * a bundled name ("ember"/"slate"/"mono"), - /// * a stem in `~/.config/valentine/themes/<name>.toml`, - /// * a path to any `.toml` file, - /// * or, if `None`, the user override / Ember default ([`Self::load`]). - /// Returns `(theme, resolved_name)`. + /// Resolve from an optional `--theme` arg (bundled name, user-dir stem, or a + /// .toml path); `None` → user override / default. Returns `(theme, name)`. pub fn resolve(arg: Option<&str>) -> (Self, String) { match arg { None => (Self::load(), Self::active_name()), Some(a) => Self::by_name(a) .map(|t| (t, a.to_string())) .or_else(|| { - // try as a filesystem path let p = std::path::Path::new(a); - parse_theme(&std::fs::read_to_string(p).ok()?) - .map(|t| (t, stem_of(p))) + parse_theme(&std::fs::read_to_string(p).ok()?).map(|t| (t, stem_of(p))) }) - .unwrap_or_else(|| (Self::default(), "ember".into())), + .unwrap_or_else(|| (Self::default(), "default".into())), } } - /// Look up a theme by name: bundled first, then `~/.config/.../themes/`. + /// Look up a theme by name: bundled first, then `~/.config/valentine/themes/`. pub fn by_name(name: &str) -> Option<Self> { let key = name.to_lowercase(); for (n, toml) in BUILTIN_THEMES { @@ -145,26 +172,23 @@ impl Theme { } } if let Some(dir) = themes_dir() { - let path = dir.join(format!("{key}.toml")); - if let Ok(text) = std::fs::read_to_string(path) { + if let Ok(text) = std::fs::read_to_string(dir.join(format!("{key}.toml"))) { return parse_theme(&text); } } None } - /// Best-effort name of whichever theme [`Self::load`] would pick (for display). fn active_name() -> String { if theme_paths().iter().any(|p| p.exists()) { "custom".into() } else { - "ember".into() + "default".into() } } - /// Pick a meter color for a 0.0..=1.0 normalized level. Thresholds chosen so - /// that on the inputs panel's −48..0 dB window, amber starts ≈ −18 dBFS and - /// red ≈ −6 dBFS — close to a typical DAW VU. + /// Meter color for a 0.0..=1.0 level (amber ≈ −18 dBFS, red ≈ −6 on the + /// inputs panel's −48..0 window). pub fn meter_color(&self, level: f32) -> Color { if level >= 0.875 { self.meter_high @@ -176,12 +200,14 @@ impl Theme { } } -/// Parse a theme from TOML text (None on error). +fn pick(opt: &Option<String>, fallback: Color) -> Color { + opt.as_deref().and_then(parse_hex).unwrap_or(fallback) +} + fn parse_theme(text: &str) -> Option<Theme> { - toml::from_str::<RawTheme>(text).ok().map(Into::into) + toml::from_str::<ThemeFile>(text).ok().map(|f| Theme::from_file(&f)) } -/// File stem of a path as a display name. fn stem_of(p: &std::path::Path) -> String { p.file_stem().and_then(|s| s.to_str()).unwrap_or("theme").to_string() } @@ -191,8 +217,7 @@ pub fn themes_dir() -> Option<std::path::PathBuf> { dirs_next::home_dir().map(|h| h.join(".config").join("valentine").join("themes")) } -/// Every selectable theme name: bundled + any `.toml` stems in the user dir, -/// de-duplicated, bundled first then user themes alphabetically. +/// Every selectable theme name: bundled (Hydra's 10) + user-dir `.toml` stems. pub fn available_themes() -> Vec<String> { let mut names: Vec<String> = BUILTIN_THEMES.iter().map(|(n, _)| n.to_string()).collect(); if let Some(dir) = themes_dir() { @@ -212,14 +237,11 @@ pub fn available_themes() -> Vec<String> { names } -/// Candidate theme-override locations, in priority order. fn theme_paths() -> Vec<std::path::PathBuf> { let mut v = Vec::new(); - // 1. ~/.config/valentine/theme.toml (XDG-style; honoured on macOS too) if let Some(home) = dirs_next::home_dir() { v.push(home.join(".config").join("valentine").join("theme.toml")); } - // 2. platform config dir (macOS: ~/Library/Application Support) if let Some(dir) = dirs_next::config_dir() { v.push(dir.join("valentine").join("theme.toml")); } @@ -228,9 +250,7 @@ fn theme_paths() -> Vec<std::path::PathBuf> { impl Default for Theme { fn default() -> Self { - toml::from_str::<RawTheme>(DEFAULT_THEME_TOML) - .expect("bundled default.toml must parse") - .into() + parse_theme(DEFAULT_THEME_TOML).expect("bundled default.toml must parse") } } @@ -239,12 +259,29 @@ mod tests { use super::*; #[test] - fn bundled_ember_theme_parses() { + fn bundled_default_parses_hydra_format() { let t = Theme::default(); - assert_eq!(t.accent, Color::Rgb(0xff, 0x5f, 0xa2)); // rose - assert_eq!(t.armed, Color::Rgb(0xff, 0x8a, 0x5b)); // coral ember - assert_eq!(t.bg, Color::Rgb(0x17, 0x0e, 0x15)); // plum-black - assert_eq!(t.danger, Color::Rgb(0xff, 0x3b, 0x5c)); // crimson + assert_eq!(t.bg, Color::Rgb(0x16, 0x18, 0x1d)); + assert_eq!(t.accent, Color::Rgb(0x4c, 0xc2, 0xb0)); // ghost → accent + assert!(!t.transparent); + } + + #[test] + fn all_ten_bundled_themes_parse() { + for (n, _) in BUILTIN_THEMES { + assert!(Theme::by_name(n).is_some(), "theme {n} failed to parse"); + } + assert_eq!(BUILTIN_THEMES.len(), 10); + } + + #[test] + fn transparent_theme_uses_reset_surface() { + let t = Theme::by_name("transparent").unwrap(); + assert!(t.transparent); + assert_eq!(t.surface(), Color::Reset); + // a non-transparent theme paints its bg + let d = Theme::by_name("default").unwrap(); + assert_eq!(d.surface(), d.bg); } #[test]