commit 42d77038563379854b95c6cde9449f6e8470d6c0
parent 21f92d59929b93ecfbaba98b020465929c672148
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 16:01:11 -0500
feat: drop-folder themes + live picker + transparency; remove Navi from repo
Theming, btop-style:
- ~/.config/hydra/themes/*.toml is a dead-simple drop folder. Any .toml there
appears in the TUI theme picker ('t'), applies live, and persists.
- 'T' toggles background transparency: bg fills become Color::Reset so the
terminal's own background (Ghostty vibrancy etc.) shows through; chrome stays
opaque. Per-theme `transparent = true` also supported.
- Theme moved into App so the picker swaps it live; partial theme files merge
onto the built-in default (any subset of keys). `--theme <path>` one-shot
override still works. Active choice + transparency persist to
~/.config/hydra/ui.toml.
- Generalized the presets overlay into a shared draw_overlay (presets + themes).
Navi removed from the repo entirely (it stays its own standalone repo):
- built-in default is now a neutral slate/teal/amber palette
- repo ships only neutral examples (themes/default.toml, transparent.toml) +
themes/README; the sketchybar plugin + theme tests no longer carry Navi hexes
- verified: zero Navi hexes / mentions in tracked source
- the user's Navi is preserved LOCALLY at ~/.config/hydra/themes/navi.toml and
set active — off the repo, on their machine.
28 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 398 insertions(+), 85 deletions(-)
diff --git a/README.md b/README.md
@@ -2,8 +2,8 @@
A terminal-native, functional replacement for [Loopback](https://rogueamoeba.com/loopback/) —
create virtual audio devices, capture per-app audio, combine inputs, monitor, and map
-channels, all from a `ratatui` TUI. Navy/cyan/amber [Navi](https://github.com/ganten7/navi-palette)
-theme, swappable.
+channels, all from a `ratatui` TUI. Fully themeable (drop a `.toml` in
+`~/.config/hydra/themes/`, press `t`), with optional terminal-background transparency.
> macOS only. Per-app capture needs **macOS 14.4+** (Core Audio process taps); the virtual
> driver is a fork of [BlackHole](https://github.com/ExistentialAudio/BlackHole) (GPL-3.0).
@@ -67,7 +67,8 @@ TUI keys:
- **Routes pane** (`⇥` to switch): `↑↓` select · `m` mute · `+/-` gain · `R` record · `d` stop
- **Always:** `r` refresh · `q` quit
-Swap the theme: copy `themes/navi.toml` to `~/Library/Application Support/hydra/theme.toml`.
+Theming: drop a `.toml` in `~/.config/hydra/themes/` and pick it with `t` (toggle terminal
+transparency with `T`). See `themes/README.md`.
## License
diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs
@@ -3,6 +3,7 @@
use hydra_ipc::{AudioApp, AudioDevice, Command, Response, RouteSummary};
use crate::client;
+use crate::theme::Theme;
/// Default gain for a new route. Core Audio process taps attenuate the captured signal
/// (~-20 dB observed), so a fresh route at unity is far too quiet to be usable; ~10x makeup
@@ -58,6 +59,10 @@ pub struct App {
pub prompt: Option<(PromptKind, String)>,
/// When `Some`, the presets overlay is open: (preset names, selected index).
pub presets: Option<(Vec<String>, usize)>,
+ /// The active theme (swappable live via the theme picker).
+ pub theme: Theme,
+ /// When `Some`, the theme picker is open: (theme names, selected index).
+ pub theme_picker: Option<(Vec<String>, usize)>,
pub should_quit: bool,
}
@@ -78,12 +83,59 @@ impl App {
status: String::new(),
prompt: None,
presets: None,
+ theme: Theme::load(),
+ theme_picker: None,
should_quit: false,
};
app.refresh();
app
}
+ // ── Theme picker (live-swappable) ──────────────────────────────────────────────────
+
+ /// Open the theme picker, listing the built-in default + every `~/.config/hydra/themes/
+ /// *.toml`. Selection starts on the active theme.
+ pub fn open_theme_picker(&mut self) {
+ let names = Theme::available();
+ let sel = names.iter().position(|n| *n == self.theme.name).unwrap_or(0);
+ self.theme_picker = Some((names, sel));
+ }
+
+ pub fn theme_picker_close(&mut self) {
+ self.theme_picker = None;
+ }
+
+ pub fn theme_picker_move(&mut self, down: bool) {
+ if let Some((names, sel)) = self.theme_picker.as_mut() {
+ if names.is_empty() {
+ return;
+ }
+ *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) };
+ }
+ }
+
+ /// Apply the highlighted theme live and persist the choice.
+ pub fn theme_picker_apply(&mut self) {
+ 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);
+ self.theme_picker = None;
+ self.status = format!("theme → {name}");
+ }
+
+ /// Toggle background transparency on the live theme (and persist).
+ pub fn toggle_transparency(&mut self) {
+ self.theme.transparent = !self.theme.transparent;
+ crate::theme::save_transparency(self.theme.transparent);
+ self.status =
+ if self.theme.transparent { "transparency on" } else { "transparency off" }.into();
+ }
+
+ pub fn is_theme_picker_open(&self) -> bool {
+ self.theme_picker.is_some()
+ }
+
// ── Modal text prompt (rename device / save preset) ──────────────────────────────
pub fn begin_rename(&mut self) {
diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs
@@ -19,7 +19,6 @@ use crossterm::{
use ratatui::{backend::CrosstermBackend, Terminal};
use app::App;
-use theme::Theme;
type Tui = Terminal<CrosstermBackend<Stdout>>;
@@ -35,18 +34,18 @@ fn main() -> Result<(), Box<dyn Error>> {
}
let mut terminal = setup_terminal()?;
- let theme = Theme::load();
- let result = run(&mut terminal, theme);
+ let result = run(&mut terminal);
restore_terminal(&mut terminal)?;
result
}
-fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> {
+fn run(terminal: &mut Tui) -> Result<(), Box<dyn Error>> {
+ // The theme lives in App so the picker can swap it live; App::new loads it.
let mut app = App::new();
let mut last_refresh = Instant::now();
while !app.should_quit {
- terminal.draw(|f| ui::draw(f, &app, &theme))?;
+ terminal.draw(|f| ui::draw(f, &app, &app.theme))?;
if event::poll(Duration::from_millis(120))? {
if let Event::Key(key) = event::read()? {
@@ -56,9 +55,10 @@ fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> {
}
}
- // Don't poll the daemon while a prompt or the presets overlay is open — it would
- // overwrite the status line and fight the text the user is typing.
- if !app.is_prompting() && !app.is_presets_open() && last_refresh.elapsed() >= REFRESH {
+ // Don't poll the daemon while a modal (prompt / presets / theme picker) is open —
+ // it would overwrite the status line and fight the open overlay.
+ let modal_open = app.is_prompting() || app.is_presets_open() || app.is_theme_picker_open();
+ if !modal_open && last_refresh.elapsed() >= REFRESH {
app.refresh();
last_refresh = Instant::now();
}
@@ -90,6 +90,17 @@ fn handle_key(app: &mut App, code: KeyCode) {
}
return;
}
+ // The theme picker captures keys while open.
+ if app.is_theme_picker_open() {
+ match code {
+ KeyCode::Esc | KeyCode::Char('q') => app.theme_picker_close(),
+ KeyCode::Down | KeyCode::Char('j') => app.theme_picker_move(true),
+ KeyCode::Up | KeyCode::Char('k') => app.theme_picker_move(false),
+ KeyCode::Enter => app.theme_picker_apply(),
+ _ => {}
+ }
+ return;
+ }
match code {
KeyCode::Char('q') | KeyCode::Esc => app.quit(),
KeyCode::Char('r') => app.refresh(),
@@ -102,6 +113,8 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Char('C') => app.route_each_marked(),
KeyCode::Char('o') => app.cycle_output(),
KeyCode::Char('a') => app.toggle_show_all(),
+ KeyCode::Char('t') => app.open_theme_picker(),
+ KeyCode::Char('T') => app.toggle_transparency(),
KeyCode::Char('n') => app.begin_rename(),
KeyCode::Char('p') => app.open_presets(),
KeyCode::Char('P') => app.begin_save_preset(),
diff --git a/crates/hydra/src/theme.rs b/crates/hydra/src/theme.rs
@@ -1,6 +1,10 @@
-//! Swappable color theme. Defaults to the Navi palette but loads
-//! `~/Library/Application Support/hydra/theme.toml` (or a `--theme <path>`) if present,
-//! so the palette is configurable from day one and never hardcoded into widgets.
+//! Swappable color theme. Loads, in priority order: an explicit `--theme <path>`, then
+//! `~/Library/Application Support/hydra/theme.toml`, else a neutral built-in default.
+//! A theme file may set any subset of keys; unspecified keys keep the built-in value.
+//!
+//! The built-in default is a deliberately generic slate/teal palette — the shipped repo
+//! carries NO personal palette. Drop a `theme.toml` in the config dir (or pass `--theme`)
+//! to restyle; see `themes/default.toml` for the full set of keys.
use ratatui::style::Color;
use serde::Deserialize;
@@ -9,57 +13,155 @@ use std::path::{Path, PathBuf};
/// Resolved colors used across the UI. Semantic roles, not raw palette names.
#[derive(Debug, Clone)]
pub struct Theme {
+ /// Display name (file stem, or "default" for the built-in).
+ pub name: String,
pub bg: Color,
pub bg_elevated: Color,
pub fg: Color,
pub fg_dim: Color,
- /// Lone amber anchor — used sparingly for the primary accent.
+ /// Primary accent — used sparingly (selected output, marks).
pub accent: Color,
- /// Ghost-light cyan — titles, cursor, active highlights.
+ /// Highlight colour — titles, cursor, active selection.
pub ghost: Color,
pub border: Color,
pub success: Color,
pub warning: Color,
pub danger: Color,
+ /// When true, background fills become `Color::Reset` so the terminal's own background
+ /// (e.g. Ghostty's transparency/vibrancy) shows through. UI chrome (chips, selection,
+ /// floating overlays) stays opaque for legibility.
+ pub transparent: bool,
+}
+
+impl Theme {
+ /// The background fill colour for content surfaces. `Color::Reset` in transparent mode
+ /// lets the terminal background show through; otherwise the theme's solid `bg`.
+ pub fn surface(&self) -> Color {
+ if self.transparent {
+ Color::Reset
+ } else {
+ self.bg
+ }
+ }
}
impl Default for Theme {
- /// The Navi palette (semantic subset). See ~/Dropbox/Projects/Libs/navi-palette.
+ /// 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.
fn default() -> Self {
Theme {
- bg: rgb(0x10, 0x1e, 0x2e), // Deep Night Blue (main-bg)
- bg_elevated: rgb(0x14, 0x2c, 0x4a), // Space Cadet (region-bg)
- fg: rgb(0xe0, 0xf0, 0xff), // Alice Blue
- fg_dim: rgb(0x78, 0x98, 0xb8), // Cadet Grey
- accent: rgb(0xf0, 0x90, 0x30), // Amber (warm anchor)
- ghost: rgb(0x40, 0xe8, 0xff), // Electric Cyan (ghost light)
- border: rgb(0x26, 0x50, 0x80), // Payne's Grey
- success: rgb(0x00, 0xe8, 0x98), // Caribbean Green
- warning: rgb(0xf0, 0x90, 0x30), // Amber
- danger: rgb(0xff, 0x60, 0x60), // Bittersweet
+ 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
+ transparent: false,
}
}
}
impl Theme {
- /// Load from the standard config location, falling back to the Navi default.
+ /// Resolve the active theme on startup. Priority:
+ /// 1. `--theme <path>` / `--theme=<path>` CLI override (one-shot, not persisted)
+ /// 2. the persisted choice in `~/.config/hydra/ui.toml` (theme name + transparency),
+ /// picked live via the TUI's theme picker
+ /// 3. the legacy `~/Library/Application Support/hydra/theme.toml`
+ /// 4. the built-in neutral default
pub fn load() -> Self {
- Self::default_path()
- .filter(|p| p.exists())
- .and_then(|p| Self::from_file(&p).ok())
- .unwrap_or_default()
+ // 1. Explicit CLI override.
+ let mut args = std::env::args().skip(1);
+ while let Some(a) = args.next() {
+ if a == "--theme" {
+ if let Some(p) = args.next() {
+ match Self::from_file(Path::new(&p)) {
+ Ok(t) => return t,
+ Err(e) => eprintln!("hydra: --theme {p}: {e}; using default"),
+ }
+ }
+ } else if let Some(p) = a.strip_prefix("--theme=") {
+ if let Ok(t) = Self::from_file(Path::new(p)) {
+ return t;
+ }
+ }
+ }
+ // 2. Persisted UI settings (name + transparency).
+ let settings = UiSettings::load();
+ let mut t = match &settings.theme {
+ Some(name) => Self::by_name(name),
+ None => Self::default_path()
+ .filter(|p| p.exists())
+ .and_then(|p| Self::from_file(&p).ok())
+ .unwrap_or_default(), // 3 → 4
+ };
+ if let Some(tr) = settings.transparent {
+ t.transparent = tr; // explicit toggle wins over the theme file's own setting
+ }
+ t
}
- /// `~/Library/Application Support/hydra/theme.toml`.
+ /// `~/Library/Application Support/hydra/theme.toml` — the single active theme. (Kept for
+ /// back-compat; the themes folder below is the user-facing way to add palettes.)
pub fn default_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("hydra").join("theme.toml"))
}
- /// Parse a theme file, using Navi defaults for any unspecified key.
+ /// The dead-easy drop folder: `~/.config/hydra/themes/`. Drop any `*.toml` here (same
+ /// format as btop-style configs) and it appears in the TUI's theme picker. Created on
+ /// first run so it's discoverable.
+ pub fn themes_dir() -> Option<PathBuf> {
+ dirs::home_dir().map(|h| h.join(".config").join("hydra").join("themes"))
+ }
+
+ /// List available theme names (file stems) from the themes folder, sorted, with the
+ /// built-in "default" always first.
+ pub fn available() -> Vec<String> {
+ let mut names = vec!["default".to_string()];
+ if let Some(dir) = Self::themes_dir() {
+ if let Ok(rd) = std::fs::read_dir(&dir) {
+ let mut found: Vec<String> = rd
+ .filter_map(|e| e.ok())
+ .filter_map(|e| {
+ let p = e.path();
+ (p.extension()?.to_str()? == "toml")
+ .then(|| p.file_stem().and_then(|s| s.to_str()).map(String::from))
+ .flatten()
+ })
+ .filter(|n| n != "default")
+ .collect();
+ found.sort();
+ names.extend(found);
+ }
+ }
+ names
+ }
+
+ /// Load a theme by name: "default" → built-in; otherwise `themes/<name>.toml`.
+ pub fn by_name(name: &str) -> Self {
+ if name == "default" {
+ return Theme::default();
+ }
+ 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()
+ }
+
+ /// Parse a theme file, keeping the built-in default for any unspecified key. The theme's
+ /// `name` is taken from the file stem.
pub fn from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let raw = std::fs::read_to_string(path)?;
let file: ThemeFile = toml::from_str(&raw)?;
let mut t = Theme::default();
+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
+ t.name = stem.to_string();
+ }
let p = file.palette;
apply(&mut t.bg, p.bg);
apply(&mut t.bg_elevated, p.bg_elevated);
@@ -71,6 +173,9 @@ impl Theme {
apply(&mut t.success, p.success);
apply(&mut t.warning, p.warning);
apply(&mut t.danger, p.danger);
+ if let Some(tr) = file.transparent {
+ t.transparent = tr;
+ }
Ok(t)
}
}
@@ -79,6 +184,8 @@ impl Theme {
struct ThemeFile {
#[allow(dead_code)]
name: Option<String>,
+ /// Top-level `transparent = true` to let the terminal background show through.
+ transparent: Option<bool>,
#[serde(default)]
palette: Palette,
}
@@ -120,21 +227,85 @@ fn apply(slot: &mut Color, value: Option<String>) {
}
}
+// ── Persisted UI settings (theme choice + transparency) ─────────────────────────────────
+// Stored at ~/.config/hydra/ui.toml — alongside the themes/ folder, in the user's dotfiles
+// world (not the macOS App Support dir), so it's easy to find and version with dotfiles.
+
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
+struct UiSettings {
+ /// Active theme name ("default" or a stem in themes/).
+ theme: Option<String>,
+ /// Background transparency toggle.
+ transparent: Option<bool>,
+}
+
+impl UiSettings {
+ fn path() -> Option<PathBuf> {
+ dirs::home_dir().map(|h| h.join(".config").join("hydra").join("ui.toml"))
+ }
+ fn load() -> Self {
+ Self::path()
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .and_then(|s| toml::from_str(&s).ok())
+ .unwrap_or_default()
+ }
+ fn save(&self) {
+ if let Some(p) = Self::path() {
+ if let Some(dir) = p.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ if let Ok(s) = toml::to_string_pretty(self) {
+ let _ = std::fs::write(p, s);
+ }
+ }
+ }
+}
+
+/// Persist the chosen theme name (keeps the current transparency setting).
+pub fn save_active(name: &str) {
+ let mut s = UiSettings::load();
+ s.theme = Some(name.to_string());
+ s.save();
+}
+
+/// Persist the transparency toggle (keeps the current theme choice).
+pub fn save_transparency(on: bool) {
+ let mut s = UiSettings::load();
+ s.transparent = Some(on);
+ s.save();
+}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hex_with_and_without_hash() {
- assert_eq!(parse_hex("#40e8ff"), Some(Color::Rgb(0x40, 0xe8, 0xff)));
- assert_eq!(parse_hex("101e2e"), Some(Color::Rgb(0x10, 0x1e, 0x2e)));
+ assert_eq!(parse_hex("#1a2b3c"), Some(Color::Rgb(0x1a, 0x2b, 0x3c)));
+ assert_eq!(parse_hex("aabbcc"), Some(Color::Rgb(0xaa, 0xbb, 0xcc)));
assert_eq!(parse_hex("nope"), None);
}
#[test]
- fn default_is_navi() {
+ fn default_is_neutral_and_complete() {
+ // The built-in default must be a real palette (every role set to an RGB), and must
+ // NOT be a personal/branded palette — the shipped repo carries no such thing.
let t = Theme::default();
- assert_eq!(t.ghost, Color::Rgb(0x40, 0xe8, 0xff));
- assert_eq!(t.accent, Color::Rgb(0xf0, 0x90, 0x30));
+ for c in [t.bg, t.fg, t.accent, t.ghost, t.border, t.success, t.warning, t.danger] {
+ assert!(matches!(c, Color::Rgb(..)));
+ }
+ }
+
+ #[test]
+ fn file_overrides_only_specified_keys() {
+ // A partial theme file keeps default values for unspecified keys.
+ let dir = std::env::temp_dir().join(format!("hydra-theme-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("t.toml");
+ std::fs::write(&path, "[palette]\naccent = \"#ff0000\"\n").unwrap();
+ let t = Theme::from_file(&path).unwrap();
+ assert_eq!(t.accent, Color::Rgb(0xff, 0x00, 0x00)); // overridden
+ assert_eq!(t.bg, Theme::default().bg); // untouched ⇒ default
+ let _ = std::fs::remove_dir_all(&dir);
}
}
diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs
@@ -12,7 +12,7 @@ use crate::app::{App, Connection, Focus};
use crate::theme::Theme;
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
- f.render_widget(Block::default().style(Style::default().bg(theme.bg).fg(theme.fg)), f.area());
+ f.render_widget(Block::default().style(Style::default().bg(theme.surface()).fg(theme.fg)), f.area());
let rows = Layout::default()
.direction(Direction::Vertical)
@@ -30,14 +30,18 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
draw_footer(f, rows[2], app, theme);
- // Presets overlay floats above everything when open.
+ // Overlays float above everything when open.
if let Some((names, sel)) = &app.presets {
- draw_presets_overlay(f, names, *sel, theme);
+ draw_overlay(f, "presets", names, *sel, "⏎ apply · d delete · esc close", theme);
+ }
+ if let Some((names, sel)) = &app.theme_picker {
+ draw_overlay(f, "theme", names, *sel, "⏎ apply · T transparency · esc close", theme);
}
}
-/// A centered modal listing saved presets.
-fn draw_presets_overlay(f: &mut Frame, names: &[String], sel: usize, theme: &Theme) {
+/// A centered modal list overlay (presets, theme picker). `hint` is shown just below.
+/// Always opaque (bg_elevated) so it stays legible even in transparent mode.
+fn draw_overlay(f: &mut Frame, title: &str, names: &[String], sel: usize, hint_text: &str, theme: &Theme) {
let area = centered_rect(46, 60, f.area());
f.render_widget(Clear, area);
@@ -47,7 +51,7 @@ fn draw_presets_overlay(f: &mut Frame, names: &[String], sel: usize, theme: &The
.collect();
let block = Block::default()
- .title(Span::styled(" presets ", Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)))
+ .title(Span::styled(format!(" {title} "), Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.ghost))
.style(Style::default().bg(theme.bg_elevated));
@@ -63,18 +67,12 @@ fn draw_presets_overlay(f: &mut Frame, names: &[String], sel: usize, theme: &The
}
f.render_stateful_widget(list, area, &mut state);
- // Hint line just below the box.
if area.bottom() < f.area().bottom() {
let hint_area = Rect::new(area.x, area.bottom(), area.width, 1);
- let hint_line = Line::from(vec![
- key("⏎", theme),
- hint(" apply ", theme),
- key("d", theme),
- hint(" delete ", theme),
- key("esc", theme),
- hint(" close", theme),
- ]);
- f.render_widget(Paragraph::new(hint_line).style(Style::default().bg(theme.bg)), hint_area);
+ f.render_widget(
+ Paragraph::new(Line::from(hint(hint_text, theme))).style(Style::default().bg(theme.surface())),
+ hint_area,
+ );
}
}
@@ -105,7 +103,7 @@ fn pane_block<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
.title(Span::styled(format!(" {title} "), Style::default().fg(title_color).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
- .style(Style::default().bg(theme.bg))
+ .style(Style::default().bg(theme.surface()))
}
fn draw_header(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
@@ -132,7 +130,7 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
- .style(Style::default().bg(theme.bg));
+ .style(Style::default().bg(theme.surface()));
f.render_widget(Paragraph::new(vec![title, status]).block(block), area);
}
@@ -221,7 +219,7 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
Span::styled("▏", Style::default().fg(theme.ghost)), // cursor
Span::styled(" ⏎ confirm · esc cancel", Style::default().fg(theme.fg_dim)),
]);
- f.render_widget(Paragraph::new(line).style(Style::default().bg(theme.bg)), area);
+ f.render_widget(Paragraph::new(line).style(Style::default().bg(theme.surface())), area);
return;
}
let mut spans = match app.focus {
@@ -242,8 +240,8 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
hint(" separate ", theme),
key("o", theme),
hint(" output ", theme),
- key("a", theme),
- hint(" all ", theme),
+ key("t", theme),
+ hint(" theme ", theme),
key("⇥", theme),
hint(" routes ", theme),
],
@@ -270,7 +268,7 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
if !app.status.is_empty() {
spans.push(Span::styled(format!(" {}", app.status), Style::default().fg(theme.fg_dim)));
}
- f.render_widget(Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.bg)), area);
+ f.render_widget(Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.surface())), area);
}
fn selection_style(focused: bool, theme: &Theme) -> Style {
diff --git a/scripts/sketchybar/hydra.sh b/scripts/sketchybar/hydra.sh
@@ -4,17 +4,17 @@
# Install: see scripts/sketchybar/README.md. Reads state via `hydra query --sketchybar`
# (one serde contract with the daemon — no fragile socket parsing in shell).
#
-# Expects $CONFIG_DIR/theme.sh to export Navi-ish colors (ACCENT, GHOST, FG_DIM, DANGER).
-# Falls back to sensible Navi hex if theme.sh is absent.
+# Colors come from $CONFIG_DIR/theme.sh if present (export ACCENT/GHOST/FG_DIM/DANGER as
+# 0xAARRGGBB) — point that at your own palette. Otherwise neutral defaults below are used.
HYDRA_BIN="${HYDRA_BIN:-$HOME/Library/CloudStorage/Dropbox/Projects/Apps/hydra/target/release/hydra}"
[ -x "$HYDRA_BIN" ] || HYDRA_BIN="$HOME/Library/CloudStorage/Dropbox/Projects/Apps/hydra/target/debug/hydra"
-# Colors (0xAARRGGBB). Source the suite theme if present.
-ACCENT=0xfff09030 # amber anchor
-GHOST=0xff40e8ff # ghost-light cyan
-FG_DIM=0xff7898b8
-DANGER=0xffff6060
+# Neutral fallback colors (0xAARRGGBB), matching Hydra's built-in default theme.
+ACCENT=0xffe09b3e # amber
+GHOST=0xff4cc2b0 # teal highlight
+FG_DIM=0xff8a9099 # muted grey
+DANGER=0xffe06c6c # red
[ -f "$CONFIG_DIR/theme.sh" ] && source "$CONFIG_DIR/theme.sh" 2>/dev/null || true
# Pull state. Each line is key=value; tolerate the daemon being down.
diff --git a/themes/README.md b/themes/README.md
@@ -0,0 +1,56 @@
+# Hydra themes
+
+Hydra's colors are fully swappable, btop-style: **drop a `.toml` file in
+`~/.config/hydra/themes/` and it appears in the TUI theme picker** (press `t`). No rebuild,
+no config syntax to learn beyond hex colors.
+
+## Quick start
+
+```sh
+mkdir -p ~/.config/hydra/themes
+cp themes/default.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
+into your config folder to use/edit them.
+
+## Format
+
+Every key is optional; unspecified keys fall back to the built-in default, so a theme can be
+as small as a single line.
+
+```toml
+name = "mytheme" # optional; the picker uses the filename anyway
+transparent = false # true = let the terminal background show through
+
+[palette]
+bg = "#16181d" # main background (ignored when transparent)
+bg_elevated = "#22262e" # overlays, selected rows
+fg = "#e6e8ea" # primary text
+fg_dim = "#8a9099" # hints, secondary text
+accent = "#e09b3e" # selected output, marks
+ghost = "#4cc2b0" # titles, cursor, active selection
+border = "#3a404a" # pane borders
+success = "#6cc66c"
+warning = "#e09b3e"
+danger = "#e06c6c"
+```
+
+## Transparency (Ghostty / vibrancy)
+
+Set `transparent = true` in a theme, or toggle it live in the TUI with **`T`**. In
+transparent mode Hydra stops painting its background, so the terminal's own background
+(Ghostty's transparency/vibrancy, etc.) shows through. UI chrome — selection highlights and
+the floating overlays — stays opaque so it remains readable.
+
+## Controls
+
+- `t` — open the theme picker (live preview on apply, persisted)
+- `T` — toggle background transparency
+- `hydra --theme /path/to/file.toml` — one-shot override (not persisted)
+
+Your active choice + transparency are saved to `~/.config/hydra/ui.toml`.
+
+> Note: this repo intentionally ships only neutral example themes. Personal palettes live in
+> your own `~/.config/hydra/themes/` (and stay in their own repos).
diff --git a/themes/default.toml b/themes/default.toml
@@ -0,0 +1,23 @@
+# 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
diff --git a/themes/navi.toml b/themes/navi.toml
@@ -1,17 +0,0 @@
-# Hydra theme — Navi palette (default).
-# Copy to ~/Library/Application Support/hydra/theme.toml and edit to swap palettes.
-# Any omitted key falls back to the built-in Navi default.
-
-name = "Navi"
-
-[palette]
-bg = "#101e2e" # Deep Night Blue
-bg_elevated = "#142c4a" # Space Cadet
-fg = "#e0f0ff" # Alice Blue
-fg_dim = "#7898b8" # Cadet Grey
-accent = "#f09030" # Amber — lone warm anchor
-ghost = "#40e8ff" # Electric Cyan — ghost light
-border = "#265080" # Payne's Grey
-success = "#00e898" # Caribbean Green
-warning = "#f09030" # Amber
-danger = "#ff6060" # Bittersweet
diff --git a/themes/transparent.toml b/themes/transparent.toml
@@ -0,0 +1,16 @@
+# Hydra theme — transparent: inherits the terminal background (Ghostty vibrancy, etc.).
+# Same palette as default but with the background painted-through. Foreground colors are
+# kept slightly brighter so text stays readable over a translucent backdrop.
+
+name = "transparent"
+transparent = true
+
+[palette]
+fg = "#f0f2f4"
+fg_dim = "#9aa0a9"
+accent = "#f0a850"
+ghost = "#5cd2c0"
+border = "#4a505a"
+success = "#78d278"
+warning = "#f0a850"
+danger = "#f07878"