tui: add profile picker

This commit is contained in:
atagen 2026-05-24 19:20:32 +10:00
parent 28aa099e80
commit 591cac7662

View file

@ -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<String>,
disconnected: Option<String>,
/// Open modal profile picker, if any. While `Some`, it intercepts
/// all keyboard input (the rest of the UI keeps live-updating
/// underneath).
picker: Option<ProfilePicker>,
}
/// Modal state for the profile switcher. Populated from `profile.list`
/// when the picker is opened; `selected` indexes `profiles`.
struct ProfilePicker {
profiles: Vec<headroom_ipc::ProfileInfo>,
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<B: ratatui::backend::Backend>(
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<InputMsg> {
// 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<Line> = 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<Span<'static>> {
@ -551,6 +736,8 @@ fn footer_text(state: &UiState) -> Vec<Span<'static>> {
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");