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:
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]