hydra

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

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:
MREADME.md | 7++++---
Mcrates/hydra/src/app.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/hydra/src/main.rs | 29+++++++++++++++++++++--------
Mcrates/hydra/src/theme.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/hydra/src/ui.rs | 42++++++++++++++++++++----------------------
Mscripts/sketchybar/hydra.sh | 14+++++++-------
Athemes/README.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Athemes/default.toml | 23+++++++++++++++++++++++
Dthemes/navi.toml | 17-----------------
Athemes/transparent.toml | 16++++++++++++++++
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"