Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -214,6 +216,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<usize> {
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`.
Expand Down Expand Up @@ -793,6 +824,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) {
Expand Down Expand Up @@ -969,6 +1017,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, the key map, tab cycling,
// and moving the selection. `?` belongs here rather than in the
Expand Down
63 changes: 53 additions & 10 deletions crates/voro/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,14 +61,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {

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()),
},
Expand All @@ -82,32 +92,65 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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
Expand Down
Loading
Loading