valentine

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

commit ea6367f204310f6e9bf3d0b960a12d7520bfac2a
parent 7fbd06301d79a498edce2c6e51a3a0370d5bebb3
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 23:29:51 -0500

fix: persist theme name + transparency across restarts (Hydra parity)

Valentine had NO theme persistence — it forgot both the theme and transparency
on every restart (worse than the Hydra bug, which at least kept the name). Now
~/.config/valentine/ui.toml stores theme name AND transparency together;
resolve() restores both on startup (CLI --theme/--transparent override + persist).
Saved on theme-picker Enter, T toggle, and explicit CLI choice. Regression
tests: transparent_theme_file_loads_transparent + ui_settings round-trip. 71 tests.

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

Diffstat:
Mvalentine/src/main.rs | 6++++++
Mvalentine/src/theme.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 87 insertions(+), 7 deletions(-)

diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -250,6 +250,7 @@ impl App { // Shift-T toggles transparency live (terminal background shows through). KeyCode::Char('T') => { self.theme.transparent = !self.theme.transparent; + theme::save_active(&self.theme_name, self.theme.transparent); self.status = Some(format!( "transparency {}", if self.theme.transparent { "on" } else { "off" } @@ -387,6 +388,7 @@ impl App { KeyCode::Enter => { let name = names[*sel].clone(); self.modal = Modal::None; + theme::save_active(&name, self.theme.transparent); self.status = Some(format!("theme: {name}")); } KeyCode::Esc => { @@ -1027,6 +1029,10 @@ fn main() -> Result<()> { if transparent_flag { app.theme.transparent = true; } + // Persist an explicit CLI choice so it sticks on the next plain launch. + if theme_arg.is_some() || transparent_flag { + theme::save_active(&app.theme_name, app.theme.transparent); + } let res = run(&mut terminal, &mut app); restore_terminal(); diff --git a/valentine/src/theme.rs b/valentine/src/theme.rs @@ -12,7 +12,7 @@ //! else the bundled default. Press `t` to switch, `T` to toggle transparency. use ratatui::style::Color; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Bundled themes `(name, toml)`. `default` is Valentine's own red-pink "Ember"; /// the other 9 are shared verbatim with Hydra (same files, drop-in compatible). @@ -148,19 +148,35 @@ impl Theme { Self::default() } - /// Resolve from an optional `--theme` arg (bundled name, user-dir stem, or a - /// .toml path); `None` → user override / default. Returns `(theme, name)`. + /// Resolve the startup theme. Priority: explicit `--theme` arg, else the + /// last persisted choice (`ui.toml`), else `~/.config/valentine/theme.toml`, + /// else the bundled default. Returns `(theme, name)`. + /// + /// The persisted transparency is applied on top of whatever the theme file + /// itself declares — this is the Hydra bug we avoid: a saved theme NAME alone + /// would lose a transparency toggle, and a stale transparency flag must not + /// override a freshly-chosen theme. We save both together, so they agree. pub fn resolve(arg: Option<&str>) -> (Self, String) { - match arg { - None => (Self::load(), Self::active_name()), - Some(a) => Self::by_name(a) + if let Some(a) = arg { + return Self::by_name(a) .map(|t| (t, a.to_string())) .or_else(|| { 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(), "default".into())), + .unwrap_or_else(|| (Self::default(), "default".into())); } + // No CLI arg: restore the last saved choice if any. + let saved = UiSettings::load(); + if let Some(name) = saved.theme.as_deref() { + if let Some(mut t) = Self::by_name(name) { + if let Some(tr) = saved.transparent { + t.transparent = tr; // saved toggle wins (recorded with the name) + } + return (t, name.to_string()); + } + } + (Self::load(), Self::active_name()) } /// Look up a theme by name: bundled first, then `~/.config/valentine/themes/`. @@ -237,6 +253,45 @@ pub fn available_themes() -> Vec<String> { names } +/// Persisted UI prefs: the chosen theme name and transparency. Stored at +/// `~/.config/valentine/ui.toml`. Theme name AND transparency are saved together +/// so a restart restores exactly what you last saw (the Hydra persistence bug). +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct UiSettings { + pub theme: Option<String>, + pub transparent: Option<bool>, +} + +impl UiSettings { + fn path() -> Option<std::path::PathBuf> { + dirs_next::home_dir().map(|h| h.join(".config").join("valentine").join("ui.toml")) + } + + pub fn load() -> Self { + Self::path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|t| toml::from_str(&t).ok()) + .unwrap_or_default() + } + + fn save(&self) { + if let Some(p) = Self::path() { + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(s) = toml::to_string_pretty(self) { + let _ = std::fs::write(p, s); + } + } + } +} + +/// Persist the active theme name + transparency (call after a theme change or +/// transparency toggle so it survives restart). +pub fn save_active(name: &str, transparent: bool) { + UiSettings { theme: Some(name.to_string()), transparent: Some(transparent) }.save(); +} + fn theme_paths() -> Vec<std::path::PathBuf> { let mut v = Vec::new(); if let Some(home) = dirs_next::home_dir() { @@ -277,6 +332,25 @@ mod tests { } #[test] + fn transparent_theme_file_loads_transparent() { + // Regression (mirrors Hydra's fix): a transparent theme must come back + // transparent — its file declares transparent=true, and by_name honors it. + let t = Theme::by_name("transparent").unwrap(); + assert!(t.transparent, "transparent theme lost its transparency flag"); + } + + #[test] + fn ui_settings_roundtrip_preserves_name_and_transparency() { + // The persisted pair must survive a TOML round-trip together (the actual + // bug was saving name but not transparency). + let s = UiSettings { theme: Some("nord".into()), transparent: Some(true) }; + let txt = toml::to_string_pretty(&s).unwrap(); + let back: UiSettings = toml::from_str(&txt).unwrap(); + assert_eq!(back.theme.as_deref(), Some("nord")); + assert_eq!(back.transparent, Some(true)); + } + + #[test] fn transparent_theme_uses_reset_surface() { let t = Theme::by_name("transparent").unwrap(); assert!(t.transparent);