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:
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);