commit d3394aa85a304fca36ade45a3e9e114bdcbc0ccb
parent 59fadb844d09c033ffa3ff387ec8817cf1e6ee86
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 15:48:54 -0500
feat: full TOML theme support — --theme CLI, bundled registry, in-app switcher
- BUILTIN_THEMES registry: ember (default), slate, mono (all TOML, compiled in).
- CLI: --theme <name|path.toml>, --list-themes, --help. Loads a bundled name, a
stem from ~/.config/valentine/themes/, or any .toml path.
- In-app theme switcher: 't' opens a picker that live-previews on ↑↓, Enter
keeps, Esc reverts. Active theme shown in the title bar.
- Theme::resolve/by_name/available_themes; user themes dir ~/.config/valentine/themes/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
4 files changed, 262 insertions(+), 9 deletions(-)
diff --git a/themes/mono.toml b/themes/mono.toml
@@ -0,0 +1,16 @@
+# Mono — near-monochrome green-on-black, terminal/CRT feel. One accent: amber.
+bg = "#0a0e0a"
+bg_elevated = "#121812"
+bg_selected = "#1e2a1e"
+border = "#2e4030"
+border_focus = "#7fe09a"
+fg = "#c8e8c8"
+fg_dim = "#6a8a6a"
+accent = "#7fe09a" # phosphor green
+armed = "#e0c060" # amber
+good = "#7fe09a"
+warn = "#e0c060"
+danger = "#e08080"
+meter_low = "#7fe09a"
+meter_mid = "#e0c060"
+meter_high = "#e08080"
diff --git a/themes/slate.toml b/themes/slate.toml
@@ -0,0 +1,16 @@
+# Slate — a cool neutral graphite theme. Calm, low-chroma, easy on the eyes.
+bg = "#14171c"
+bg_elevated = "#1c2129"
+bg_selected = "#2a323d"
+border = "#3a4452"
+border_focus = "#7fb0e0"
+fg = "#dfe6ee"
+fg_dim = "#8a97a6"
+accent = "#7fb0e0" # steel blue
+armed = "#e0b070" # warm sand (engaged/changed)
+good = "#7fc8a0" # sage
+warn = "#e0b070"
+danger = "#e07a7a" # muted red
+meter_low = "#7fc8a0"
+meter_mid = "#e0b070"
+meter_high = "#e07a7a"
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -137,10 +137,15 @@ enum Modal {
Confirm { name: String, preset: Box<scarlett_core::preset::Preset> },
/// Save-as name entry: the text typed so far.
SaveName { buf: String },
+ /// Theme switcher: available theme names + highlighted index. Applies live
+ /// as you move; Enter keeps it, Esc reverts to the one you started with.
+ ThemePicker { names: Vec<String>, sel: usize, original: String },
}
struct App {
theme: Theme,
+ /// Name of the active theme (for the title/switcher display).
+ theme_name: String,
tab: usize,
device: Result<Device, String>,
input_cursor: InputCursor,
@@ -174,9 +179,13 @@ struct App {
}
impl App {
- fn new() -> Self {
+ fn new(theme_arg: Option<&str>) -> Self {
let device = Device::connect().map_err(describe);
- Self::with_device(device)
+ let (theme, theme_name) = Theme::resolve(theme_arg);
+ let mut app = Self::with_device(device);
+ app.theme = theme;
+ app.theme_name = theme_name;
+ app
}
/// Build an App around an already-resolved device result (no USB I/O here —
@@ -184,6 +193,7 @@ impl App {
fn with_device(device: Result<Device, String>) -> Self {
App {
theme: Theme::load(),
+ theme_name: "ember".into(),
tab: 0,
device,
input_cursor: InputCursor::default(),
@@ -241,6 +251,16 @@ impl App {
self.save_to_nvram();
return;
}
+ 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 {
+ names,
+ sel,
+ original: self.theme_name.clone(),
+ };
+ return;
+ }
KeyCode::Char('?') => {
self.show_help = !self.show_help;
return;
@@ -374,10 +394,44 @@ impl App {
}
_ => {}
},
+ Modal::ThemePicker { names, sel, original } => match code {
+ KeyCode::Up | KeyCode::Char('k') => {
+ *sel = sel.saturating_sub(1);
+ let name = names[*sel].clone();
+ self.apply_theme_by_name(&name);
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ if *sel + 1 < names.len() {
+ *sel += 1;
+ }
+ let name = names[*sel].clone();
+ self.apply_theme_by_name(&name);
+ }
+ KeyCode::Enter => {
+ let name = names[*sel].clone();
+ self.modal = Modal::None;
+ self.status = Some(format!("theme: {name}"));
+ }
+ KeyCode::Esc => {
+ let orig = original.clone();
+ self.modal = Modal::None;
+ self.apply_theme_by_name(&orig);
+ self.status = Some("theme unchanged".into());
+ }
+ _ => {}
+ },
Modal::None => {}
}
}
+ /// Apply a theme by name immediately (live preview / selection).
+ fn apply_theme_by_name(&mut self, name: &str) {
+ if let Some(t) = Theme::by_name(name) {
+ self.theme = t;
+ self.theme_name = name.to_string();
+ }
+ }
+
fn routing_key(&mut self, code: KeyCode) {
let n = scarlett_core::mux::dest_pairs(&scarlett_core::mux::PORT_COUNT_18I20_GEN3).len();
match code {
@@ -1062,6 +1116,40 @@ fn restore_terminal() {
}
fn main() -> Result<()> {
+ // CLI: --theme <name|path>, --list-themes, --help.
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ let mut theme_arg: Option<String> = None;
+ let mut i = 0;
+ while i < args.len() {
+ match args[i].as_str() {
+ "--theme" | "-t" => {
+ theme_arg = args.get(i + 1).cloned();
+ i += 2;
+ }
+ s if s.starts_with("--theme=") => {
+ theme_arg = Some(s["--theme=".len()..].to_string());
+ i += 1;
+ }
+ "--list-themes" => {
+ for n in theme::available_themes() {
+ println!("{n}");
+ }
+ return Ok(());
+ }
+ "--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.",
+ theme::available_themes().join(", ")
+ );
+ return Ok(());
+ }
+ _ => i += 1,
+ }
+ }
+
// Install a panic hook that restores the terminal BEFORE the default hook
// prints — otherwise a panic inside the alternate screen is wiped on exit and
// you see nothing (the symptom of "the TUI flashed and vanished").
@@ -1077,7 +1165,7 @@ fn main() -> Result<()> {
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
- let mut app = App::new();
+ let mut app = App::new(theme_arg.as_deref());
let res = run(&mut terminal, &mut app);
restore_terminal();
@@ -1133,6 +1221,10 @@ fn ui(f: &mut Frame, app: &App) {
Style::default().fg(t.armed).add_modifier(Modifier::BOLD),
),
Span::styled(S18I20_GEN3.name, Style::default().fg(t.fg_dim)),
+ Span::styled(
+ format!(" ◈ {}", app.theme_name),
+ Style::default().fg(t.fg_dim),
+ ),
]);
f.render_widget(Paragraph::new(title), chunks[0]);
@@ -1216,6 +1308,7 @@ fn ui(f: &mut Frame, app: &App) {
Modal::LoadPicker { entries, sel } => preset_picker(f, t, entries, *sel),
Modal::Confirm { name, .. } => confirm_modal(f, t, name),
Modal::SaveName { buf } => save_name_modal(f, t, buf),
+ Modal::ThemePicker { names, sel, .. } => theme_picker(f, t, names, *sel),
Modal::None => {}
}
@@ -1301,6 +1394,42 @@ fn confirm_modal(f: &mut Frame, t: &Theme, name: &str) {
f.render_widget(Paragraph::new(lines), inner);
}
+/// The theme switcher overlay (live-applies as you move).
+fn theme_picker(f: &mut Frame, t: &Theme, names: &[String], sel: usize) {
+ let area = f.area();
+ let h = (names.len() as u16 + 4).clamp(5, area.height.saturating_sub(2));
+ let rect = modal_rect(area, 40, h);
+ f.render_widget(ratatui::widgets::Clear, rect);
+ let block = Block::default()
+ .title(Span::styled(" theme ", Style::default().fg(t.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(t.border_focus))
+ .style(Style::default().bg(t.bg_elevated));
+ let inner = block.inner(rect);
+ f.render_widget(block, rect);
+
+ let mut lines: Vec<Line> = Vec::new();
+ for (i, name) in names.iter().enumerate() {
+ let here = i == sel;
+ let style = if here {
+ Style::default().fg(t.accent).add_modifier(Modifier::BOLD).bg(t.bg_selected)
+ } else {
+ Style::default().fg(t.fg)
+ };
+ lines.push(Line::from(Span::styled(
+ format!("{} {name}", if here { "▸" } else { " " }),
+ style,
+ )));
+ }
+ lines.push(Line::from(""));
+ lines.push(Line::from(Span::styled(
+ "↑↓ preview Enter keep Esc revert",
+ Style::default().fg(t.fg_dim),
+ )));
+ f.render_widget(Paragraph::new(lines), inner);
+}
+
fn save_name_modal(f: &mut Frame, t: &Theme, buf: &str) {
let area = f.area();
let rect = modal_rect(area, 44, 5);
@@ -1373,6 +1502,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) {
head("Presets & device"),
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("W", "write current config to device NVRAM"),
key("r", "reconnect to the device"),
Line::from(""),
@@ -1440,7 +1570,7 @@ fn status_bar(app: &App) -> Paragraph<'_> {
}
spans.push(Span::styled(
- " Tab panels · S save · p presets · W →NVRAM · ? help · q quit",
+ " Tab panels · S save · p presets · 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,10 +1,11 @@
//! Theming. The palette is data, not code.
//!
-//! Valentine compiles in **Ember** (`themes/default.toml`, a tasteful red/magenta
-//! palette) as the default everyone gets, and loads an override from
-//! `~/.config/valentine/theme.toml` if present. Keeping the palette in a swappable
-//! file means a personal palette (e.g. Navi) lives only in the user's config and
-//! never has to ship in the source — clean to open-source.
+//! 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.
use ratatui::style::Color;
use serde::Deserialize;
@@ -12,6 +13,13 @@ 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.
+pub const BUILTIN_THEMES: &[(&str, &str)] = &[
+ ("ember", include_str!("../../themes/default.toml")),
+ ("slate", include_str!("../../themes/slate.toml")),
+ ("mono", include_str!("../../themes/mono.toml")),
+];
+
/// A hex color string from the theme file, e.g. "#ff5fa2".
#[derive(Debug, Clone, Deserialize)]
struct Hex(String);
@@ -107,6 +115,53 @@ impl Theme {
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)`.
+ 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)))
+ })
+ .unwrap_or_else(|| (Self::default(), "ember".into())),
+ }
+ }
+
+ /// Look up a theme by name: bundled first, then `~/.config/.../themes/`.
+ pub fn by_name(name: &str) -> Option<Self> {
+ let key = name.to_lowercase();
+ for (n, toml) in BUILTIN_THEMES {
+ if *n == key {
+ return parse_theme(toml);
+ }
+ }
+ if let Some(dir) = themes_dir() {
+ let path = dir.join(format!("{key}.toml"));
+ if let Ok(text) = std::fs::read_to_string(path) {
+ 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()
+ }
+ }
+
/// 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.
@@ -121,6 +176,42 @@ impl Theme {
}
}
+/// Parse a theme from TOML text (None on error).
+fn parse_theme(text: &str) -> Option<Theme> {
+ toml::from_str::<RawTheme>(text).ok().map(Into::into)
+}
+
+/// 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()
+}
+
+/// User theme directory: `~/.config/valentine/themes/`.
+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.
+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() {
+ if let Ok(rd) = std::fs::read_dir(dir) {
+ let mut user: Vec<String> = rd
+ .flatten()
+ .filter_map(|e| {
+ let p = e.path();
+ (p.extension()?.to_str()? == "toml").then(|| stem_of(&p))
+ })
+ .filter(|n| !names.iter().any(|b| b.eq_ignore_ascii_case(n)))
+ .collect();
+ user.sort();
+ names.extend(user);
+ }
+ }
+ names
+}
+
/// Candidate theme-override locations, in priority order.
fn theme_paths() -> Vec<std::path::PathBuf> {
let mut v = Vec::new();