diff --git a/README.md b/README.md index ef8c9fc..f36521a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ dimmed. The keys also work without opening the menu. | Where | Keys | |---|---| | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `q` quit | -| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$` | +| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$`; `i` move the cursor with those keys first, then `v` to select from there | | toolbar | `a` looks good · `c` comment · `d` delete · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; click a bubble | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 384bbf2..93b5391 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -13,7 +13,7 @@ cargo build --release | Where | Keys | |---|---| | anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit | -| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations | | selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears | | rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index a56778c..9a44667 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -198,8 +198,11 @@ impl App { } } - // Keyboard cursor, visible while selecting with the keyboard. - if doc_focused && self.selection.is_some_and(|s| s.dragging) && row_index == self.cursor.0 { + // Keyboard cursor, visible while roaming or selecting with the keyboard. + if doc_focused + && (self.roam || self.selection.is_some_and(|s| s.dragging)) + && row_index == self.cursor.0 + { let x = doc.x + (self.cursor.1.min(usize::from(doc.width).saturating_sub(1))) as u16; buf.set_style(Rect { x, y: screen_y, width: 1, height: 1 }, Style::new().bg(CURSOR_BG)); } @@ -416,7 +419,8 @@ impl App { _ if self.pending.is_some() => "a looks good · c comment · d delete · esc clear ", Focus::Tree => "j/k · enter open · E send · t hide · q quit ", Focus::Rail => "j/k · e edit · x remove · tab · q quit ", - Focus::Document => "drag or v select · c comment · E send · tab · q quit ", + Focus::Document if self.roam => "hjkl move · v select · c comment · esc blocks · q quit ", + Focus::Document => "i move · v select · c comment · E send · tab · q quit ", }; // The status must stay readable at any width, so the key help yields columns to it // (and is clipped) rather than the other way round. diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 869b228..80f5370 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -169,6 +169,18 @@ impl App { self.visual_key(key); return Ok(()); } + // Roaming (`i`): the cursor moves with nothing selected yet, so `v` can start + // anywhere. Other keys fall through to their block-mode meaning. + if self.roam && self.selection.is_none() { + if key.code == KeyCode::Esc { + self.roam = false; + self.status = None; + return Ok(()); + } + if self.motion_key(key) { + return Ok(()); + } + } match (key.code, key.modifiers) { (KeyCode::Esc, _) => { if self.pending.is_some() || self.selection.is_some() { @@ -182,6 +194,11 @@ impl App { self.selection = Some(Selection::start(self.cursor)); self.status = Some("visual: move to extend, enter to select, esc to cancel".into()); } + (KeyCode::Char('i'), _) => { + self.clear_selection(); + self.roam = true; + self.status = Some("move: hjkl w b 0 $ · v select · esc back to blocks".into()); + } (KeyCode::Char('j') | KeyCode::Down, _) => self.select_block(self.selected + 1), (KeyCode::Char('k') | KeyCode::Up, _) => self.select_block(self.selected.saturating_sub(1)), (KeyCode::Char('h') | KeyCode::Left, _) => self.move_cursor(0, -1), @@ -219,6 +236,18 @@ impl App { match key.code { KeyCode::Esc => self.clear_selection(), KeyCode::Enter | KeyCode::Char('v') => self.finish_selection(), + _ => { + self.motion_key(key); + } + } + if let Some(sel) = self.selection.as_mut() { + sel.set_head(self.cursor); + } + } + + /// The cursor motions shared by visual and roaming modes. True when `key` was one. + fn motion_key(&mut self, key: KeyEvent) -> bool { + match key.code { KeyCode::Char('h') | KeyCode::Left => self.move_cursor(0, -1), KeyCode::Char('l') | KeyCode::Right => self.move_cursor(0, 1), KeyCode::Char('j') | KeyCode::Down => self.move_cursor(1, 0), @@ -230,12 +259,10 @@ impl App { self.cursor.1 = self.open.layout.row(self.cursor.0).map_or(0, |r| r.cells.len().saturating_sub(1)); } - _ => {} - } - if let Some(sel) = self.selection.as_mut() { - sel.set_head(self.cursor); + _ => return false, } self.ensure_cursor_visible(); + true } /// Move the keyboard cursor by rows/columns, skipping gap rows and clamping to text. diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index c1c6925..b222686 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -124,6 +124,7 @@ impl Open { use self::compose::Compose; +#[allow(clippy::struct_excessive_bools, reason = "independent toggles, none of them a state machine")] pub(crate) struct App { open: Open, /// Where annotations are stored and how this folder is named there. @@ -153,6 +154,8 @@ pub(crate) struct App { pending: Option, /// Keyboard cursor for visual selection, in document (row, col). cursor: (usize, usize), + /// `i`: the cursor moves by row without selecting, so `v` can start mid-block. + roam: bool, /// Index into the rail's placed annotations. rail_cursor: usize, mode: Mode, @@ -229,6 +232,7 @@ impl App { selection: None, pending: None, cursor: (0, 0), + roam: false, rail_cursor: 0, mode: Mode::Browse, candidates: Vec::new(), @@ -408,6 +412,7 @@ impl App { return; } self.clear_selection(); + self.roam = false; self.selected = block.min(self.open.doc.blocks.len() - 1); if let Some(rendered) = self.open.layout.blocks.get(self.selected) { self.cursor = (rendered.first_row, 0); diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index cab8f54..5633879 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -437,3 +437,48 @@ fn pasting_into_the_comment_box_keeps_newlines() { let placed = app.open.store.placed(); assert_eq!(placed.last().expect("annotation").annotation.body, "pasted one\npasted two"); } + +#[test] +fn roaming_moves_by_row_so_a_selection_can_start_mid_block() { + // A paragraph with a hard break (two rows), then a list: in block mode `j` from the top lands on "- one", + // roaming lands on "second line" of the same block. + let source = DocumentSource::new( + "first line\\\nsecond line\n\n- one\n- two\n".to_owned(), + "doc.md", + true, + Provenance::Stdin, + ); + let mut app = App::open(source, 60, Box::new(Discard)).expect("app opens"); + app.data_dir = scratch_data_dir(); + draw(&mut app); + let j = key(KeyCode::Char('j'), KeyModifiers::NONE); + app.handle_event(&j).expect("block j"); + assert_eq!((app.selected, app.cursor.0), (1, 3), "block mode: j skips to the list"); + app.handle_event(&key(KeyCode::Char('g'), KeyModifiers::NONE)).expect("g"); + + app.handle_event(&key(KeyCode::Char('i'), KeyModifiers::NONE)).expect("i"); + app.handle_event(&j).expect("roam j"); + assert_eq!((app.selected, app.cursor.0), (0, 1), "roaming: j moves one row, same block"); + app.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)).expect("v"); + app.handle_event(&key(KeyCode::Char('$'), KeyModifiers::NONE)).expect("$"); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + let pending = app.pending.as_ref().expect("selection finished"); + assert_eq!(app.open.doc.source.get(pending.range.clone()), Some("second line")); + + // Esc clears the selection, a second Esc leaves roaming, and j jumps blocks again. + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("clear"); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("leave roam"); + app.handle_event(&j).expect("block j"); + assert_eq!(app.selected, 1); +} + +#[test] +fn a_block_key_ends_roaming() { + let mut app = app(Box::new(Discard)); + draw(&mut app); + app.handle_event(&key(KeyCode::Char('i'), KeyModifiers::NONE)).expect("i"); + assert!(app.roam); + app.handle_event(&key(KeyCode::Char('G'), KeyModifiers::NONE)).expect("G"); + assert!(!app.roam, "jumping to a block puts the cursor back on its first row"); + assert_eq!(app.cursor, (app.open.layout.blocks[app.selected].first_row, 0)); +} diff --git a/docs/decisions.md b/docs/decisions.md index 3aedaea..9667cc7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -363,3 +363,16 @@ looked up in whichever table holds it. Verified against the `beta` source (`packages/core/src/session/sql.ts`, `packages/schema/src/session-message.ts`, `packages/util/src/global-roots.ts`) and a mixed-schema fixture reproducing the report. + +## 15. The cursor can roam before selecting (2026-09-18) + +Keyboard selection always anchored at the selected block's first row, because block mode +had no row movement: commenting on the seventh bullet of a list meant the mouse. `i` now +enters roaming: the visual-mode motions move the cursor with nothing selected, `v` anchors +there, `Esc` (or any block key) returns to block mode. + +Two alternatives were rejected. A vim-style `o` that swaps anchor and head inside visual +mode still forces the selection to start at the top and be shrunk from below. Splitting a +top-level list into one block per item was built and dropped: it made `j`/`k` + `c` reach a +bullet directly, but changed rendering (a block gap between every bullet) and the block +model for everyone, when roaming already reaches any line.