tui: add profile picker
This commit is contained in:
parent
28aa099e80
commit
591cac7662
1 changed files with 241 additions and 13 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue