valentine

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

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:
Athemes/mono.toml | 16++++++++++++++++
Athemes/slate.toml | 16++++++++++++++++
Mvalentine/src/main.rs | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mvalentine/src/theme.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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();