From a78fdc3de27b968ea304a41265c78d77825b5164 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 1 Aug 2026 21:13:23 +0100 Subject: [PATCH] Select rows and picker options with the mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI was keyboard-only: the event loop matched only key events and mouse capture was never enabled, so clicking a task did nothing. A click is a selection move and nothing more — the same field j/k set — except inside a picker, where a second click on the option already under the cursor sends the ⏎ its key handler answers. Key handlers deliberately run without geometry, so the layout is resolved where it is already known: draw() now returns a hit-map of (rect, intent) built from the scroll offset ratatui itself computed while rendering each list, and the main loop routes left-button-down through it. A modal clears the map the screen behind it contributed, so a popup owns the pointer and clicks beside it are ignored rather than dismissing it. Mouse reporting is enabled with the terminal and disabled with it — including on the panic path and around the $EDITOR, attach and plan round-trips that tear the TUI down — so no session leaves the terminal reporting. --- crates/voro/src/app.rs | 83 ++++++ crates/voro/src/main.rs | 63 ++++- crates/voro/src/ui.rs | 551 +++++++++++++++++++++++++++++++++++++--- docs/DESIGN.md | 2 + 4 files changed, 656 insertions(+), 43 deletions(-) diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index a3fd944..b05e2a2 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -1,4 +1,6 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::ui::Hit; use voro_core::{ Action, ActionRow, AgentsConfig, DepKind, DepRef, DigestRow, Event, PrRef, Priority, Project, Queue, QueueRow, ReviewAction, ReviewMedium, RunningRow, ScoreBreakdown, StateCounts, Store, @@ -210,6 +212,35 @@ pub enum Mode { }, } +impl Mode { + /// The cursor of a pick-from-list popup, where this mode is one. The + /// text-entry forms and the detail popup have none, which is what makes them + /// ignore the mouse (DESIGN.md §9). + fn picker_sel(&self) -> Option { + match self { + Mode::PickProject { sel, .. } + | Mode::Transition { sel, .. } + | Mode::AgentPicker { sel, .. } + | Mode::DocPicker { sel, .. } + | Mode::ReviewActionPicker { sel, .. } + | Mode::DefaultPicker { sel, .. } => Some(*sel), + _ => None, + } + } + + fn picker_sel_mut(&mut self) -> Option<&mut usize> { + match self { + Mode::PickProject { sel, .. } + | Mode::Transition { sel, .. } + | Mode::AgentPicker { sel, .. } + | Mode::DocPicker { sel, .. } + | Mode::ReviewActionPicker { sel, .. } + | Mode::DefaultPicker { sel, .. } => Some(sel), + _ => None, + } + } +} + /// Which create flow the project picker feeds (DESIGN.md §8/§9): the manual /// `$EDITOR` form on `n`, or an interactive agent planning session on `N` — /// the same lowercase-default, uppercase-variant pairing as `d`/`D`. @@ -789,6 +820,23 @@ impl App { self.detail_scroll = 0; } + /// Put the selection on `index` of the current screen's list, the way a + /// click does. Out-of-range indices are ignored rather than clamped: a stale + /// hit-map naming a row the last refresh dropped should move nothing. + fn select_index(&mut self, index: usize) { + let (sel, len) = match self.screen { + Screen::Cockpit => (&mut self.cockpit_sel, self.cockpit_rows.len()), + Screen::Tasks => (&mut self.tasks_sel, self.all.len()), + Screen::Projects => (&mut self.projects_sel, self.projects.len()), + Screen::Config => (&mut self.config_sel, self.config_viewers.len()), + }; + if index >= len { + return; + } + *sel = index; + self.detail_scroll = 0; + } + /// Scroll the cockpit focus card, clamped to the overflow `draw_detail` /// last measured so `K` past the top or `J` past the bottom simply stops. fn scroll_detail(&mut self, delta: i64) { @@ -962,6 +1010,41 @@ impl App { } } + /// Route a left click at `(col, row)` through the hit-map the last draw + /// built (DESIGN.md §9). A click is a selection move and nothing more — it + /// never fires the row's action — except inside a picker, where a click on + /// the option already under the cursor confirms it as ⏎ would. Clicks + /// anywhere the map does not cover do nothing. + pub fn on_mouse(&mut self, col: u16, row: u16, hits: &crate::ui::HitMap) { + self.status = None; + let Some(hit) = hits.at(col, row) else { + return; + }; + match hit { + Hit::CockpitRow(i) if self.screen == Screen::Cockpit => self.select_index(i), + Hit::TaskRow(i) if self.screen == Screen::Tasks => self.select_index(i), + Hit::ProjectRow(i) if self.screen == Screen::Projects => self.select_index(i), + Hit::ViewerRow(i) if self.screen == Screen::Config => self.select_index(i), + Hit::PickerOption(i) => self.click_picker_option(i), + _ => {} + } + } + + /// A click on a picker option: move the cursor there, or — if it is already + /// there — hand the picker the Enter its key handler answers, so clicking + /// confirms through exactly the path the keyboard takes. + fn click_picker_option(&mut self, index: usize) { + match self.mode.picker_sel() { + Some(sel) if sel == index => self.on_key(KeyEvent::from(KeyCode::Enter)), + Some(_) => { + if let Some(sel) = self.mode.picker_sel_mut() { + *sel = index; + } + } + None => {} + } + } + fn key_normal(&mut self, key: KeyEvent) { // Navigation shared by every screen: quit, tab cycling, and moving the // selection. diff --git a/crates/voro/src/main.rs b/crates/voro/src/main.rs index e0e0059..220618f 100644 --- a/crates/voro/src/main.rs +++ b/crates/voro/src/main.rs @@ -13,7 +13,11 @@ mod worktree; use std::path::PathBuf; use std::time::Duration; -use ratatui::crossterm::event::{self, Event, KeyEventKind}; +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, +}; +use ratatui::crossterm::execute; use app::{App, EditorRequest}; use voro_core::Store; @@ -57,14 +61,20 @@ fn main() -> Result<(), Box> { let mut app = App::new(store, ctx)?; - let mut terminal = ratatui::init(); + let mut terminal = init_terminal(); + // The click targets of the frame on screen, rebuilt by every draw, since + // the key handlers deliberately run without geometry (DESIGN.md §9). + let mut hits = ui::HitMap::default(); let result = loop { - if let Err(e) = terminal.draw(|frame| ui::draw(frame, &app)) { + if let Err(e) = terminal.draw(|frame| hits = ui::draw(frame, &app)) { break Err(e.into()); } match event::poll(Duration::from_millis(500)) { Ok(true) => match event::read() { Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => app.on_key(key), + Ok(Event::Mouse(m)) if m.kind == MouseEventKind::Down(MouseButton::Left) => { + app.on_mouse(m.column, m.row, &hits) + } Ok(_) => {} Err(e) => break Err(e.into()), }, @@ -82,32 +92,65 @@ fn main() -> Result<(), Box> { if let Some(request) = app.pending_editor.take() { // $EDITOR owns the terminal for the duration; tear the TUI down // around it rather than fighting over raw mode. - ratatui::restore(); + restore_terminal(); editor_session(&mut app, request); - terminal = ratatui::init(); + terminal = init_terminal(); } if let Some(request) = app.pending_attach.take() { // attach/resume are full-screen interactive sessions that own the // terminal until the user detaches, same treatment as $EDITOR. - ratatui::restore(); + restore_terminal(); foreground_session(&mut app, "attach", &request.command, &request.cwd); - terminal = ratatui::init(); + terminal = init_terminal(); } if let Some(launch) = app.pending_plan.take() { // a planning session is interactive in the same way; the refresh // on return is what makes the task it proposed appear in the queue. - ratatui::restore(); + restore_terminal(); foreground_session(&mut app, launch.label, &launch.command, &launch.cwd); - terminal = ratatui::init(); + terminal = init_terminal(); } if app.should_quit { break Ok(()); } }; - ratatui::restore(); + restore_terminal(); result } +/// Take the terminal for the TUI: ratatui's own setup plus mouse reporting, +/// which is what turns a click into an event the loop can route (DESIGN.md §9). +/// Every teardown must undo both, so this is paired with [`restore_terminal`] at +/// each of the round-trips that hand the terminal to another program. +fn init_terminal() -> DefaultTerminal { + let terminal = ratatui::init(); + install_mouse_panic_hook(); + let _ = execute!(std::io::stdout(), EnableMouseCapture); + terminal +} + +/// Hand the terminal back, mouse reporting first. A terminal left reporting +/// spews escape bytes on every mouse move, so this runs even where the caller +/// only means to borrow the screen for an $EDITOR or attach round-trip. +fn restore_terminal() { + let _ = execute!(std::io::stdout(), DisableMouseCapture); + ratatui::restore(); +} + +/// Disable mouse reporting on the way out of a panic too. `ratatui::init` +/// installs a hook that restores the screen but knows nothing of the capture +/// this TUI adds, so wrap it once with one that does. +fn install_mouse_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(std::io::stdout(), DisableMouseCapture); + hook(info); + })); + }); +} + /// Run an agent's attach/resume command — or a planning session (DESIGN.md §8) /// — in the foreground, the terminal already restored by the caller. The /// command inherits stdio so the agent's own TUI takes over; on return the diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 5b5af3d..ff384a8 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1,5 +1,5 @@ use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::{Constraint, Layout, Margin, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; @@ -13,6 +13,62 @@ use crate::app::{App, CockpitRow, Mode, ReviewActionOption, Screen, TaskRow}; const SELECTED: Style = Style::new().add_modifier(Modifier::REVERSED); +/// What a click at some point of the last-drawn frame means (DESIGN.md §9). +/// Each variant carries the index the click selects, counted in the same space +/// the key handlers count in — so routing a click is setting the field `j`/`k` +/// would set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hit { + /// A cockpit row, queue strip and running strip alike: an index into + /// `App::cockpit_rows`, the one space `cockpit_sel` counts in. + CockpitRow(usize), + TaskRow(usize), + ProjectRow(usize), + ViewerRow(usize), + /// An option of whichever modal picker is open. + PickerOption(usize), +} + +/// The click targets of one drawn frame. Built by [`draw`] and read by +/// `App::on_mouse`, which is what keeps the key handlers free of geometry: the +/// layout is resolved where it is already known, at draw time. Anything no rect +/// covers — the detail pane, the header, the status line, empty space — is a +/// dead zone where a click does nothing. +#[derive(Debug, Default)] +pub struct HitMap(Vec<(Rect, Hit)>); + +impl HitMap { + /// The rows of a bordered list, mapped through `hit`. `offset` is the scroll + /// ratatui computed while rendering, so a scrolled list still maps a visible + /// line to the item on it; lines past `count` items — the trailing read-only + /// note some lists carry — get no target. Rows are one line tall throughout. + fn push_list(&mut self, area: Rect, offset: usize, count: usize, hit: impl Fn(usize) -> Hit) { + let inner = area.inner(Margin::new(1, 1)); + for line in 0..inner.height { + let item = offset + line as usize; + if item >= count { + break; + } + self.0.push(( + Rect::new(inner.x, inner.y + line, inner.width, 1), + hit(item), + )); + } + } + + /// Drop every target, for when a modal takes the pointer. + fn clear(&mut self) { + self.0.clear(); + } + + pub fn at(&self, col: u16, row: u16) -> Option { + self.0 + .iter() + .find(|(rect, _)| rect.contains((col, row).into())) + .map(|(_, hit)| *hit) + } +} + /// The canonical rendering of a task identifier, right-aligned for list columns. fn task_ref(id: i64) -> String { format!("{:>4}", format!("#{id}")) @@ -31,17 +87,24 @@ fn question_summary(question: &str) -> String { } } -pub fn draw(frame: &mut Frame, app: &App) { +pub fn draw(frame: &mut Frame, app: &App) -> HitMap { + let mut hits = HitMap::default(); match app.screen { - Screen::Cockpit => draw_cockpit(frame, app), - Screen::Tasks => draw_tasks(frame, app), - Screen::Projects => draw_projects(frame, app), - Screen::Config => draw_config(frame, app), - } - draw_mode(frame, app); + Screen::Cockpit => draw_cockpit(frame, app, &mut hits), + Screen::Tasks => draw_tasks(frame, app, &mut hits), + Screen::Projects => draw_projects(frame, app, &mut hits), + Screen::Config => draw_config(frame, app, &mut hits), + } + // A modal owns the pointer: the screen behind it keeps drawing but stops + // being clickable, so only the popup's own options are targets. + if !matches!(app.mode, Mode::Normal) { + hits.clear(); + } + draw_mode(frame, app, &mut hits); + hits } -fn draw_mode(frame: &mut Frame, app: &App) { +fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) { match &app.mode { Mode::Normal => {} Mode::AddProject { @@ -79,6 +142,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { .iter() .map(|p| ListItem::new(p.name.clone())) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; let area = popup_area(frame, 44, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); @@ -90,6 +154,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } Mode::Transition { task_id, @@ -100,6 +165,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { .iter() .map(|a| ListItem::new(crate::app::action_label(a))) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; let area = popup_area(frame, 48, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); @@ -111,6 +177,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } Mode::Prompt { kind, buffer, .. } => { // The buffer is usually one line, but a RejectWork prompt can be @@ -255,6 +322,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { } }) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; let area = popup_area(frame, 44, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); @@ -264,6 +332,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ))) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } Mode::DocPicker { task_id, docs, sel, .. @@ -272,6 +341,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { .iter() .map(|doc| ListItem::new(doc_picker_row(app, *task_id, doc))) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; // Resolved locations are absolute paths, so this picker takes what // the terminal will give rather than the fixed width the short-row @@ -285,6 +355,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ))) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } Mode::ReviewActionPicker { options, @@ -305,6 +376,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ))), }) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; let area = popup_area(frame, 52, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); @@ -316,6 +388,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } Mode::ViewerForm { name, @@ -377,6 +450,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { } }) .collect(); + let count = items.len(); let height = items.len() as u16 + 2; let area = popup_area(frame, 44, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); @@ -392,6 +466,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), count, Hit::PickerOption); } } } @@ -469,7 +544,7 @@ fn history_lines(events: &[Event]) -> Vec> { lines } -fn draw_cockpit(frame: &mut Frame, app: &App) { +fn draw_cockpit(frame: &mut Frame, app: &App, hits: &mut HitMap) { let queue_rows = app .cockpit_rows .iter() @@ -493,9 +568,9 @@ fn draw_cockpit(frame: &mut Frame, app: &App) { .areas(frame.area()); draw_header(frame, app, header); - draw_queue(frame, app, queue); + draw_queue(frame, app, queue, hits); draw_detail(frame, app, detail); - draw_running(frame, app, running); + draw_running(frame, app, running, hits); draw_status(frame, app, status); } @@ -865,9 +940,12 @@ fn pluralise(n: usize, noun: &str) -> String { } } -fn draw_queue(frame: &mut Frame, app: &App, area: Rect) { +fn draw_queue(frame: &mut Frame, app: &App, area: Rect, hits: &mut HitMap) { let mut items: Vec = Vec::new(); let mut selected: Option = None; + // The pane skips the running rows, so a rendered line stands for the + // cockpit row this records, not for its own position. + let mut rows: Vec = Vec::new(); for (i, row) in app.cockpit_rows.iter().enumerate() { let item = match row { CockpitRow::Queue(idx) => match app.queue.rows.get(*idx) { @@ -884,6 +962,7 @@ fn draw_queue(frame: &mut Frame, app: &App, area: Rect) { if i == app.cockpit_sel { selected = Some(items.len()); } + rows.push(i); items.push(item); } let empty = items.is_empty(); @@ -905,6 +984,9 @@ fn draw_queue(frame: &mut Frame, app: &App, area: Rect) { } let list = List::new(items).block(block).highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), rows.len(), |i| { + Hit::CockpitRow(rows[i]) + }); if empty { let inner = area.inner(ratatui::layout::Margin::new(1, 1)); frame.render_widget(Paragraph::new("nothing to do — press n").dim(), inner); @@ -1104,18 +1186,20 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { /// Live sessions (DESIGN.md §9): agent, task state, and elapsed time since /// dispatch. `draw_cockpit` collapses this to a zero-height area when nothing /// is running. -fn draw_running(frame: &mut Frame, app: &App, area: Rect) { +fn draw_running(frame: &mut Frame, app: &App, area: Rect, hits: &mut HitMap) { if area.height == 0 { return; } let mut items: Vec = Vec::new(); let mut selected: Option = None; + let mut rows: Vec = Vec::new(); for (i, row) in app.cockpit_rows.iter().enumerate() { if let CockpitRow::Running(idx) = row { let r = &app.running[*idx]; if i == app.cockpit_sel { selected = Some(items.len()); } + rows.push(i); let agent = match &r.agent { Some(agent) => Span::styled(format!("{agent:8} "), Style::new().fg(Color::Magenta)), None => Span::styled(format!("{:8} ", "—"), Style::new().dim()), @@ -1144,6 +1228,9 @@ fn draw_running(frame: &mut Frame, app: &App, area: Rect) { .block(Block::default().borders(Borders::ALL).title("Running")) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); + hits.push_list(area, state.offset(), rows.len(), |i| { + Hit::CockpitRow(rows[i]) + }); } /// Seconds since a session's `started_at` as a compact clock — `12s`, @@ -1160,7 +1247,7 @@ fn format_elapsed(secs: i64) -> String { } } -fn draw_tasks(frame: &mut Frame, app: &App) { +fn draw_tasks(frame: &mut Frame, app: &App, hits: &mut HitMap) { let [list_area, status] = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(frame.area()); @@ -1214,6 +1301,7 @@ fn draw_tasks(frame: &mut Frame, app: &App) { .block(Block::default().borders(Borders::ALL).title("All tasks")) .highlight_style(SELECTED); frame.render_stateful_widget(list, list_area, &mut state); + hits.push_list(list_area, state.offset(), app.all.len(), Hit::TaskRow); draw_status(frame, app, status); } @@ -1271,7 +1359,7 @@ fn blocker_spans(row: &TaskRow) -> Vec> { /// open count is the project's non-terminal tasks, from the loaded task list. /// An archived project stays on this screen, dim and tagged, so it can be /// found and unarchived (§5). -fn draw_projects(frame: &mut Frame, app: &App) { +fn draw_projects(frame: &mut Frame, app: &App, hits: &mut HitMap) { let [list_area, status] = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(frame.area()); @@ -1319,6 +1407,12 @@ fn draw_projects(frame: &mut Frame, app: &App) { .block(Block::default().borders(Borders::ALL).title("Projects")) .highlight_style(SELECTED); frame.render_stateful_widget(list, list_area, &mut state); + hits.push_list( + list_area, + state.offset(), + app.projects.len(), + Hit::ProjectRow, + ); if empty { let inner = list_area.inner(ratatui::layout::Margin::new(1, 1)); frame.render_widget( @@ -1333,7 +1427,7 @@ fn draw_projects(frame: &mut Frame, app: &App) { /// (read-only) with provenance and the default marked, over the editable named /// viewers, with the legacy anonymous `[viewer]` shown read-only beneath them. A /// file that failed to parse is surfaced here rather than rendering empty. -fn draw_config(frame: &mut Frame, app: &App) { +fn draw_config(frame: &mut Frame, app: &App, hits: &mut HitMap) { let [main, status] = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(frame.area()); @@ -1452,6 +1546,9 @@ fn draw_config(frame: &mut Frame, app: &App) { ) .highlight_style(SELECTED); frame.render_stateful_widget(viewers, viewers_area, &mut state); + // Same rule as the selection: the anonymous note below the named viewers is + // not selectable, so it is not clickable either. + hits.push_list(viewers_area, state.offset(), named, Hit::ViewerRow); if empty { let inner = viewers_area.inner(ratatui::layout::Margin::new(1, 1)); frame.render_widget( @@ -1630,7 +1727,11 @@ mod tests { assert_eq!(app.screen, Screen::Config); let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -1759,7 +1860,11 @@ mod tests { app.toggle_screen(); let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); - terminal.draw(|f| draw_tasks(f, &app)).unwrap(); + terminal + .draw(|f| { + draw_tasks(f, &app, &mut HitMap::default()); + }) + .unwrap(); let rendered = terminal .backend() @@ -1807,7 +1912,11 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); let render = |app: &App, terminal: &mut Terminal| -> String { - terminal.draw(|f| draw(f, app)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); terminal .backend() .buffer() @@ -1872,7 +1981,11 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); let render = |app: &App, terminal: &mut Terminal| -> String { - terminal.draw(|f| draw(f, app)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); terminal .backend() .buffer() @@ -1948,7 +2061,11 @@ mod tests { assert_eq!(app.screen, Screen::Projects); let mut terminal = Terminal::new(TestBackend::new(80, 8)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2008,7 +2125,11 @@ mod tests { app.on_key(KeyEvent::from(KeyCode::Char('h'))); let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2119,7 +2240,11 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); let render = |app: &App, terminal: &mut Terminal| -> String { - terminal.draw(|f| draw(f, app)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); terminal .backend() .buffer() @@ -2225,7 +2350,11 @@ mod tests { )); let app = App::new(store, ctx).unwrap(); let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2301,7 +2430,11 @@ mod tests { assert_eq!(app.queue_task_ids(), vec![asking.id]); let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2357,7 +2490,11 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(40, 16)).unwrap(); let render = |app: &App, terminal: &mut Terminal| -> String { - terminal.draw(|f| draw(f, app)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); terminal .backend() .buffer() @@ -2440,7 +2577,11 @@ mod tests { assert!(matches!(app.mode, Mode::Detail { .. })); let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2499,7 +2640,11 @@ mod tests { }; let render = |app: &App| { let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); - terminal.draw(|f| draw(f, app)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); terminal .backend() .buffer() @@ -2581,7 +2726,11 @@ mod tests { )); let app = App::new(store, ctx).unwrap(); let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2639,7 +2788,11 @@ mod tests { let app = App::new(store, ctx).unwrap(); let width: u16 = 100; let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); // Reassemble the buffer into rows so each question line is checked on // its own terminal row — a single-span rendering would collapse them. let rows: Vec = terminal @@ -2775,7 +2928,11 @@ mod tests { let app = App::new(store, ctx).unwrap(); let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let header = terminal.backend().buffer().content()[..80] .iter() .map(|c| c.symbol()) @@ -2828,7 +2985,11 @@ mod tests { }; let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let rendered = terminal .backend() .buffer() @@ -2841,4 +3002,328 @@ mod tests { "modal should name the browser jump: {rendered}" ); } + + // --- mouse (DESIGN.md §9) --- + // + // Every click test goes through the real draw, so what it clicks is the + // geometry the operator sees: the row is found by the text on it rather + // than by a coordinate the test picked. + + type TestTerminal = ratatui::Terminal; + + /// Draw a frame and keep the hit-map it built — the pair the event loop + /// holds between a draw and the next click. + fn frame(app: &crate::app::App, terminal: &mut TestTerminal) -> HitMap { + let mut hits = HitMap::default(); + terminal.draw(|f| hits = draw(f, app)).unwrap(); + hits + } + + /// One drawn row, cell by cell — the column of a cell is its index, which + /// is what a click is addressed in. + fn cells_at(terminal: &TestTerminal, y: u16) -> Vec { + let buf = terminal.backend().buffer(); + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + } + + fn line_at(terminal: &TestTerminal, y: u16) -> String { + cells_at(terminal, y).concat() + } + + /// Where on screen some text was drawn, as the point a click on it would + /// land — so a test aims at what the operator sees rather than at a + /// coordinate it worked out for itself. + fn point_of(terminal: &TestTerminal, needle: &str) -> (u16, u16) { + let height = terminal.backend().buffer().area.height; + for y in 0..height { + let cells = cells_at(terminal, y); + for x in 0..cells.len() { + if cells[x..].concat().starts_with(needle) { + return (x as u16, y); + } + } + } + panic!("'{needle}' is not on screen"); + } + + fn row_of(terminal: &TestTerminal, needle: &str) -> u16 { + point_of(terminal, needle).1 + } + + fn ready_task(store: &mut voro_core::Store, project_id: i64, title: &str) -> voro_core::Task { + store + .create_task(voro_core::NewTask { + project_id, + repo_id: None, + title: title.into(), + body: String::new(), + priority: Priority::P2, + state: TaskState::Ready, + agent: None, + human: false, + deep: false, + }) + .unwrap() + } + + fn test_app(store: voro_core::Store) -> crate::app::App { + let ctx = crate::dispatch::DispatchCtx::without_config(std::path::Path::new( + "/nonexistent/voro.db", + )); + crate::app::App::new(store, ctx).unwrap() + } + + /// A click on a cockpit row selects it — in the queue and in the running + /// strip alike, which are one selection despite being two panes — and the + /// detail pane follows, since it reads the same selection the keys move. + #[test] + fn cockpit_click_selects_the_row_under_the_pointer() { + use voro_core::Store; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + let first = ready_task(&mut store, p.id, "first"); + let second = ready_task(&mut store, p.id, "second"); + let third = ready_task(&mut store, p.id, "third"); + let live = ready_task(&mut store, p.id, "in flight"); + store + .record_dispatch(live.id, "claude", None, None) + .unwrap(); + + let mut app = test_app(store); + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let hits = frame(&app, &mut terminal); + assert_eq!(app.selected_task_id(), Some(first.id)); + + // A queue row two below the selection: the click moves the selection + // there and nowhere else — no transition menu, no dispatch. + let (x, y) = point_of(&terminal, &task_ref(third.id)); + app.on_mouse(x, y, &hits); + assert_eq!(app.selected_task_id(), Some(third.id)); + assert!(matches!(app.mode, Mode::Normal)); + + let hits = frame(&app, &mut terminal); + let (x, y) = point_of(&terminal, &task_ref(second.id)); + app.on_mouse(x, y, &hits); + assert_eq!(app.selected_task_id(), Some(second.id)); + + // The running strip is a separate pane over the same row space. + let hits = frame(&app, &mut terminal); + let (x, y) = point_of(&terminal, &task_ref(live.id)); + app.on_mouse(x, y, &hits); + assert_eq!(app.selected_task_id(), Some(live.id)); + assert!(matches!( + app.cockpit_rows[app.cockpit_sel], + CockpitRow::Running(_) + )); + } + + /// The panes that show rather than list — the detail card, the header, the + /// status line — are dead zones, so a click in one changes nothing. + #[test] + fn clicks_outside_a_list_do_nothing() { + use voro_core::Store; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + let first = ready_task(&mut store, p.id, "first"); + ready_task(&mut store, p.id, "second"); + + let mut app = test_app(store); + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let hits = frame(&app, &mut terminal); + + // The header, the detail pane's own border and body, the status line. + let detail = row_of(&terminal, "Detail"); + for y in [0, detail, detail + 2, 23] { + app.on_mouse(5, y, &hits); + assert_eq!( + app.selected_task_id(), + Some(first.id), + "a click at row {y} should not move the selection" + ); + } + assert!(matches!(app.mode, Mode::Normal)); + } + + /// A full-screen list scrolled past its first page still maps the line + /// under the pointer to the item drawn on it, because the hit-map is built + /// from the scroll offset ratatui itself computed while rendering. + #[test] + fn tasks_browser_click_selects_the_right_row_when_scrolled() { + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::Store; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + for i in 0..40 { + ready_task(&mut store, p.id, &format!("task {i}")); + } + + let mut app = test_app(store); + app.on_key(KeyEvent::from(KeyCode::Char('2'))); + assert_eq!(app.screen, Screen::Tasks); + // Walk the selection off the bottom of the pane so the list scrolls. + for _ in 0..35 { + app.on_key(KeyEvent::from(KeyCode::Char('j'))); + } + + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let hits = frame(&app, &mut terminal); + // The first row on screen is no longer the first task, so a hit-map + // that ignored the offset would be off by exactly that much. + assert!( + !line_at(&terminal, 1).contains(&task_ref(app.all[0].task.id)), + "the list should have scrolled: {}", + line_at(&terminal, 1) + ); + + let expected = app.all[30].task.id; + let (x, y) = point_of(&terminal, &task_ref(expected)); + app.on_mouse(x, y, &hits); + assert_eq!(app.all[app.tasks_sel].task.id, expected); + assert!(matches!(app.mode, Mode::Normal), "a click opens no popup"); + } + + /// The other two screens list the same way and click the same way. + #[test] + fn projects_and_config_clicks_select_rows() { + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::Store; + + let dir = std::env::temp_dir().join(format!( + "voro-ui-mouse-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let agents_path = dir.join("voro.toml"); + std::fs::write( + &agents_path, + "[viewers.zed]\ncmd = \"zed {path}\"\n\n[viewers.diff]\ncmd = \"git diff {base}\"\n", + ) + .unwrap(); + + let mut store = Store::open_in_memory().unwrap(); + store.create_project("alpha", "/tmp/alpha").unwrap(); + store.create_project("beta", "/tmp/beta").unwrap(); + store.create_project("gamma", "/tmp/gamma").unwrap(); + + let ctx = crate::dispatch::DispatchCtx { + db_path: dir.join("voro.db"), + agents_path, + runtime_dir: dir.join("sessions"), + ref_capture_timeout: std::time::Duration::ZERO, + }; + let mut app = crate::app::App::new(store, ctx).unwrap(); + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + + app.on_key(KeyEvent::from(KeyCode::Char('3'))); + let hits = frame(&app, &mut terminal); + let (x, y) = point_of(&terminal, "gamma"); + app.on_mouse(x, y, &hits); + assert_eq!(app.projects[app.projects_sel].name, "gamma"); + + // The projects screen reads the digit keys as weights, so tab across + // rather than jumping with `4`. + app.on_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(app.screen, Screen::Config); + let hits = frame(&app, &mut terminal); + let target = app.config_viewers[1].name.clone(); + let (x, y) = point_of(&terminal, &target); + app.on_mouse(x, y, &hits); + assert_eq!(app.config_viewers[app.config_sel].name, target); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// In a picker a click moves the cursor, and a second click on the option + /// already under it confirms — the same ⏎ the keyboard would send, so the + /// transition runs through the state machine unchanged. + #[test] + fn picker_click_selects_then_a_second_click_confirms() { + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::Store; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + let task = ready_task(&mut store, p.id, "startable"); + + let mut app = test_app(store); + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + app.on_key(KeyEvent::from(KeyCode::Enter)); + // ready → [start, park, abandon]; park is the one below the cursor. + assert!(matches!(app.mode, Mode::Transition { sel: 0, .. })); + + let hits = frame(&app, &mut terminal); + let (x, y) = point_of(&terminal, "park → parked"); + app.on_mouse(x, y, &hits); + assert!( + matches!(app.mode, Mode::Transition { sel: 1, .. }), + "the first click should only move the cursor" + ); + assert_eq!(app.store.task(task.id).unwrap().state, TaskState::Ready); + + let hits = frame(&app, &mut terminal); + let (x, y) = point_of(&terminal, "park → parked"); + app.on_mouse(x, y, &hits); + assert!(matches!(app.mode, Mode::Normal), "the picker should close"); + assert_eq!(app.store.task(task.id).unwrap().state, TaskState::Parked); + } + + /// A popup owns the pointer: a click beside it is not a dismiss, and the + /// list it covers is not clickable through it. Text-entry modes have no + /// options to click, so they ignore the mouse entirely. + #[test] + fn clicks_outside_a_popup_and_in_text_entry_modes_do_nothing() { + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::Store; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + let first = ready_task(&mut store, p.id, "first"); + let second = ready_task(&mut store, p.id, "second"); + + let mut app = test_app(store); + let mut terminal = TestTerminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + + // The row the popup does not cover would have been a target a moment ago. + let hits = frame(&app, &mut terminal); + let (x_second, y_second) = point_of(&terminal, &task_ref(second.id)); + app.on_key(KeyEvent::from(KeyCode::Enter)); + assert!(matches!(app.mode, Mode::Transition { .. })); + + let hits_modal = frame(&app, &mut terminal); + assert!(hits.at(x_second, y_second).is_some()); + for (col, row) in [(0, 0), (x_second, y_second), (99, 23)] { + app.on_mouse(col, row, &hits_modal); + } + assert!( + matches!(app.mode, Mode::Transition { sel: 0, .. }), + "clicks outside the popup should neither dismiss nor move it" + ); + assert_eq!(app.selected_task_id(), Some(first.id)); + + // A text prompt: nothing in the frame is a click target at all. + app.on_key(KeyEvent::from(KeyCode::Esc)); + app.mode = Mode::Prompt { + task_id: first.id, + kind: crate::app::PromptKind::Ask, + buffer: "half typed".into(), + }; + let hits = frame(&app, &mut terminal); + for y in 0..24 { + for x in [0, 5, 50, 99] { + assert!( + hits.at(x, y).is_none(), + "a text prompt should offer no click target at ({x}, {y})" + ); + } + } + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 32d0c07..7a81b2e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -315,6 +315,8 @@ Beyond the cockpit, the TUI cycles (Tab, or `1`–`4`) through three further ful The first milestone deliberately restricts scope to three lists and a handful of keybindings — the risk of TUI-first is polishing panes before the workflow is validated, and the mitigation is scope, not sequence. Core interactions, roughly in order of implementation: create/edit a task in `$EDITOR` (title, body, priority, deps, agent override via frontmatter or a form) — or plan one interactively with an agent (§8's planning sessions, on the sibling key); edit project weights on a dedicated projects screen — one row per project, weight set by a single keystroke (*this must be fast — it happens every morning*); resume a queued question once it is answered in the agent's session; dispatch a ready task (default agent) and dispatch-via-picker; accept/reject a review item; triage `proposed` tasks from the queue; redispatch a stalled task; a score-decomposition view folded inline into any task's detail (toggled with `x`, not a popup). +The mouse is a secondary input over the same keys: a left click on any list row or picker option moves the selection there, exactly as `j`/`k` would, and a second click on an option already under the cursor picks it, as ⏎ would — a click never fires a row's action, and mouse reporting stays on for the whole session, which trades away the terminal's own text selection (shift-drag still bypasses it). + Every action ultimately gets a CLI equivalent so the whole tool is scriptable and agent-legible, but the human-facing CLI trails the TUI rather than preceding it. ## 10. Delivery plan