From 5306ded045e7f296cc2170908ffeabf79d341012 Mon Sep 17 00:00:00 2001 From: lawrencegripper Date: Fri, 18 Sep 2026 12:39:24 +0100 Subject: [PATCH 1/5] feat(doc): each top-level list item is its own block A list was one block, so j/k stepped over a whole findings list and c commented on all of it. Items open a block each; nested content stays with its item. --- crates/plannotator-tui/src/doc.rs | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/plannotator-tui/src/doc.rs b/crates/plannotator-tui/src/doc.rs index 2d631cc..62570ed 100644 --- a/crates/plannotator-tui/src/doc.rs +++ b/crates/plannotator-tui/src/doc.rs @@ -79,7 +79,7 @@ fn kind_of(tag: &Tag<'_>) -> BlockKind { match tag { Tag::Heading { .. } => BlockKind::Heading, Tag::Paragraph => BlockKind::Paragraph, - Tag::List(_) => BlockKind::List, + Tag::List(_) | Tag::Item => BlockKind::List, Tag::CodeBlock(_) => BlockKind::CodeBlock, Tag::BlockQuote(_) => BlockKind::BlockQuote, Tag::Table(_) => BlockKind::Table, @@ -93,21 +93,27 @@ fn kind_of(tag: &Tag<'_>) -> BlockKind { fn split_blocks(source: &str) -> Vec { let mut blocks = Vec::new(); let mut depth = 0usize; - let mut open: Option<(usize, BlockKind)> = None; + // The block being read: where it starts, what it is, and the depth its End lands on. + let mut open: Option<(usize, BlockKind, usize)> = None; for (event, range) in Parser::new_ext(source, parse_options()).into_offset_iter() { match event { Event::Start(tag) => { - if depth == 0 { - open = Some((range.start, kind_of(&tag))); + // A top-level list is not a block; each of its items is, so a bullet can be + // selected on its own. Whatever an item contains stays with that item. + let opens = match depth { + 0 => !matches!(tag, Tag::List(_)), + 1 => matches!(tag, Tag::Item), + _ => false, + }; + if opens { + open = Some((range.start, kind_of(&tag), depth)); } depth += 1; } Event::End(_) => { depth = depth.saturating_sub(1); - if depth == 0 - && let Some((start, kind)) = open.take() - { + if let Some((start, kind, _)) = open.take_if(|(_, _, closes_at)| *closes_at == depth) { blocks.push(Block { range: start..range.end, kind }); } } @@ -144,19 +150,30 @@ mod tests { BlockKind::Heading, BlockKind::Paragraph, BlockKind::List, + BlockKind::List, BlockKind::CodeBlock, BlockKind::Rule ] ); assert_eq!(doc.block_text(0), "# Title"); assert_eq!(doc.block_text(1), "Para one\nstill one."); - assert_eq!(doc.block_text(3), "```rs\nfn x() {}\n```"); + assert_eq!(doc.block_text(2), "- a"); + assert_eq!(doc.block_text(3), "- b"); + assert_eq!(doc.block_text(4), "```rs\nfn x() {}\n```"); } #[test] - fn front_matter_is_dropped_and_nested_lists_stay_one_block() { + fn front_matter_is_dropped_and_a_nested_list_stays_with_its_item() { let doc = Document::parse("---\ntitle: X\n---\n\n- a\n - nested\n- b\n".to_owned()); - assert_eq!(doc.blocks.len(), 1); - assert_eq!(doc.blocks.first().map(|b| b.kind), Some(BlockKind::List)); + let texts: Vec<_> = (0..doc.blocks.len()).map(|i| doc.block_text(i)).collect(); + assert_eq!(texts, ["- a\n - nested", "- b"]); + assert!(doc.blocks.iter().all(|b| b.kind == BlockKind::List)); + } + + #[test] + fn list_items_are_blocks_whether_tight_loose_or_numbered() { + let doc = Document::parse("- a\n\n- b\n\n1. one\n2. two\n\npara\n".to_owned()); + let texts: Vec<_> = (0..doc.blocks.len()).map(|i| doc.block_text(i)).collect(); + assert_eq!(texts, ["- a", "- b", "1. one", "2. two", "para"]); } } From cbf62f6e1ab6443489533a103eb7d0f4258e9d70 Mon Sep 17 00:00:00 2001 From: lawrencegripper Date: Fri, 18 Sep 2026 12:39:24 +0100 Subject: [PATCH 2/5] feat(input): o roams the cursor so v can start a selection mid-block Block mode had no row movement, so a keyboard selection always anchored at the block's first row. o enters roaming: the visual motions move the cursor with nothing selected, v anchors there, Esc or a block key returns. Motions are shared with visual mode. --- README.md | 2 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/app/draw.rs | 10 ++++--- crates/plannotator-tui/src/app/input.rs | 35 ++++++++++++++++++++++--- crates/plannotator-tui/src/app/mod.rs | 5 ++++ crates/plannotator-tui/src/app/tests.rs | 26 ++++++++++++++++++ docs/decisions.md | 14 ++++++++++ 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ef8c9fc..3c4db54 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 (each list item is a block); `c` comment on the block; `x` clear its annotations; `o` move the cursor with `hjkl` `w` `b` `0` `$`, then `v` select from there; `v` select with the same keys | | 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..9fd857a 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 · `o` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block (each list item is one) · `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..2a81a3e 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 => "o 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..3323e21 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 (`o`): 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('o'), _) => { + 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..cbed640 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), + /// `o`: 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..37bead2 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -437,3 +437,29 @@ 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_lets_a_selection_start_on_a_later_list_item() { + let source = + DocumentSource::new("- one\n- two\n- three\n".to_owned(), "list.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); + assert_eq!(app.open.doc.blocks.len(), 3, "each list item is its own block"); + // `o` frees the cursor; `j` now moves a row (onto the second item) instead of a block + // from its first row, and `v` anchors the selection there. + app.handle_event(&key(KeyCode::Char('o'), KeyModifiers::NONE)).expect("o"); + app.handle_event(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); + assert_eq!(app.selected, 1); + 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"); + let quoted = app.open.doc.source.get(pending.range.clone()).expect("range in source"); + assert!(quoted.contains("two") && !quoted.contains("one"), "quoted {quoted:?}"); + // Back in block mode, `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(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); + assert_eq!(app.selected, 2); +} diff --git a/docs/decisions.md b/docs/decisions.md index 3aedaea..2ddeee0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -363,3 +363,17 @@ 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. List items are blocks, and the cursor can roam before selecting (2026-09-18) + +A top-level list used to be one block, so `j`/`k` skipped a seventeen-item findings list in +one step and `c` commented on all of it. `split_blocks` now opens a block per `Tag::Item` +at depth one instead of one for the `Tag::List` that holds them; nested lists and paragraphs +inside an item stay with that item. Items therefore render with the usual one-row block gap +between them. + +Keyboard selection always anchored at the selected block's first row, because block mode +had no row movement. `o` 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. The +alternative, a vim-style `o` that swaps anchor and head inside visual mode, was rejected: +it still forces the selection to start at the top and be shrunk from below. From 49e31ad789741f1d21c5ba54159eb294fc5d6cd8 Mon Sep 17 00:00:00 2001 From: lawrencegripper Date: Fri, 18 Sep 2026 12:45:12 +0100 Subject: [PATCH 3/5] revert(doc): keep a top-level list as one block Roaming reaches any line of a list, so splitting it into item blocks bought little and changed rendering (a block gap between bullets) and the block model for everyone. Decision 15 records why. --- README.md | 2 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/doc.rs | 39 +++++++++---------------------- docs/decisions.md | 21 ++++++++--------- 4 files changed, 23 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3c4db54..270abd8 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 (each list item is a block); `c` comment on the block; `x` clear its annotations; `o` move the cursor with `hjkl` `w` `b` `0` `$`, then `v` select from there; `v` select with the same keys | +| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$`; `o` 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 9fd857a..b0100e3 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 · `o` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block (each list item is one) · `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 · `o` 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/doc.rs b/crates/plannotator-tui/src/doc.rs index 62570ed..2d631cc 100644 --- a/crates/plannotator-tui/src/doc.rs +++ b/crates/plannotator-tui/src/doc.rs @@ -79,7 +79,7 @@ fn kind_of(tag: &Tag<'_>) -> BlockKind { match tag { Tag::Heading { .. } => BlockKind::Heading, Tag::Paragraph => BlockKind::Paragraph, - Tag::List(_) | Tag::Item => BlockKind::List, + Tag::List(_) => BlockKind::List, Tag::CodeBlock(_) => BlockKind::CodeBlock, Tag::BlockQuote(_) => BlockKind::BlockQuote, Tag::Table(_) => BlockKind::Table, @@ -93,27 +93,21 @@ fn kind_of(tag: &Tag<'_>) -> BlockKind { fn split_blocks(source: &str) -> Vec { let mut blocks = Vec::new(); let mut depth = 0usize; - // The block being read: where it starts, what it is, and the depth its End lands on. - let mut open: Option<(usize, BlockKind, usize)> = None; + let mut open: Option<(usize, BlockKind)> = None; for (event, range) in Parser::new_ext(source, parse_options()).into_offset_iter() { match event { Event::Start(tag) => { - // A top-level list is not a block; each of its items is, so a bullet can be - // selected on its own. Whatever an item contains stays with that item. - let opens = match depth { - 0 => !matches!(tag, Tag::List(_)), - 1 => matches!(tag, Tag::Item), - _ => false, - }; - if opens { - open = Some((range.start, kind_of(&tag), depth)); + if depth == 0 { + open = Some((range.start, kind_of(&tag))); } depth += 1; } Event::End(_) => { depth = depth.saturating_sub(1); - if let Some((start, kind, _)) = open.take_if(|(_, _, closes_at)| *closes_at == depth) { + if depth == 0 + && let Some((start, kind)) = open.take() + { blocks.push(Block { range: start..range.end, kind }); } } @@ -150,30 +144,19 @@ mod tests { BlockKind::Heading, BlockKind::Paragraph, BlockKind::List, - BlockKind::List, BlockKind::CodeBlock, BlockKind::Rule ] ); assert_eq!(doc.block_text(0), "# Title"); assert_eq!(doc.block_text(1), "Para one\nstill one."); - assert_eq!(doc.block_text(2), "- a"); - assert_eq!(doc.block_text(3), "- b"); - assert_eq!(doc.block_text(4), "```rs\nfn x() {}\n```"); + assert_eq!(doc.block_text(3), "```rs\nfn x() {}\n```"); } #[test] - fn front_matter_is_dropped_and_a_nested_list_stays_with_its_item() { + fn front_matter_is_dropped_and_nested_lists_stay_one_block() { let doc = Document::parse("---\ntitle: X\n---\n\n- a\n - nested\n- b\n".to_owned()); - let texts: Vec<_> = (0..doc.blocks.len()).map(|i| doc.block_text(i)).collect(); - assert_eq!(texts, ["- a\n - nested", "- b"]); - assert!(doc.blocks.iter().all(|b| b.kind == BlockKind::List)); - } - - #[test] - fn list_items_are_blocks_whether_tight_loose_or_numbered() { - let doc = Document::parse("- a\n\n- b\n\n1. one\n2. two\n\npara\n".to_owned()); - let texts: Vec<_> = (0..doc.blocks.len()).map(|i| doc.block_text(i)).collect(); - assert_eq!(texts, ["- a", "- b", "1. one", "2. two", "para"]); + assert_eq!(doc.blocks.len(), 1); + assert_eq!(doc.blocks.first().map(|b| b.kind), Some(BlockKind::List)); } } diff --git a/docs/decisions.md b/docs/decisions.md index 2ddeee0..a26b101 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -364,16 +364,15 @@ looked up in whichever table holds it. Verified against the `beta` source `packages/util/src/global-roots.ts`) and a mixed-schema fixture reproducing the report. -## 15. List items are blocks, and the cursor can roam before selecting (2026-09-18) - -A top-level list used to be one block, so `j`/`k` skipped a seventeen-item findings list in -one step and `c` commented on all of it. `split_blocks` now opens a block per `Tag::Item` -at depth one instead of one for the `Tag::List` that holds them; nested lists and paragraphs -inside an item stay with that item. Items therefore render with the usual one-row block gap -between them. +## 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. `o` 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. The -alternative, a vim-style `o` that swaps anchor and head inside visual mode, was rejected: -it still forces the selection to start at the top and be shrunk from below. +had no row movement: commenting on the seventh bullet of a list meant the mouse. `o` 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. From 4eadffcb106b8e32217c252a55f584fcda63bdfe Mon Sep 17 00:00:00 2001 From: lawrencegripper Date: Fri, 18 Sep 2026 12:45:12 +0100 Subject: [PATCH 4/5] test(app): roaming moves by row inside a block, a block key ends it The first version used three one-line items, so j moving a row and j moving a block were indistinguishable. --- crates/plannotator-tui/src/app/tests.rs | 45 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 37bead2..8fe6b3d 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -439,27 +439,46 @@ fn pasting_into_the_comment_box_keeps_newlines() { } #[test] -fn roaming_lets_a_selection_start_on_a_later_list_item() { - let source = - DocumentSource::new("- one\n- two\n- three\n".to_owned(), "list.md", true, Provenance::Stdin); +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); - assert_eq!(app.open.doc.blocks.len(), 3, "each list item is its own block"); - // `o` frees the cursor; `j` now moves a row (onto the second item) instead of a block - // from its first row, and `v` anchors the selection there. + 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('o'), KeyModifiers::NONE)).expect("o"); - app.handle_event(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); - assert_eq!(app.selected, 1); + 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"); - let quoted = app.open.doc.source.get(pending.range.clone()).expect("range in source"); - assert!(quoted.contains("two") && !quoted.contains("one"), "quoted {quoted:?}"); - // Back in block mode, `j` jumps blocks again. + 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(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); - assert_eq!(app.selected, 2); + 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('o'), KeyModifiers::NONE)).expect("o"); + 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)); } From b3f502587cabe2bba02b96db2a8f81c6c6a3b9a2 Mon Sep 17 00:00:00 2001 From: lawrencegripper Date: Fri, 18 Sep 2026 13:21:45 +0100 Subject: [PATCH 5/5] feat(input): i, not o, enters row movement Closer to vim: i steps into the block the way insert mode steps into a line, and o carries unrelated meanings (open a line, swap visual ends). --- README.md | 2 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/app/draw.rs | 2 +- crates/plannotator-tui/src/app/input.rs | 4 ++-- crates/plannotator-tui/src/app/mod.rs | 2 +- crates/plannotator-tui/src/app/tests.rs | 4 ++-- docs/decisions.md | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 270abd8..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` `$`; `o` move the cursor with those keys first, then `v` to select from there | +| 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 b0100e3..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 · `o` 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 | +| 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 2a81a3e..9a44667 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -420,7 +420,7 @@ impl App { 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 if self.roam => "hjkl move · v select · c comment · esc blocks · q quit ", - Focus::Document => "o move · v select · c comment · E send · tab · 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 3323e21..80f5370 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -169,7 +169,7 @@ impl App { self.visual_key(key); return Ok(()); } - // Roaming (`o`): the cursor moves with nothing selected yet, so `v` can start + // 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 { @@ -194,7 +194,7 @@ 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('o'), _) => { + (KeyCode::Char('i'), _) => { self.clear_selection(); self.roam = true; self.status = Some("move: hjkl w b 0 $ · v select · esc back to blocks".into()); diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index cbed640..b222686 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -154,7 +154,7 @@ pub(crate) struct App { pending: Option, /// Keyboard cursor for visual selection, in document (row, col). cursor: (usize, usize), - /// `o`: the cursor moves by row without selecting, so `v` can start mid-block. + /// `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, diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 8fe6b3d..5633879 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -456,7 +456,7 @@ fn roaming_moves_by_row_so_a_selection_can_start_mid_block() { 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('o'), KeyModifiers::NONE)).expect("o"); + 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"); @@ -476,7 +476,7 @@ fn roaming_moves_by_row_so_a_selection_can_start_mid_block() { fn a_block_key_ends_roaming() { let mut app = app(Box::new(Discard)); draw(&mut app); - app.handle_event(&key(KeyCode::Char('o'), KeyModifiers::NONE)).expect("o"); + 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"); diff --git a/docs/decisions.md b/docs/decisions.md index a26b101..9667cc7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -367,7 +367,7 @@ looked up in whichever table holds it. Verified against the `beta` source ## 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. `o` now +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.