hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

commit f6829bf02ce67a9a7de647eaff6983bcd05d4a12
parent 85a303fa45a6b1c42c91a9314ee638c2497e916d
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 23:19:52 -0500

themes: "Abyssal" default (distinct from transparent) + persist transparency on start

Three fixes:
1. Transparency now persists across launches. Bug: picking a theme saved only its
   name, so a stale `transparent` toggle in ui.toml clobbered a transparent
   theme's own setting on load — it came back opaque. save_active now records the
   theme's transparency too; load() applies it. Regression-tested.
2. Deleted the old `default` theme — it duplicated `transparent`'s slate palette.
3. New built-in default "Abyssal": Hydra's signature deep-sea palette (abyssal
   blue-green, bioluminescent seafoam highlight, coral accent) — fits the
   water-serpent. Shipped as both the built-in and an editable themes/abyssal.toml.
   by_name prefers a user abyssal.toml if present; "default" stays a back-compat
   alias. Still 10 themes.

30 tests green (added: transparent-loads-transparent, abyssal-distinct), 0 warns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcrates/hydra/src/app.rs | 4+++-
Mcrates/hydra/src/theme.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mthemes/README.md | 5+++--
Athemes/abyssal.toml | 20++++++++++++++++++++
Dthemes/default.toml | 23-----------------------
5 files changed, 86 insertions(+), 50 deletions(-)

diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -125,7 +125,9 @@ impl App { let Some((names, sel)) = self.theme_picker.as_ref() else { return }; let Some(name) = names.get(*sel).cloned() else { return }; self.theme = Theme::by_name(&name); - crate::theme::save_active(&name); + // Persist name + the theme's own transparency, so a transparent theme reloads + // transparent on next launch (not clobbered by a stale toggle). + crate::theme::save_active(&name, self.theme.transparent); self.theme_picker = None; self.status = format!("theme → {name}"); } diff --git a/crates/hydra/src/theme.rs b/crates/hydra/src/theme.rs @@ -46,21 +46,22 @@ impl Theme { } impl Default for Theme { - /// Neutral built-in palette: a calm dark slate with a teal highlight and amber accent. - /// Intentionally generic and personal-palette-free so the repo ships nothing branded. + /// "Abyssal" — Hydra's signature default: the deep, where the water-serpent lives. + /// Abyssal blue-green depths, a bioluminescent seafoam highlight, a coral accent. + /// Distinct from `transparent`'s neutral slate (which it used to duplicate). fn default() -> Self { Theme { - name: "default".to_string(), - bg: rgb(0x16, 0x18, 0x1d), // slate-900 - bg_elevated: rgb(0x22, 0x26, 0x2e), // slate-800 - fg: rgb(0xe6, 0xe8, 0xea), // near-white - fg_dim: rgb(0x8a, 0x90, 0x99), // muted grey - accent: rgb(0xe0, 0x9b, 0x3e), // amber - ghost: rgb(0x4c, 0xc2, 0xb0), // teal highlight - border: rgb(0x3a, 0x40, 0x4a), // slate border - success: rgb(0x6c, 0xc6, 0x6c), // green - warning: rgb(0xe0, 0x9b, 0x3e), // amber - danger: rgb(0xe0, 0x6c, 0x6c), // red + name: "abyssal".to_string(), + bg: rgb(0x07, 0x14, 0x16), // abyssal depth (near-black teal) + bg_elevated: rgb(0x10, 0x24, 0x28), // submerged surface + fg: rgb(0xd6, 0xe8, 0xe4), // sea-foam white + fg_dim: rgb(0x6a, 0x8c, 0x88), // drowned grey-green + accent: rgb(0xe8, 0x84, 0x5c), // coral (the lone warm spark in the deep) + ghost: rgb(0x3f, 0xd9, 0xb8), // bioluminescent seafoam (highlight) + border: rgb(0x1c, 0x38, 0x3c), // deep current + success: rgb(0x4f, 0xd4, 0x9a), // phosphor green + warning: rgb(0xe0, 0xb0, 0x50), // amber glow + danger: rgb(0xf0, 0x5c, 0x78), // coral red transparent: false, } } @@ -118,10 +119,13 @@ impl Theme { dirs::home_dir().map(|h| h.join(".config").join("hydra").join("themes")) } + /// The built-in default theme's name. Always present in the picker; has no file. + pub const BUILTIN_NAME: &'static str = "abyssal"; + /// List available theme names (file stems) from the themes folder, sorted, with the - /// built-in "default" always first. + /// built-in "abyssal" always first. pub fn available() -> Vec<String> { - let mut names = vec!["default".to_string()]; + let mut names = vec![Self::BUILTIN_NAME.to_string()]; if let Some(dir) = Self::themes_dir() { if let Ok(rd) = std::fs::read_dir(&dir) { let mut found: Vec<String> = rd @@ -132,7 +136,7 @@ impl Theme { .then(|| p.file_stem().and_then(|s| s.to_str()).map(String::from)) .flatten() }) - .filter(|n| n != "default") + .filter(|n| n != Self::BUILTIN_NAME) .collect(); found.sort(); names.extend(found); @@ -141,16 +145,19 @@ impl Theme { names } - /// Load a theme by name: "default" → built-in; otherwise `themes/<name>.toml`. + /// Load a theme by name. A matching `themes/<name>.toml` wins (so abyssal.toml is + /// editable); otherwise the built-in (for the built-in name, "default" back-compat + /// alias, or any unknown name) is used. pub fn by_name(name: &str) -> Self { - if name == "default" { - return Theme::default(); - } - Self::themes_dir() + if let Some(t) = Self::themes_dir() .map(|d| d.join(format!("{name}.toml"))) .filter(|p| p.exists()) .and_then(|p| Self::from_file(&p).ok()) - .unwrap_or_default() + { + return t; + } + // No file: built-in name / "default" alias / unknown all fall back to the built-in. + Theme::default() } /// Parse a theme file, keeping the built-in default for any unspecified key. The theme's @@ -261,10 +268,13 @@ impl UiSettings { } } -/// Persist the chosen theme name (keeps the current transparency setting). -pub fn save_active(name: &str) { +/// Persist the chosen theme name AND its transparency, so the next launch loads exactly +/// what the user is looking at. (Picking a `transparent = true` theme must come back +/// transparent — recording the name alone let a stale toggle override it.) +pub fn save_active(name: &str, transparent: bool) { let mut s = UiSettings::load(); s.theme = Some(name.to_string()); + s.transparent = Some(transparent); s.save(); } @@ -308,4 +318,30 @@ mod tests { assert_eq!(t.bg, Theme::default().bg); // untouched ⇒ default let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn transparent_theme_file_loads_transparent() { + // Regression: a `transparent = true` theme must come back transparent (the bug was a + // stale persisted toggle clobbering it; we now persist the theme's own value). + let dir = std::env::temp_dir().join(format!("hydra-theme-tr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("glass.toml"); + std::fs::write(&path, "transparent = true\n[palette]\nfg = \"#ffffff\"\n").unwrap(); + let t = Theme::from_file(&path).unwrap(); + assert!(t.transparent, "transparent=true in file must load as transparent"); + assert_eq!(t.surface(), Color::Reset, "transparent theme paints Color::Reset"); + let opaque = Theme::default(); + assert_eq!(opaque.surface(), opaque.bg, "opaque theme paints its bg"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn builtin_default_is_abyssal_and_distinct_from_transparent() { + // The default is now "abyssal" — and must NOT duplicate the transparent theme's + // palette (that duplication was why the old `default` got deleted). + let d = Theme::default(); + assert_eq!(d.name, "abyssal"); + // abyssal's bg is the deep teal, not the old shared slate #16181d. + assert_eq!(d.bg, Color::Rgb(0x07, 0x14, 0x16)); + } } diff --git a/themes/README.md b/themes/README.md @@ -8,11 +8,12 @@ no config syntax to learn beyond hex colors. ```sh mkdir -p ~/.config/hydra/themes -cp themes/default.toml ~/.config/hydra/themes/mytheme.toml +cp themes/abyssal.toml ~/.config/hydra/themes/mytheme.toml # edit the hex values, then in Hydra press `t` and pick "mytheme" ``` -The files in *this* folder (`default.toml`, `transparent.toml`) are examples — copy them +The files in *this* folder (`abyssal.toml` = the built-in default, `transparent.toml`, …) +are examples — copy them into your config folder to use/edit them. ## Format diff --git a/themes/abyssal.toml b/themes/abyssal.toml @@ -0,0 +1,20 @@ +# Hydra theme — "Abyssal", the built-in default, written out as an editable example. +# +# The deep where the water-serpent lives: abyssal blue-green depths, a bioluminescent +# seafoam highlight, a coral accent. This file mirrors Hydra's built-in default — edit a +# copy in ~/.config/hydra/themes/ to make it yours. Every key is optional. + +name = "abyssal" +transparent = false + +[palette] +bg = "#071416" # abyssal depth (near-black teal) +bg_elevated = "#102428" # submerged surface — overlays, selected rows +fg = "#d6e8e4" # sea-foam white +fg_dim = "#6a8c88" # drowned grey-green — hints, secondary text +accent = "#e8845c" # coral — the lone warm spark (selected output, marks) +ghost = "#3fd9b8" # bioluminescent seafoam — titles, cursor, active selection +border = "#1c383c" # deep current — pane borders +success = "#4fd49a" # phosphor green +warning = "#e0b050" # amber glow +danger = "#f05c78" # coral red — clip, recording, errors diff --git a/themes/default.toml b/themes/default.toml @@ -1,23 +0,0 @@ -# Hydra theme — the built-in neutral default, written out as an editable example. -# -# 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 - -[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