From 591cac76621d6718716672a29e02707b3dd03550 Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 24 May 2026 19:20:32 +1000 Subject: [PATCH] tui: add profile picker --- crates/headroom-cli/src/tui.rs | 254 +++++++++++++++++++++++++++++++-- 1 file changed, 241 insertions(+), 13 deletions(-) diff --git a/crates/headroom-cli/src/tui.rs b/crates/headroom-cli/src/tui.rs index cd688b1..90a2c64 100644 --- a/crates/headroom-cli/src/tui.rs +++ b/crates/headroom-cli/src/tui.rs @@ -25,7 +25,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table}, + widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap}, Frame, Terminal, }; @@ -132,6 +132,38 @@ struct UiState { overflow_total: u64, last_error: Option, disconnected: Option, + /// Open modal profile picker, if any. While `Some`, it intercepts + /// all keyboard input (the rest of the UI keeps live-updating + /// underneath). + picker: Option, +} + +/// Modal state for the profile switcher. Populated from `profile.list` +/// when the picker is opened; `selected` indexes `profiles`. +struct ProfilePicker { + profiles: Vec, + selected: usize, +} + +impl ProfilePicker { + /// Move the highlight by `delta` (negative = up), wrapping. + fn move_sel(&mut self, delta: isize) { + if self.profiles.is_empty() { + return; + } + let n = self.profiles.len() as isize; + self.selected = (self.selected as isize + delta).rem_euclid(n) as usize; + } +} + +/// Initial highlight when opening the picker: the active profile, else +/// the one matching the daemon's reported current name, else the top. +fn initial_picker_selection(profiles: &[headroom_ipc::ProfileInfo], current: &str) -> usize { + profiles + .iter() + .position(|p| p.active) + .or_else(|| profiles.iter().position(|p| p.name == current)) + .unwrap_or(0) } impl UiState { @@ -170,6 +202,7 @@ impl UiState { overflow_total: 0, last_error: None, disconnected: None, + picker: None, } } @@ -347,8 +380,11 @@ fn draw_loop( Err(_) => return Ok(()), }, recv(input_rx) -> msg => match msg { - Ok(InputMsg::Quit) => return Ok(()), - Ok(InputMsg::Key(k)) => handle_key(&mut state, control, k), + Ok(InputMsg::Key(k)) => { + if handle_key(&mut state, control, k) { + return Ok(()); + } + } Ok(InputMsg::Redraw) => {} Err(_) => return Ok(()), }, @@ -379,11 +415,28 @@ fn poll_layer_a(state: &mut UiState, control: &mut Client) { /// Apply a keypress: navigation + global toggles + per-row actions. /// Control ops are issued synchronously on the (separate) control -/// connection; failures land in the footer. -fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { +/// connection; failures land in the footer. Returns `true` when the +/// key requests quitting the TUI. +fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) -> bool { + // Ctrl-C is a hard exit, even with the picker open. + if k.modifiers.contains(KeyModifiers::CONTROL) + && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')) + { + return true; + } + // The picker is modal: while open it swallows every other key. + if state.picker.is_some() { + handle_picker_key(state, control, k); + return false; + } + // q / Esc quit when no modal is open. + if is_quit(&k) { + return true; + } match k.code { KeyCode::Char('j') | KeyCode::Down => state.move_selection(1), KeyCode::Char('k') | KeyCode::Up => state.move_selection(-1), + KeyCode::Char('P') => open_profile_picker(state, control), KeyCode::Char('b') => { let target = !state.bypass; match control.bypass_set(target) { @@ -399,8 +452,12 @@ fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { } } KeyCode::Char('r') | KeyCode::Enter => { - let Some(node) = state.effective_selection() else { return }; - let Some(cur) = state.streams.get(&node).map(|s| s.route) else { return }; + let Some(node) = state.effective_selection() else { + return false; + }; + let Some(cur) = state.streams.get(&node).map(|s| s.route) else { + return false; + }; let to = match cur { Route::Processed => Route::Bypass, Route::Bypass => Route::Processed, @@ -415,11 +472,15 @@ fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { } } KeyCode::Char('a') => { - let Some(node) = state.effective_selection() else { return }; - let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else { return }; + let Some(node) = state.effective_selection() else { + return false; + }; + let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else { + return false; + }; if app.is_empty() { state.last_error = Some("per-app: selected stream has no app label".into()); - return; + return false; } let managed = state.la_snapshots.get(&node).is_some_and(|s| s.managed); if let Err(e) = control.per_app_set(&app, !managed) { @@ -427,13 +488,62 @@ fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { } } KeyCode::Char('x') => { - let Some(node) = state.effective_selection() else { return }; + let Some(node) = state.effective_selection() else { + return false; + }; if let Err(e) = control.layer_a_reset(node) { state.last_error = Some(format!("reset: {e}")); } } _ => {} } + false +} + +/// Open the modal profile picker, populating it from `profile.list`. +/// On failure the error lands in the footer and no modal opens. +fn open_profile_picker(state: &mut UiState, control: &mut Client) { + match control.profile_list() { + Ok(profiles) if profiles.is_empty() => { + state.last_error = Some("profile list is empty".into()); + } + Ok(profiles) => { + let selected = initial_picker_selection(&profiles, &state.profile); + state.picker = Some(ProfilePicker { profiles, selected }); + } + Err(e) => state.last_error = Some(format!("profile list: {e}")), + } +} + +/// Handle a keypress while the profile picker is open. +fn handle_picker_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { + let Some(picker) = state.picker.as_mut() else { + return; + }; + match k.code { + KeyCode::Char('j') | KeyCode::Down => picker.move_sel(1), + KeyCode::Char('k') | KeyCode::Up => picker.move_sel(-1), + KeyCode::Esc | KeyCode::Char('q') => state.picker = None, + KeyCode::Enter => { + let Some(name) = picker.profiles.get(picker.selected).map(|p| p.name.clone()) else { + state.picker = None; + return; + }; + match control.profile_use(&name) { + Ok(applied) => { + // The `profile.changed` event will also land, but set + // it eagerly so the header updates without a round-trip. + state.profile = applied; + state.picker = None; + } + Err(e) => { + // Keep the picker open so the user can retry/cancel. + state.last_error = Some(format!("profile use: {e}")); + } + } + } + _ => {} + } } // --------------------------------------------------------------------------- @@ -441,7 +551,6 @@ fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) { // --------------------------------------------------------------------------- enum InputMsg { - Quit, Key(KeyEvent), Redraw, } @@ -454,8 +563,10 @@ fn spawn_input_thread() -> Receiver { // Block on the next terminal event; crossterm's read() is // a blocking syscall against stdin. let Ok(ev) = event::read() else { return }; + // Forward all key events to the draw loop; quit/modal + // decisions need UI state, so they're made in `handle_key`, + // not here. let msg = match ev { - CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit, CtEvent::Key(k) => InputMsg::Key(k), CtEvent::Resize(_, _) => InputMsg::Redraw, _ => continue, @@ -505,6 +616,80 @@ fn draw(f: &mut Frame, state: &UiState) { draw_loudness(f, chunks[1], state); draw_streams(f, chunks[2], state); draw_layer_a_detail(f, chunks[3], state); + + // Modal overlay, drawn last so it sits on top of everything. + if let Some(picker) = &state.picker { + draw_profile_picker(f, area, picker); + } +} + +/// Centered rectangle of at most `width`×`height`, clamped to `area`. +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + Rect { + x: area.x + (area.width - w) / 2, + y: area.y + (area.height - h) / 2, + width: w, + height: h, + } +} + +/// Modal profile switcher: a centered list of profiles with the active +/// one marked (●) and the highlighted one reversed, plus a description +/// line for the highlighted profile. +fn draw_profile_picker(f: &mut Frame, area: Rect, picker: &ProfilePicker) { + let rows = picker.profiles.len() as u16; + // list rows + 2 borders + 2 description lines. + let rect = centered_rect(60, rows + 4, area); + + f.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .title(" switch profile ") + .title_bottom(Line::from(" j/k move · Enter apply · Esc cancel ").right_aligned()); + let inner = block.inner(rect); + f.render_widget(block, rect); + + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(2)]) + .split(inner); + + let lines: Vec = picker + .profiles + .iter() + .enumerate() + .map(|(i, p)| { + let marker = if p.active { "●" } else { " " }; + let style = if i == picker.selected { + Style::default().add_modifier(Modifier::REVERSED) + } else if p.active { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + Line::from(vec![ + Span::raw(format!(" {marker} ")), + Span::styled(p.name.clone(), style), + ]) + }) + .collect(); + f.render_widget(Paragraph::new(lines), parts[0]); + + let desc = picker + .profiles + .get(picker.selected) + .map(|p| p.description.clone()) + .unwrap_or_default(); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {desc}"), + Style::default().fg(Color::DarkGray), + ))) + .wrap(Wrap { trim: true }), + parts[1], + ); } fn header_status(state: &UiState) -> Vec> { @@ -551,6 +736,8 @@ fn footer_text(state: &UiState) -> Vec> { sep(), Span::raw(" p per-app "), sep(), + Span::raw(" P profile "), + sep(), Span::raw(" q quit "), ]; if state.overflow_total > 0 { @@ -1038,6 +1225,47 @@ mod tests { assert_eq!(state.overflow_total, 5); } + fn pinfo(name: &str, active: bool) -> headroom_ipc::ProfileInfo { + headroom_ipc::ProfileInfo { + name: name.into(), + active, + description: format!("{name} desc"), + } + } + + #[test] + fn picker_initial_selection_prefers_active() { + let profiles = vec![ + pinfo("default", false), + pinfo("night", true), + pinfo("speech", false), + ]; + assert_eq!(initial_picker_selection(&profiles, "default"), 1); + } + + #[test] + fn picker_initial_selection_falls_back_to_current_name_then_top() { + let profiles = vec![pinfo("default", false), pinfo("night", false)]; + // No active flag: match the daemon's reported current name. + assert_eq!(initial_picker_selection(&profiles, "night"), 1); + // Neither active nor a name match: default to the top. + assert_eq!(initial_picker_selection(&profiles, "ghost"), 0); + } + + #[test] + fn picker_move_sel_wraps_both_ways() { + let mut p = ProfilePicker { + profiles: vec![pinfo("a", false), pinfo("b", false), pinfo("c", false)], + selected: 0, + }; + p.move_sel(-1); + assert_eq!(p.selected, 2); // wrap up from top + p.move_sel(1); + assert_eq!(p.selected, 0); // wrap down from bottom + p.move_sel(2); + assert_eq!(p.selected, 2); + } + #[test] fn fmt_uptime_buckets() { assert_eq!(fmt_uptime(5), "5s");