app.rs (24121B)
1 //! TUI application state and the actions that mutate it. Rendering lives in `ui.rs`. 2 3 use hydra_ipc::{AudioApp, AudioDevice, Command, Response, RouteSummary}; 4 5 use crate::client; 6 use crate::theme::Theme; 7 8 /// Default gain for a new route. Core Audio process taps attenuate the captured signal 9 /// (~-20 dB observed), so a fresh route at unity is far too quiet to be usable; ~10x makeup 10 /// gain restores roughly the source level. Tunable per-route with +/- in the TUI. 11 const DEFAULT_GAIN: f32 = 10.0; 12 13 /// Whether we're currently talking to the daemon. 14 #[derive(Debug, Clone)] 15 pub enum Connection { 16 Connected { protocol: u32 }, 17 Disconnected { reason: String }, 18 } 19 20 /// A modal text-entry prompt. Both flows are "type a name, ⏎ to confirm, esc to cancel"; 21 /// the kind decides what the confirmed text does. 22 #[derive(Debug, Clone, PartialEq, Eq)] 23 pub enum PromptKind { 24 /// Rename the Hydra virtual device. 25 RenameDevice, 26 /// Save the current routing as a named preset. 27 SavePreset, 28 } 29 30 /// Which pane has keyboard focus. 31 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 32 pub enum Focus { 33 Apps, 34 Routes, 35 } 36 37 pub struct App { 38 pub connection: Connection, 39 /// All audio processes from the daemon (already kind-sorted). 40 pub apps: Vec<AudioApp>, 41 /// When false (default), background daemons (kind 2) are hidden from the app list, 42 /// so you see real apps (Spotify, browsers, DAWs) not the system-helper wall. 43 pub show_all_apps: bool, 44 /// PIDs the user has marked (space) to combine into one route. 45 pub marked: std::collections::BTreeSet<i32>, 46 pub routes: Vec<RouteSummary>, 47 /// Decaying peak-hold per route id, for the meter's "peak hold" tick. Updated each 48 /// refresh: jump up to a new peak instantly, decay slowly otherwise. 49 pub peak_hold: std::collections::HashMap<String, f32>, 50 /// Output devices a route can target (output_channels > 0), e.g. speakers or "Hydra". 51 pub outputs: Vec<AudioDevice>, 52 /// Index into `outputs` of the currently chosen route target. 53 pub output_sel: usize, 54 /// Input-capable devices (mics, line-in, interfaces) — sources for hardware routing. 55 pub inputs: Vec<AudioDevice>, 56 /// When `Some`, the input-source picker is open: (input device names, selected index). 57 pub input_picker: Option<(Vec<String>, usize)>, 58 pub focus: Focus, 59 pub app_sel: usize, 60 pub route_sel: usize, 61 pub status: String, 62 /// When `Some`, a modal text prompt is open: (what it's for, in-progress text). 63 pub prompt: Option<(PromptKind, String)>, 64 /// When `Some`, the presets overlay is open: (preset names, selected index). 65 pub presets: Option<(Vec<String>, usize)>, 66 /// The active theme (swappable live via the theme picker). 67 pub theme: Theme, 68 /// When `Some`, the theme picker is open: (theme names, selected index). 69 pub theme_picker: Option<(Vec<String>, usize)>, 70 pub should_quit: bool, 71 } 72 73 impl App { 74 pub fn new() -> Self { 75 let mut app = App { 76 connection: Connection::Disconnected { reason: "connecting…".into() }, 77 apps: Vec::new(), 78 show_all_apps: false, 79 marked: std::collections::BTreeSet::new(), 80 routes: Vec::new(), 81 peak_hold: std::collections::HashMap::new(), 82 outputs: Vec::new(), 83 output_sel: 0, 84 inputs: Vec::new(), 85 input_picker: None, 86 focus: Focus::Apps, 87 app_sel: 0, 88 route_sel: 0, 89 status: String::new(), 90 prompt: None, 91 presets: None, 92 theme: Theme::load(), 93 theme_picker: None, 94 should_quit: false, 95 }; 96 app.refresh(); 97 app 98 } 99 100 // ── Theme picker (live-swappable) ────────────────────────────────────────────────── 101 102 /// Open the theme picker, listing the built-in default + every `~/.config/hydra/themes/ 103 /// *.toml`. Selection starts on the active theme. 104 pub fn open_theme_picker(&mut self) { 105 let names = Theme::available(); 106 let sel = names.iter().position(|n| *n == self.theme.name).unwrap_or(0); 107 self.theme_picker = Some((names, sel)); 108 } 109 110 pub fn theme_picker_close(&mut self) { 111 self.theme_picker = None; 112 } 113 114 pub fn theme_picker_move(&mut self, down: bool) { 115 if let Some((names, sel)) = self.theme_picker.as_mut() { 116 if names.is_empty() { 117 return; 118 } 119 *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) }; 120 } 121 } 122 123 /// Apply the highlighted theme live and persist the choice. 124 pub fn theme_picker_apply(&mut self) { 125 let Some((names, sel)) = self.theme_picker.as_ref() else { return }; 126 let Some(name) = names.get(*sel).cloned() else { return }; 127 self.theme = Theme::by_name(&name); 128 // Persist name + the theme's own transparency, so a transparent theme reloads 129 // transparent on next launch (not clobbered by a stale toggle). 130 crate::theme::save_active(&name, self.theme.transparent); 131 self.theme_picker = None; 132 self.status = format!("theme → {name}"); 133 } 134 135 /// Toggle background transparency on the live theme (and persist). 136 pub fn toggle_transparency(&mut self) { 137 self.theme.transparent = !self.theme.transparent; 138 crate::theme::save_transparency(self.theme.transparent); 139 self.status = 140 if self.theme.transparent { "transparency on" } else { "transparency off" }.into(); 141 } 142 143 pub fn is_theme_picker_open(&self) -> bool { 144 self.theme_picker.is_some() 145 } 146 147 // ── Modal text prompt (rename device / save preset) ────────────────────────────── 148 149 pub fn begin_rename(&mut self) { 150 self.prompt = Some((PromptKind::RenameDevice, String::new())); 151 } 152 153 pub fn begin_save_preset(&mut self) { 154 self.prompt = Some((PromptKind::SavePreset, String::new())); 155 } 156 157 pub fn prompt_push(&mut self, c: char) { 158 if let Some((_, buf)) = self.prompt.as_mut() { 159 buf.push(c); 160 } 161 } 162 163 pub fn prompt_backspace(&mut self) { 164 if let Some((_, buf)) = self.prompt.as_mut() { 165 buf.pop(); 166 } 167 } 168 169 pub fn prompt_cancel(&mut self) { 170 self.prompt = None; 171 self.status = "cancelled".into(); 172 } 173 174 /// Confirm the open prompt, dispatching by kind. 175 pub fn prompt_commit(&mut self) { 176 let Some((kind, text)) = self.prompt.take() else { return }; 177 let text = text.trim().to_string(); 178 if text.is_empty() { 179 self.status = "cancelled (empty)".into(); 180 return; 181 } 182 match kind { 183 PromptKind::RenameDevice => match client::request(Command::SetDeviceName { name: text.clone() }) { 184 Ok(Response::Ok) => { 185 self.status = format!("device → \"{text}\" (apply: sudo killall coreaudiod)") 186 } 187 Ok(Response::Error(e)) => self.status = e, 188 Ok(other) => self.status = format!("unexpected: {other:?}"), 189 Err(e) => self.status = format!("rename failed: {e}"), 190 }, 191 PromptKind::SavePreset => match client::request(Command::SavePreset { name: text.clone() }) { 192 Ok(Response::Ok) => self.status = format!("saved preset \"{text}\""), 193 Ok(Response::Error(e)) => self.status = e, 194 Ok(other) => self.status = format!("unexpected: {other:?}"), 195 Err(e) => self.status = format!("save failed: {e}"), 196 }, 197 } 198 } 199 200 /// Title shown above the open prompt, if any. 201 pub fn prompt_title(&self) -> Option<&'static str> { 202 self.prompt.as_ref().map(|(k, _)| match k { 203 PromptKind::RenameDevice => "rename device", 204 PromptKind::SavePreset => "save preset as", 205 }) 206 } 207 208 pub fn prompt_text(&self) -> Option<&str> { 209 self.prompt.as_ref().map(|(_, b)| b.as_str()) 210 } 211 212 pub fn is_prompting(&self) -> bool { 213 self.prompt.is_some() 214 } 215 216 // ── Presets overlay (list → apply / delete) ─────────────────────────────────────── 217 218 /// Open the presets overlay, fetching the current list from the daemon. 219 pub fn open_presets(&mut self) { 220 match client::request(Command::ListPresets) { 221 Ok(Response::Presets(names)) if !names.is_empty() => self.presets = Some((names, 0)), 222 Ok(Response::Presets(_)) => self.status = "no saved presets — press P to save the current routing".into(), 223 Ok(other) => self.status = format!("unexpected: {other:?}"), 224 Err(e) => self.status = format!("list presets failed: {e}"), 225 } 226 } 227 228 pub fn presets_close(&mut self) { 229 self.presets = None; 230 } 231 232 pub fn presets_move(&mut self, down: bool) { 233 if let Some((names, sel)) = self.presets.as_mut() { 234 if names.is_empty() { 235 return; 236 } 237 *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) }; 238 } 239 } 240 241 /// Apply the highlighted preset (replaces all live routes). 242 pub fn presets_apply(&mut self) { 243 let Some((names, sel)) = self.presets.as_ref() else { return }; 244 let Some(name) = names.get(*sel).cloned() else { return }; 245 self.presets = None; 246 match client::request(Command::ApplyPreset { name: name.clone() }) { 247 Ok(Response::PresetApplied { restored, total }) => { 248 self.status = format!("applied \"{name}\": {restored}/{total} route(s) live"); 249 } 250 Ok(Response::Error(e)) => self.status = e, 251 Ok(other) => self.status = format!("unexpected: {other:?}"), 252 Err(e) => self.status = format!("apply failed: {e}"), 253 } 254 self.refresh(); 255 } 256 257 /// Delete the highlighted preset. 258 pub fn presets_delete(&mut self) { 259 let Some((names, sel)) = self.presets.as_ref() else { return }; 260 let Some(name) = names.get(*sel).cloned() else { return }; 261 match client::request(Command::DeletePreset { name: name.clone() }) { 262 Ok(Response::Ok) => { 263 self.status = format!("deleted preset \"{name}\""); 264 self.open_presets(); // refresh the list (closes if now empty) 265 } 266 Ok(Response::Error(e)) => self.status = e, 267 Ok(other) => self.status = format!("unexpected: {other:?}"), 268 Err(e) => self.status = format!("delete failed: {e}"), 269 } 270 } 271 272 pub fn is_presets_open(&self) -> bool { 273 self.presets.is_some() 274 } 275 276 /// Pull connection status, app list, output devices, and routes from the daemon. 277 pub fn refresh(&mut self) { 278 match client::request(Command::Ping) { 279 Ok(Response::Pong { version }) => self.connection = Connection::Connected { protocol: version }, 280 Ok(other) => { 281 self.connection = Connection::Disconnected { reason: format!("unexpected: {other:?}") }; 282 return; 283 } 284 Err(e) => { 285 self.connection = Connection::Disconnected { reason: e.to_string() }; 286 self.apps.clear(); 287 self.routes.clear(); 288 return; 289 } 290 } 291 292 if let Ok(Response::Apps(apps)) = client::request(Command::ListApps) { 293 // Daemon already sorts by kind then name; keep that order. 294 self.apps = apps; 295 } 296 if let Ok(Response::Devices(devices)) = client::request(Command::ListDevices) { 297 self.set_devices(devices); 298 } 299 if let Ok(Response::State(snap)) = client::request(Command::GetState) { 300 self.routes = snap.routes; 301 self.update_peak_hold(); 302 } 303 304 self.clamp_selection(); 305 } 306 307 /// Update the per-route peak-hold: snap up to any new peak, otherwise decay toward the 308 /// current level so the held tick drifts down. Drop holds for routes that no longer exist. 309 fn update_peak_hold(&mut self) { 310 const DECAY: f32 = 0.88; // ~per-refresh; gentle fall like a hardware peak meter 311 let live: std::collections::HashSet<&str> = self.routes.iter().map(|r| r.id.as_str()).collect(); 312 self.peak_hold.retain(|id, _| live.contains(id.as_str())); 313 for r in &self.routes { 314 let held = self.peak_hold.entry(r.id.clone()).or_insert(0.0); 315 *held = if r.peak >= *held { r.peak } else { (*held * DECAY).max(r.peak) }; 316 } 317 } 318 319 /// The held peak for a route (0.0 if unknown). 320 pub fn peak_hold_for(&self, id: &str) -> f32 { 321 self.peak_hold.get(id).copied().unwrap_or(0.0) 322 } 323 324 /// Split the device list into output targets (for the `o` picker) and input sources (for 325 /// the `i` input-routing picker); preserve the current output selection across refreshes. 326 fn set_devices(&mut self, devices: Vec<AudioDevice>) { 327 let prev_uid = self.outputs.get(self.output_sel).map(|d| d.uid.clone()); 328 // Exclude our own Hydra device from inputs — routing Hydra→Hydra is a feedback loop. 329 self.inputs = 330 devices.iter().filter(|d| d.input_channels > 0 && d.uid != "Hydra_UID").cloned().collect(); 331 self.outputs = devices.into_iter().filter(|d| d.output_channels > 0).collect(); 332 self.output_sel = prev_uid 333 .and_then(|uid| self.outputs.iter().position(|d| d.uid == uid)) 334 .or_else(|| self.outputs.iter().position(|d| d.is_default_output)) 335 .unwrap_or(0); 336 } 337 338 // ── Input-source picker (route a mic / line-in to the current output) ────────────── 339 340 /// Open the input picker, listing input-capable devices. 341 pub fn open_input_picker(&mut self) { 342 if self.inputs.is_empty() { 343 self.status = "no input devices found".into(); 344 return; 345 } 346 let names: Vec<String> = self.inputs.iter().map(|d| d.name.clone()).collect(); 347 self.input_picker = Some((names, 0)); 348 } 349 350 pub fn input_picker_close(&mut self) { 351 self.input_picker = None; 352 } 353 354 pub fn input_picker_move(&mut self, down: bool) { 355 if let Some((names, sel)) = self.input_picker.as_mut() { 356 if names.is_empty() { 357 return; 358 } 359 *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) }; 360 } 361 } 362 363 /// Route the highlighted input device to the current output (StartInput). 364 pub fn input_picker_apply(&mut self) { 365 let Some((_, sel)) = self.input_picker.as_ref() else { return }; 366 let Some(dev) = self.inputs.get(*sel) else { return }; 367 let input_uid = dev.uid.clone(); 368 let src = dev.name.clone(); 369 let output_uid = self.selected_output().map(|d| d.uid.clone()); 370 let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); 371 self.input_picker = None; 372 match client::request(Command::StartInput { input_uid, output_uid, gain: DEFAULT_GAIN }) { 373 Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {src} → {dest}"), 374 Ok(Response::Error(e)) => self.status = format!("input route: {e}"), 375 Ok(other) => self.status = format!("unexpected: {other:?}"), 376 Err(e) => self.status = format!("input route failed: {e}"), 377 } 378 self.refresh(); 379 } 380 381 pub fn is_input_picker_open(&self) -> bool { 382 self.input_picker.is_some() 383 } 384 385 /// The app list as shown: real apps only (kind < 2) unless `show_all_apps` is on. 386 pub fn visible_apps(&self) -> Vec<&AudioApp> { 387 self.apps.iter().filter(|a| self.show_all_apps || a.kind < 2).collect() 388 } 389 390 /// Toggle whether background daemons/helpers appear in the app list. 391 pub fn toggle_show_all(&mut self) { 392 self.show_all_apps = !self.show_all_apps; 393 self.clamp_selection(); 394 } 395 396 fn clamp_selection(&mut self) { 397 self.app_sel = self.app_sel.min(self.visible_apps().len().saturating_sub(1)); 398 self.route_sel = self.route_sel.min(self.routes.len().saturating_sub(1)); 399 self.output_sel = self.output_sel.min(self.outputs.len().saturating_sub(1)); 400 } 401 402 /// The output device a new route will target, if any. 403 pub fn selected_output(&self) -> Option<&AudioDevice> { 404 self.outputs.get(self.output_sel) 405 } 406 407 /// Cycle the target output device (the route destination — pick "Hydra" to feed the 408 /// virtual device, or speakers to monitor). 409 pub fn cycle_output(&mut self) { 410 if !self.outputs.is_empty() { 411 self.output_sel = (self.output_sel + 1) % self.outputs.len(); 412 } 413 } 414 415 pub fn toggle_focus(&mut self) { 416 self.focus = match self.focus { 417 Focus::Apps => Focus::Routes, 418 Focus::Routes => Focus::Apps, 419 }; 420 } 421 422 pub fn move_down(&mut self) { 423 match self.focus { 424 Focus::Apps => { 425 let n = self.visible_apps().len(); 426 if n > 0 { 427 self.app_sel = (self.app_sel + 1).min(n - 1); 428 } 429 } 430 Focus::Routes if !self.routes.is_empty() => { 431 self.route_sel = (self.route_sel + 1).min(self.routes.len() - 1) 432 } 433 _ => {} 434 } 435 } 436 437 pub fn move_up(&mut self) { 438 match self.focus { 439 Focus::Apps => self.app_sel = self.app_sel.saturating_sub(1), 440 Focus::Routes => self.route_sel = self.route_sel.saturating_sub(1), 441 } 442 } 443 444 /// Start monitoring the selected app to the selected output device. 445 pub fn start_selected(&mut self) { 446 let visible = self.visible_apps(); 447 let Some(app) = visible.get(self.app_sel) else { return }; 448 let (pid, name) = (app.pid, app.name.clone()); 449 let output_uid = self.selected_output().map(|d| d.uid.clone()); 450 let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); 451 match client::request(Command::StartMonitor { pid, output_uid, gain: DEFAULT_GAIN }) { 452 Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {name} → {dest}"), 453 Ok(Response::Error(e)) => self.status = format!("start failed: {e}"), 454 Ok(other) => self.status = format!("unexpected: {other:?}"), 455 Err(e) => self.status = format!("start failed: {e}"), 456 } 457 self.refresh(); 458 } 459 460 /// Toggle whether the highlighted app is marked for combining. 461 pub fn toggle_mark(&mut self) { 462 let visible = self.visible_apps(); 463 if let Some(app) = visible.get(self.app_sel) { 464 let pid = app.pid; 465 if !self.marked.insert(pid) { 466 self.marked.remove(&pid); 467 } 468 } 469 } 470 471 /// Combine all marked apps into one route to the selected output (Loopback "combine"). 472 /// Falls back to the highlighted app if nothing is marked. 473 pub fn combine_marked(&mut self) { 474 let pids: Vec<i32> = if self.marked.is_empty() { 475 self.visible_apps().get(self.app_sel).map(|a| a.pid).into_iter().collect() 476 } else { 477 self.marked.iter().copied().collect() 478 }; 479 if pids.is_empty() { 480 return; 481 } 482 let output_uid = self.selected_output().map(|d| d.uid.clone()); 483 let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); 484 match client::request(Command::StartCombined { pids: pids.clone(), output_uid, gain: DEFAULT_GAIN }) { 485 Ok(Response::RouteStarted { id }) => { 486 self.status = format!("{id}: {} sources → {dest}", pids.len()); 487 self.marked.clear(); 488 } 489 Ok(Response::Error(e)) => self.status = format!("combine failed: {e}"), 490 Ok(other) => self.status = format!("unexpected: {other:?}"), 491 Err(e) => self.status = format!("combine failed: {e}"), 492 } 493 self.refresh(); 494 } 495 496 /// Start each marked app as its OWN route to the selected output (vs `c`, which makes 497 /// one mixed route with a shared gain). Separate routes give independent per-source 498 /// volume/mute/record/metering — the real mixer. Verified: N routes to one device sum 499 /// at the output, and muting one isolates only its source. 500 pub fn route_each_marked(&mut self) { 501 let pids: Vec<i32> = if self.marked.is_empty() { 502 self.visible_apps().get(self.app_sel).map(|a| a.pid).into_iter().collect() 503 } else { 504 self.marked.iter().copied().collect() 505 }; 506 if pids.is_empty() { 507 return; 508 } 509 let output_uid = self.selected_output().map(|d| d.uid.clone()); 510 let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); 511 let mut started = 0; 512 for pid in &pids { 513 if let Ok(Response::RouteStarted { .. }) = 514 client::request(Command::StartMonitor { pid: *pid, output_uid: output_uid.clone(), gain: DEFAULT_GAIN }) 515 { 516 started += 1; 517 } 518 } 519 self.status = format!("{started} separate route(s) → {dest} (independent volume)"); 520 self.marked.clear(); 521 self.refresh(); 522 } 523 524 pub fn stop_selected(&mut self) { 525 let Some(route) = self.routes.get(self.route_sel) else { return }; 526 let _ = client::request(Command::StopRoute { id: route.id.clone() }); 527 self.status = "route stopped".into(); 528 self.refresh(); 529 } 530 531 pub fn toggle_mute_selected(&mut self) { 532 let Some(route) = self.routes.get(self.route_sel) else { return }; 533 let _ = client::request(Command::SetMute { id: route.id.clone(), muted: !route.muted }); 534 self.refresh(); 535 } 536 537 /// Start or stop recording the selected route to a WAV file (daemon picks the path in 538 /// ~/Music/Hydra). Toggles based on the route's current recording state. 539 pub fn toggle_record_selected(&mut self) { 540 let Some(route) = self.routes.get(self.route_sel) else { return }; 541 let id = route.id.clone(); 542 let cmd = if route.recording { 543 Command::StopRecording { id } 544 } else { 545 Command::StartRecording { id, path: None } 546 }; 547 match client::request(cmd) { 548 Ok(Response::RecordingStarted { path }) => self.status = format!("● recording → {path}"), 549 Ok(Response::RecordingStopped { path, frames }) => { 550 let secs = frames as f64 / 44100.0; 551 self.status = format!("saved {path} ({secs:.1}s)"); 552 } 553 Ok(Response::Error(e)) => self.status = format!("record: {e}"), 554 Ok(other) => self.status = format!("unexpected: {other:?}"), 555 Err(e) => self.status = format!("record failed: {e}"), 556 } 557 self.refresh(); 558 } 559 560 /// Scale the selected route's gain by `factor` (multiplicative, so a few presses span 561 /// the whole range). `up=true` multiplies, `up=false` divides. Clamped to 0..=16. 562 /// Multiplicative because Core Audio process taps attenuate ~-20 dB, so useful makeup 563 /// gain is ~10x — unreachable with additive 0.05 steps. 564 pub fn adjust_gain(&mut self, up: bool) { 565 let Some(route) = self.routes.get(self.route_sel) else { return }; 566 const STEP: f32 = 1.4; // ~+3 dB per press 567 let gain = if up { 568 (route.gain.max(0.05) * STEP).clamp(0.0, 16.0) 569 } else { 570 (route.gain / STEP).clamp(0.0, 16.0) 571 }; 572 let _ = client::request(Command::SetGain { id: route.id.clone(), gain }); 573 self.refresh(); 574 } 575 576 pub fn quit(&mut self) { 577 self.should_quit = true; 578 } 579 }