From 7e58a8a7e119457693257ef98f9b5f0f509bf284 Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 11:25:48 +0100 Subject: [PATCH 1/7] feat(keys): word-end, whole-row selection and marking a block directly `e` moves to a word's last character and `V` selects whole rows. `a` and `d` now mark the selected block without going through a selection. Leaving a block comment no longer leaves a toolbar behind: block `c` made a pending selection for the box and Esc kept it, which is why `a` and `d` only appeared after a cancelled comment. `a` and `d` on a block sit outside the keymap's conflict check, so a config that gave those keys to a document action keeps loading and keeps its key. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/app/draw.rs | 9 ++ crates/plannotator-tui/src/app/input.rs | 72 +++++++---- crates/plannotator-tui/src/app/mod.rs | 15 +++ crates/plannotator-tui/src/app/selection.rs | 21 +++- crates/plannotator-tui/src/app/tests.rs | 133 ++++++++++++++++++++ crates/plannotator-tui/src/keys.rs | 25 ++-- crates/plannotator-tui/src/keys/tests.rs | 16 +++ 9 files changed, 261 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index d4f139f..aeb2663 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ dimmed. The keys also work without opening the menu. | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit | | reply review | `S` send and close | | gated review | `A` approve; `E` send and finish; `q` dismiss | -| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | +| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, `Enter` to finish; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | | toolbar | `a` looks good · `c` comment · `d` delete · `y` copy · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; click a bubble | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | @@ -278,7 +278,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key | Action | Default | Does | |---|---|---| | `up` `down` `left` `right` | `k` `j` `h` `l`, arrows | move; `left`/`right` in the document start the cursor, `right` in the tree opens | -| `word-forward` `word-back` | `w` `b` | cursor to the next or previous word | +| `word-forward` `word-back` `word-end` | `w` `b` `e` | cursor to the next or previous word's start, or the next word's end | | `line-start` `line-end` | `0` `$`, `Home` `End` | cursor to the start or end of the line | | `page-down` `page-up` | `ctrl+d` `ctrl+u`, `PageDown` `PageUp` | half a page | | `top` `bottom` | `g` `G`, `Home` `End` | first or last block | @@ -295,10 +295,10 @@ screen show your keys. `plannotator-tui config` prints every action with its key | `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree | | `edit` `remove` | `e` `x`, `Delete` | the selected note | | `attach-image` `remove-image` | `I` `D` | an image on the selected note, or the selected block's | -| `visual` `move-cursor` | `v` `i` | select with the cursor; move it first | +| `visual` `visual-line` `move-cursor` | `v` `V` `i` | select with the cursor; select whole rows; move it first | | `comment` `clear-block` | `c` `x` | on the block; clear its annotations | | `clear-all` | `X` | every annotation on the document, after `yes` | -| `looks-good` `delete` | `a` `d` | toolbar, with `comment` | +| `looks-good` `delete` | `a` `d` | toolbar, with `comment`; also on the selected block, unless another document action has the key | | `copy` | `y` | copy the selection waiting in the toolbar | | `yes` `no` | `y` `n`, `Y` `N` | the send-before-quitting and clear-all questions | diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index d028bdb..2bca8ab 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 · `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 · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` `e` / `0` `$` to select; `Enter` confirms · `V` selects whole rows · `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 · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | | 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 · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it | | reply review | `S` send feedback and quit; stays open if the agent is at a dialog | diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index a2c4002..0b344fd 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -493,6 +493,14 @@ impl App { let note = self.current_note(); let has_images = note.as_deref().is_some_and(|id| self.open.store.has_images(id)); let remove_image = has_images.then(|| key(Action::RemoveImage, "remove image")).flatten(); + // One hint for both: the footer has to fit 80 columns. + let marks = [Action::LooksGood, Action::Delete] + .map(|action| self.keys.hint(action)) + .into_iter() + .filter(|hint| !hint.is_empty()) + .collect::>() + .join("/"); + let marks = bare(marks).map(|keys| format!("{keys} mark")); let help: Vec> = match self.focus { _ if self.pending.is_some() => vec![ key(Action::LooksGood, "looks good"), @@ -534,6 +542,7 @@ impl App { key(Action::MoveCursor, "move"), key(Action::Visual, "select"), key(Action::Comment, "comment"), + marks, note.is_some().then(|| key(Action::AttachImage, "image")).flatten(), remove_image, key(Action::Send, "send"), diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 72bcd14..2c328e3 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -261,7 +261,9 @@ impl App { return Ok(()); } } - match self.keys.action(&key, keys::DOCUMENT) { + let action = + self.keys.action(&key, keys::DOCUMENT).or_else(|| self.keys.action(&key, keys::BLOCK_MARKS)); + match action { Some(Action::Back) => { if self.pending.is_some() || self.selection.is_some() { self.clear_selection(); @@ -269,15 +271,8 @@ impl App { self.request_quit(); } } - Some(Action::Visual) => { - self.clear_selection(); - self.selection = Some(Selection::start(self.cursor)); - self.status = Some(format!( - "visual: move to extend, {} to select, {} to cancel", - self.keys.hint(Action::Confirm), - self.keys.hint(Action::Back) - )); - } + Some(Action::Visual) => self.start_visual(Selection::start(self.cursor)), + Some(Action::VisualLine) => self.start_visual(Selection::start_rows(self.cursor)), Some(Action::MoveCursor) => self.start_roaming(), Some(Action::Down) => self.select_block(self.selected + 1), Some(Action::Up) => self.select_block(self.selected.saturating_sub(1)), @@ -293,15 +288,10 @@ impl App { Some(Action::Bottom) => { self.select_block(self.open.doc.blocks.len().saturating_sub(1)); } - Some(Action::Comment | Action::Confirm) => { - // No selection: comment on the whole selected block. - if let (Some(block), Some(rendered)) = - (self.open.doc.blocks.get(self.selected), self.open.layout.blocks.get(self.selected)) - { - self.pending = Some(Pending { range: block.range.clone(), at: (rendered.first_row, 0) }); - self.act(Kind::Comment)?; - } - } + // No selection: these act on the whole selected block. + Some(Action::Comment | Action::Confirm) => self.act_on_block(Kind::Comment)?, + Some(Action::LooksGood) => self.act_on_block(Kind::LooksGood)?, + Some(Action::Delete) => self.act_on_block(Kind::Delete)?, Some(Action::ClearBlock) => { let removed = self.open.store.remove_in_block(&self.open.doc, self.selected)?; if removed > 0 { @@ -318,11 +308,21 @@ impl App { Ok(()) } + fn start_visual(&mut self, selection: Selection) { + self.clear_selection(); + self.selection = Some(selection); + self.status = Some(format!( + "visual: move to extend, {} to select, {} to cancel", + self.keys.hint(Action::Confirm), + self.keys.hint(Action::Back) + )); + } + /// Keys while a keyboard selection is being extended. fn visual_key(&mut self, key: KeyEvent) { match self.keys.action(&key, keys::VISUAL) { Some(Action::Back) => self.clear_selection(), - Some(Action::Confirm | Action::Visual) => self.finish_selection(), + Some(Action::Confirm | Action::Visual | Action::VisualLine) => self.finish_selection(), _ => { self.motion_key(key); } @@ -338,10 +338,11 @@ impl App { self.roam = true; let hint = |action| self.keys.hint(action); self.status = Some(format!( - "move: {} {} {} {} {} · {} select · {} back to blocks", + "move: {} {} {} {} {} {} · {} select · {} back to blocks", self.keys.directions_hint(), hint(Action::WordForward), hint(Action::WordBack), + hint(Action::WordEnd), hint(Action::LineStart), hint(Action::LineEnd), hint(Action::Visual), @@ -358,6 +359,7 @@ impl App { Some(Action::Up) => self.move_cursor(-1, 0), Some(Action::WordForward) => self.move_word(1), Some(Action::WordBack) => self.move_word(-1), + Some(Action::WordEnd) => self.move_word_end(), Some(Action::LineStart) => self.cursor.1 = 0, Some(Action::LineEnd) => { self.cursor.1 = @@ -399,6 +401,20 @@ impl App { self.ensure_cursor_visible(); } + /// Jump to the next word's last character on the current row; past the row's last word, + /// stay put. + fn move_word_end(&mut self) { + let Some(row) = self.open.layout.row(self.cursor.0) else { return }; + let chars: Vec = row.line.to_string().chars().collect(); + let is_end = |i: usize| { + chars.get(i).is_some_and(|c| !c.is_whitespace()) + && chars.get(i + 1).is_none_or(|c| c.is_whitespace()) + }; + if let Some(end) = (self.cursor.1 + 1..chars.len()).find(|&i| is_end(i)) { + self.cursor.1 = end; + } + } + /// Jump to the next (+1) or previous (-1) word start on the current row. fn move_word(&mut self, direction: i64) { let Some(row) = self.open.layout.row(self.cursor.0) else { return }; @@ -418,9 +434,17 @@ impl App { self.cursor.1 = next; } + /// Leave the comment box without saving. + fn cancel_compose(&mut self) { + self.mode = Mode::Browse; + if self.pending.as_ref().is_some_and(|pending| !pending.has_toolbar) { + self.pending = None; + } + } + fn text_key(&mut self, key: KeyEvent) -> Result<()> { match self.compose.handle_key(key) { - ComposeAction::Cancel => self.mode = Mode::Browse, + ComposeAction::Cancel => self.cancel_compose(), ComposeAction::Save => { let body = self.compose.value().trim().to_owned(); match std::mem::replace(&mut self.mode, Mode::Browse) { @@ -511,7 +535,7 @@ impl App { self.selection = None; self.cursor = pos; self.status = None; - self.pending = Some(Pending { range, at: pos }); + self.pending = Some(Pending { range, at: pos, has_toolbar: true }); return Ok(()); } if let Some(block) = self.open.layout.block_at_row(pos.0) { @@ -640,6 +664,6 @@ impl App { self.selected = block; } self.status = None; - self.pending = Some(Pending { range, at: a }); + self.pending = Some(Pending { range, at: a, has_toolbar: true }); } } diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 37e02f8..e96c3d1 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -117,6 +117,9 @@ struct Pending { range: Range, /// Document (row, col) where the selection starts; anchors the toolbar and compose box. at: (usize, usize), + /// Whether leaving the comment box comes back to the toolbar. A block comment opened + /// straight from the document never showed one. + has_toolbar: bool, } /// Everything about the open document; swapped wholesale when the tree switches files. @@ -488,6 +491,18 @@ impl App { Ok(()) } + /// Apply `kind` to the whole selected block, with no toolbar in between. + fn act_on_block(&mut self, kind: Kind) -> Result<()> { + let (Some(block), Some(rendered)) = + (self.open.doc.blocks.get(self.selected), self.open.layout.blocks.get(self.selected)) + else { + return Ok(()); + }; + self.pending = + Some(Pending { range: block.range.clone(), at: (rendered.first_row, 0), has_toolbar: false }); + self.act(kind) + } + /// `copy`: the waiting selection's source text to the clipboard. It stays selected. fn copy_pending(&mut self) { let Some(pending) = &self.pending else { return }; diff --git a/crates/plannotator-tui/src/app/selection.rs b/crates/plannotator-tui/src/app/selection.rs index 6ede05b..d315cad 100644 --- a/crates/plannotator-tui/src/app/selection.rs +++ b/crates/plannotator-tui/src/app/selection.rs @@ -8,6 +8,8 @@ pub(super) struct Selection { anchor: (usize, usize), head: (usize, usize), pub(super) dragging: bool, + /// Covers whole rows, whichever columns the anchor and head are on. + whole_rows: bool, } impl Selection { @@ -17,23 +19,31 @@ impl Selection { } pub(super) fn start(at: (usize, usize)) -> Self { - Self { anchor: at, head: at, dragging: true } + Self { anchor: at, head: at, dragging: true, whole_rows: false } + } + + pub(super) fn start_rows(at: (usize, usize)) -> Self { + Self { anchor: at, head: at, dragging: true, whole_rows: true } } pub(super) fn finished(anchor: (usize, usize), head: (usize, usize)) -> Self { - Self { anchor, head, dragging: false } + Self { anchor, head, dragging: false, whole_rows: false } } pub(super) fn set_head(&mut self, head: (usize, usize)) { self.head = head; } + /// Start and end in reading order. A whole-row selection reads from column 0 of its first + /// row; only `columns_on` knows how wide its last row is. pub(super) fn ordered(&self) -> ((usize, usize), (usize, usize)) { - if self.anchor <= self.head { (self.anchor, self.head) } else { (self.head, self.anchor) } + let (a, b) = + if self.anchor <= self.head { (self.anchor, self.head) } else { (self.head, self.anchor) }; + if self.whole_rows { ((a.0, 0), b) } else { (a, b) } } pub(super) fn is_empty(&self) -> bool { - self.anchor == self.head + !self.whole_rows && self.anchor == self.head } /// Columns of `row` covered by the selection, if any. @@ -42,6 +52,9 @@ impl Selection { if row < a.0 || row > b.0 { return None; } + if self.whole_rows { + return (row_width > 0).then_some(0..row_width); + } let start = if row == a.0 { a.1 } else { 0 }; let end = if row == b.0 { b.1 + 1 } else { row_width }; (start < end).then_some(start..end) diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 7dd4743..585e8c2 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -600,6 +600,139 @@ fn roaming_moves_by_row_so_a_selection_can_start_mid_block() { assert_eq!(app.selected, 1); } +/// A paragraph with a hard break (two rows of "alpha beta" text), then a list. +fn two_row_paragraph_app() -> App { + let source = DocumentSource::new( + "alpha beta gamma\\\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); + app +} + +fn press_char(app: &mut App, c: char) { + app.handle_event(&key(KeyCode::Char(c), KeyModifiers::NONE)).expect("key"); +} + +fn selected_text(app: &App) -> Option<&str> { + let pending = app.pending.as_ref()?; + app.open.doc.source.get(pending.range.clone()) +} + +#[test] +fn e_extends_a_selection_to_each_word_end_including_its_last_character() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + assert_eq!(app.cursor, (0, 4)); + press_char(&mut app, 'e'); + assert_eq!(app.cursor, (0, 9)); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + assert_eq!(selected_text(&app), Some("alpha beta")); +} + +#[test] +fn e_at_the_last_word_end_of_a_row_stays_put() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'i'); + for _ in 0..5 { + press_char(&mut app, 'e'); + } + assert_eq!(app.cursor, (0, 15)); +} + +#[test] +fn shift_v_selects_the_whole_row_whatever_the_column() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'i'); + press_char(&mut app, 'w'); + press_char(&mut app, 'V'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + assert_eq!(selected_text(&app), Some("alpha beta gamma")); +} + +#[test] +fn line_selection_extends_by_whole_rows_in_either_direction() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'V'); + press_char(&mut app, 'j'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + assert_eq!(selected_text(&app), Some("alpha beta gamma\\\nsecond line")); + assert_eq!(app.pending.as_ref().map(|p| p.at), Some((0, 0))); + + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("clear"); + press_char(&mut app, 'V'); + press_char(&mut app, 'k'); + press_char(&mut app, 'V'); + assert_eq!(selected_text(&app), Some("alpha beta gamma\\\nsecond line"), "V finishes it too"); + assert_eq!(app.pending.as_ref().map(|p| p.at), Some((0, 0)), "the toolbar anchors at the start"); +} + +#[test] +fn a_and_d_mark_the_selected_block_directly() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'a'); + press_char(&mut app, 'j'); + press_char(&mut app, 'd'); + let kinds: Vec = app.open.store.placed().iter().map(|p| p.annotation.anchor.kind()).collect(); + assert_eq!(kinds, [Kind::LooksGood, Kind::Delete]); + assert!(app.pending.is_none(), "no toolbar is left waiting"); +} + +#[test] +fn esc_from_a_block_comment_returns_to_the_block_not_to_a_toolbar() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'c'); + assert_eq!(app.mode, Mode::Compose); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + assert_eq!(app.mode, Mode::Browse); + assert!(app.pending.is_none()); + let rows = draw(&mut app); + assert!(!rows.iter().any(|r| r.contains("looks good (a)")), "{rows:#?}"); +} + +#[test] +fn discarding_a_block_comment_returns_to_the_block_not_to_a_toolbar() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'c'); + press_char(&mut app, 'x'); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + press_char(&mut app, 'y'); + assert!(app.pending.is_none()); +} + +#[test] +fn esc_from_a_comment_on_a_selection_or_double_click_keeps_its_toolbar() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + press_char(&mut app, 'c'); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + assert!(app.pending.is_some(), "a keyboard selection goes back to its toolbar"); + + let mut app = two_row_paragraph_app(); + app.handle_event(&click_at(5, 1)).expect("click"); + app.handle_event(&click_at(5, 1)).expect("double click"); + assert!(app.pending.is_some(), "the double click offered the toolbar"); + press_char(&mut app, 'c'); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + assert!(app.pending.is_some(), "a double-clicked block goes back to its toolbar"); +} + +#[test] +fn a_document_key_wins_over_looks_good_and_delete_on_a_block() { + let mut app = two_row_paragraph_app(); + app.keys = toml::from_str("looks-good = [\"j\"]\n").expect("a config from before a was live on blocks"); + press_char(&mut app, 'j'); + assert_eq!(app.selected, 1, "j still moves down"); + assert!(app.open.store.placed().is_empty(), "and marks nothing"); +} + #[test] fn a_block_key_ends_roaming() { let mut app = app(Box::new(Discard)); diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index 00ee8fe..7be3633 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -21,6 +21,7 @@ pub(crate) enum Action { Right, WordForward, WordBack, + WordEnd, LineStart, LineEnd, PageDown, @@ -51,6 +52,7 @@ pub(crate) enum Action { AttachImage, RemoveImage, Visual, + VisualLine, MoveCursor, Comment, ClearBlock, @@ -68,8 +70,8 @@ use Action::{ Approve, Archive, AttachImage, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CopySelection, CycleFocus, Delete, Down, Edit, FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, OpenInEditor, PageDown, PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, RemoveImage, ResendAll, - ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, - WordForward, Yes, + ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, VisualLine, + WordBack, WordEnd, WordForward, Yes, }; /// Browsing, before the focused pane gets the key. @@ -97,6 +99,7 @@ pub(crate) const RAIL: &[Action] = &[Down, Up, Confirm, Edit, Remove, AttachImag pub(crate) const DOCUMENT: &[Action] = &[ Back, Visual, + VisualLine, MoveCursor, Down, Up, @@ -113,10 +116,14 @@ pub(crate) const DOCUMENT: &[Action] = &[ AttachImage, RemoveImage, ]; +/// Marking the selected block, tried only when no `DOCUMENT` action claims the key. They sit +/// outside `SHARED` so a config that gave `a` or `d` to a document action keeps loading. +pub(crate) const BLOCK_MARKS: &[Action] = &[LooksGood, Delete]; /// Cursor motions, in visual mode and while moving the cursor (`move-cursor`). -pub(crate) const MOTION: &[Action] = &[Left, Right, Down, Up, WordForward, WordBack, LineStart, LineEnd]; +pub(crate) const MOTION: &[Action] = + &[Left, Right, Down, Up, WordForward, WordBack, WordEnd, LineStart, LineEnd]; /// While a keyboard selection grows; any other key is a motion. -pub(crate) const VISUAL: &[Action] = &[Back, Confirm, Visual]; +pub(crate) const VISUAL: &[Action] = &[Back, Confirm, Visual, VisualLine]; /// While a finished selection waits for its kind. pub(crate) const TOOLBAR: &[Action] = &[LooksGood, Comment, Delete, CopySelection]; pub(crate) const CONFIRM_QUIT: &[Action] = &[Yes, Confirm, No, Back]; @@ -249,6 +256,7 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (Right, &[ch('l'), named(KeyCode::Right)]), (WordForward, &[ch('w')]), (WordBack, &[ch('b')]), + (WordEnd, &[ch('e')]), (LineStart, &[ch('0'), named(KeyCode::Home)]), (LineEnd, &[ch('$'), named(KeyCode::End)]), (PageDown, &[ctrl('d'), named(KeyCode::PageDown)]), @@ -279,6 +287,7 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (AttachImage, &[ch('I')]), (RemoveImage, &[ch('D')]), (Visual, &[ch('v')]), + (VisualLine, &[ch('V')]), (MoveCursor, &[ch('i')]), (Comment, &[ch('c')]), (ClearBlock, &[ch('x')]), @@ -290,9 +299,9 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (No, &[ch('n'), ch('N')]), ]; -/// Actions added after configs were written: `approve` (live only in a gated review) and -/// `copy` (on a waiting selection). -const LATE: &[Action] = &[Approve, CopySelection]; +/// Actions added after configs were written: `approve` (live only in a gated review), `copy` +/// (on a waiting selection), `word-end` and `visual-line`. +const LATE: &[Action] = &[Approve, CopySelection, WordEnd, VisualLine]; /// The keys bound to each action. A `[keys]` entry replaces that action's keys and leaves the /// others at their defaults; an empty list unbinds the action. @@ -399,6 +408,7 @@ impl Action { Self::Right => "right", Self::WordForward => "word-forward", Self::WordBack => "word-back", + Self::WordEnd => "word-end", Self::LineStart => "line-start", Self::LineEnd => "line-end", Self::PageDown => "page-down", @@ -429,6 +439,7 @@ impl Action { Self::AttachImage => "attach-image", Self::RemoveImage => "remove-image", Self::Visual => "visual", + Self::VisualLine => "visual-line", Self::MoveCursor => "move-cursor", Self::Comment => "comment", Self::ClearBlock => "clear-block", diff --git a/crates/plannotator-tui/src/keys/tests.rs b/crates/plannotator-tui/src/keys/tests.rs index a8cd797..729cfa2 100644 --- a/crates/plannotator-tui/src/keys/tests.rs +++ b/crates/plannotator-tui/src/keys/tests.rs @@ -135,6 +135,22 @@ fn a_config_that_gave_approves_key_away_still_loads() { assert_eq!(parse("approve = [\"Y\"]\n").expect("rebinds").hint(Approve), "Y"); } +#[test] +fn word_end_and_visual_line_yield_to_an_older_config() { + let keymap = Keymap::default(); + assert_eq!(keymap.action(&press(KeyCode::Char('e'), KeyModifiers::NONE), MOTION), Some(WordEnd)); + assert_eq!(keymap.action(&press(KeyCode::Char('V'), KeyModifiers::SHIFT), DOCUMENT), Some(VisualLine)); + assert_eq!(parse("left = [\"e\"]\n").expect("an older config keeps loading").hint(WordEnd), ""); + assert_eq!(parse("comment = [\"V\"]\n").expect("an older config keeps loading").hint(VisualLine), ""); +} + +#[test] +fn looks_good_and_delete_on_a_block_never_conflict_with_a_document_key() { + let keymap = parse("looks-good = [\"j\"]\nclear-block = [\"d\"]\n").expect("loads as it did"); + assert_eq!(keymap.action(&press(KeyCode::Char('j'), KeyModifiers::NONE), DOCUMENT), Some(Down)); + assert_eq!(keymap.action(&press(KeyCode::Char('d'), KeyModifiers::NONE), DOCUMENT), Some(ClearBlock)); +} + #[test] fn copy_is_y_on_a_waiting_selection_and_yields_to_an_older_config() { let keymap = Keymap::default(); From 684aeba0c299c926253bb3c80f1de83b553dca7b Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 12:48:45 +0100 Subject: [PATCH 2/7] fix(keys): word motions count screen columns and any whitespace w, b and e indexed a row by character while the cursor is in screen columns, so they landed short on rows with wide characters. w and b also missed a word after a non-ASCII space such as U+3000. Co-Authored-By: Claude Opus 5.5 --- crates/plannotator-tui/src/app/input.rs | 15 +++++++++---- crates/plannotator-tui/src/app/tests.rs | 29 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 2c328e3..9ce8a92 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -3,6 +3,8 @@ use anyhow::Result; use plannotator_tui_schema::Kind; use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::text::Line; +use unicode_width::UnicodeWidthChar; use super::compose::ComposeAction; use super::menu::ReviewAction; @@ -13,6 +15,12 @@ use crate::delivery::Delivery as _; use crate::gate::Gate; use crate::keys::{self, Action}; +/// A row's characters indexed by screen column, as the cursor is: a wide one fills two. +fn chars_by_column(line: &Line<'_>) -> Vec { + let text = line.to_string(); + text.chars().flat_map(|c| std::iter::repeat_n(c, c.width().unwrap_or(0))).collect() +} + impl App { pub(crate) fn handle_event(&mut self, event: &Event) -> Result<()> { match event { @@ -405,7 +413,7 @@ impl App { /// stay put. fn move_word_end(&mut self) { let Some(row) = self.open.layout.row(self.cursor.0) else { return }; - let chars: Vec = row.line.to_string().chars().collect(); + let chars = chars_by_column(&row.line); let is_end = |i: usize| { chars.get(i).is_some_and(|c| !c.is_whitespace()) && chars.get(i + 1).is_none_or(|c| c.is_whitespace()) @@ -418,11 +426,10 @@ impl App { /// Jump to the next (+1) or previous (-1) word start on the current row. fn move_word(&mut self, direction: i64) { let Some(row) = self.open.layout.row(self.cursor.0) else { return }; - let text = row.line.to_string(); - let chars: Vec = text.chars().collect(); + let chars = chars_by_column(&row.line); let is_boundary = |i: usize| { let here = chars.get(i).is_some_and(|c| !c.is_whitespace()); - let before = i == 0 || chars.get(i - 1).is_some_and(char::is_ascii_whitespace); + let before = i == 0 || chars.get(i - 1).is_some_and(|c| c.is_whitespace()); here && before }; let col = self.cursor.1; diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 585e8c2..610155f 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -635,6 +635,35 @@ fn e_extends_a_selection_to_each_word_end_including_its_last_character() { assert_eq!(selected_text(&app), Some("alpha beta")); } +#[test] +fn word_motions_count_a_wide_character_as_two_columns() { + let source = + DocumentSource::new("日本語 テキスト abc def\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); + press_char(&mut app, 'i'); + press_char(&mut app, 'w'); + assert_eq!(app.cursor, (0, 7)); + press_char(&mut app, 'e'); + assert_eq!(app.cursor, (0, 14)); + press_char(&mut app, 'w'); + assert_eq!(app.cursor, (0, 16)); + press_char(&mut app, 'b'); + assert_eq!(app.cursor, (0, 7)); +} + +#[test] +fn a_word_starts_after_an_ideographic_space() { + let source = DocumentSource::new("a\u{3000}b c\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); + press_char(&mut app, 'i'); + press_char(&mut app, 'w'); + assert_eq!(app.cursor, (0, 3)); +} + #[test] fn e_at_the_last_word_end_of_a_row_stays_put() { let mut app = two_row_paragraph_app(); From c441daeade7d46d081e0530f54c8c98a1e4f469c Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 14:32:15 +0100 Subject: [PATCH 3/7] fix(marks): repeating a mark on the same text adds nothing Pressing a or d twice added two identical notes, and both were sent to the agent. The status now says the mark is already saved. Co-Authored-By: Claude Opus 5.5 --- crates/plannotator-tui/src/app/mod.rs | 12 ++++++++-- crates/plannotator-tui/src/app/tests.rs | 31 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index e96c3d1..3a93610 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -483,9 +483,17 @@ impl App { self.compose = Compose::default(); } Kind::LooksGood | Kind::Delete => { - self.annotate(pending.range, kind, String::new())?; + let placed = self.open.store.placed(); + let repeated = placed.iter().any(|p| p.kind() == kind && *p.range == pending.range); + drop(placed); + let outcome = if repeated { + "already saved" + } else { + self.annotate(pending.range, kind, String::new())?; + "saved" + }; self.clear_selection(); - self.status = Some(format!("{} saved", label(kind))); + self.status = Some(format!("{} {outcome}", label(kind))); } } Ok(()) diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 610155f..b6e4e8c 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -712,6 +712,37 @@ fn a_and_d_mark_the_selected_block_directly() { assert!(app.pending.is_none(), "no toolbar is left waiting"); } +#[test] +fn repeating_a_mark_on_a_block_adds_nothing() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'a'); + press_char(&mut app, 'a'); + assert_eq!(app.open.store.len(), 1); + assert_eq!(app.status.as_deref(), Some("looks good already saved")); + assert!(app.pending.is_none(), "no toolbar is left waiting"); + + app.open.store.resolve_all(&app.open.doc); + press_char(&mut app, 'a'); + assert_eq!(app.open.store.len(), 1, "a mark reloaded from disk counts too"); + + press_char(&mut app, 'd'); + assert_eq!(app.open.store.len(), 2, "the other mark is a different note"); +} + +#[test] +fn repeating_a_mark_on_the_same_selection_adds_nothing() { + let mut app = two_row_paragraph_app(); + for _ in 0..2 { + press_char(&mut app, 'i'); + press_char(&mut app, '0'); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + press_char(&mut app, 'a'); + } + assert_eq!(app.open.store.len(), 1); +} + #[test] fn esc_from_a_block_comment_returns_to_the_block_not_to_a_toolbar() { let mut app = two_row_paragraph_app(); From a1584038aa21727e6fca9d18849859b979a4b05a Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 14:55:33 +0100 Subject: [PATCH 4/7] feat(keys): a, c and d act on a selection without Enter first Enter still opens the toolbar, and a motion bound to one of these keys still moves. A key that does nothing during a mouse drag no longer collapses the selection, and a selection that never moved no longer leaves its visual hint in the footer. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/app/input.rs | 42 ++++++++++---- crates/plannotator-tui/src/app/tests.rs | 74 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index aeb2663..335c7c3 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ dimmed. The keys also work without opening the menu. | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit | | reply review | `S` send and close | | gated review | `A` approve; `E` send and finish; `q` dismiss | -| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, `Enter` to finish; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | +| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, then `a`, `c` or `d` to act on it, or `Enter` for the toolbar; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | | toolbar | `a` looks good · `c` comment · `d` delete · `y` copy · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; 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 2bca8ab..963b899 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` `e` / `0` `$` to select; `Enter` confirms · `V` selects whole rows · `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 · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` `e` / `0` `$` to select; `a`, `c` or `d` acts on it, `Enter` opens the toolbar · `V` selects whole rows · `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 · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | | 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 · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it | | reply review | `S` send feedback and quit; stays open if the agent is at a dialog | diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 9ce8a92..d8447b7 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -247,15 +247,13 @@ impl App { return Ok(()); } if self.pending.is_some() - && let Some(action) = self.keys.action(&key, keys::TOOLBAR) - && let Some(&(_, _, _, kind)) = TOOLBAR.iter().find(|item| item.2 == action) + && let Some(kind) = self.toolbar_kind(&key) { return self.act(kind); } // Visual mode: the selection follows the cursor. if self.selection.is_some_and(|s| s.dragging) { - self.visual_key(key); - return Ok(()); + return self.visual_key(key); } // Roaming (`i`): the cursor moves with nothing selected yet, so `v` can start // anywhere. Other keys fall through to their block-mode meaning. @@ -319,25 +317,45 @@ impl App { fn start_visual(&mut self, selection: Selection) { self.clear_selection(); self.selection = Some(selection); + let marks = TOOLBAR + .map(|item| self.keys.hint(item.2)) + .into_iter() + .filter(|hint| !hint.is_empty()) + .collect::>() + .join("/"); self.status = Some(format!( - "visual: move to extend, {} to select, {} to cancel", + "visual: move to extend, {marks} to act, {} to select, {} to cancel", self.keys.hint(Action::Confirm), self.keys.hint(Action::Back) )); } - /// Keys while a keyboard selection is being extended. - fn visual_key(&mut self, key: KeyEvent) { + /// Keys while a keyboard selection is being extended. A motion wins over a toolbar key, so + /// a config that gave `a` to a motion keeps it. + fn visual_key(&mut self, key: KeyEvent) -> Result<()> { match self.keys.action(&key, keys::VISUAL) { Some(Action::Back) => self.clear_selection(), Some(Action::Confirm | Action::Visual | Action::VisualLine) => self.finish_selection(), + _ if self.motion_key(key) => { + // Only a motion moves the head: during a mouse drag the cursor is not on it. + if let Some(sel) = self.selection.as_mut() { + sel.set_head(self.cursor); + } + } _ => { - self.motion_key(key); + if let Some(kind) = self.toolbar_kind(&key) { + self.finish_selection(); + return self.act(kind); + } } } - if let Some(sel) = self.selection.as_mut() { - sel.set_head(self.cursor); - } + Ok(()) + } + + /// The annotation kind `key` makes from the toolbar. + fn toolbar_kind(&self, key: &KeyEvent) -> Option { + let action = self.keys.action(key, keys::TOOLBAR)?; + TOOLBAR.iter().find(|item| item.2 == action).map(|item| item.3) } /// Enter roaming: the visual-mode motions move the cursor with nothing selected. @@ -641,6 +659,7 @@ impl App { sel.dragging = false; if sel.is_empty() { self.selection = None; + self.status = None; return; } let sel = *sel; @@ -656,6 +675,7 @@ impl App { } let Some((lo, hi)) = bounds else { self.selection = None; + self.status = None; return; }; let source = &self.open.doc.source; diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index b6e4e8c..ec17180 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -712,6 +712,80 @@ fn a_and_d_mark_the_selected_block_directly() { assert!(app.pending.is_none(), "no toolbar is left waiting"); } +#[test] +fn a_and_d_mark_a_selection_still_being_extended() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + press_char(&mut app, 'a'); + let placed = app.open.store.placed(); + assert_eq!(placed.len(), 1); + assert_eq!(placed[0].kind(), Kind::LooksGood); + assert_eq!(app.open.doc.source.get(placed[0].range.clone()), Some("alpha")); + drop(placed); + assert!(app.selection.is_none() && app.pending.is_none(), "nothing is left waiting"); + + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'V'); + press_char(&mut app, 'd'); + let placed = app.open.store.placed(); + assert_eq!(placed.len(), 1); + assert_eq!(placed[0].kind(), Kind::Delete); + assert_eq!(app.open.doc.source.get(placed[0].range.clone()), Some("alpha beta gamma")); +} + +#[test] +fn c_comments_on_a_selection_still_being_extended_and_esc_returns_to_its_toolbar() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + press_char(&mut app, 'c'); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(selected_text(&app), Some("alpha")); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + assert_eq!(selected_text(&app), Some("alpha"), "the toolbar is back for the selection"); +} + +#[test] +fn marking_a_selection_that_never_moved_selects_nothing() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'a'); + assert!(app.open.store.placed().is_empty()); + assert!(app.selection.is_none()); + assert_eq!(app.status, None, "the visual hint must not outlive the selection"); +} + +#[test] +fn the_visual_hint_names_only_the_bound_toolbar_keys() { + let mut app = two_row_paragraph_app(); + app.keys = toml::from_str("comment = []\n").expect("keys"); + press_char(&mut app, 'v'); + assert!(app.status.as_deref().is_some_and(|s| s.contains(" a/d to act")), "{:?}", app.status); +} + +#[test] +fn a_key_that_does_nothing_during_a_mouse_drag_keeps_the_selection() { + let mut app = two_row_paragraph_app(); + let doc = app.geometry.doc; + click(&mut app, MouseEventKind::Down(MouseButton::Left), doc.x, doc.y); + click(&mut app, MouseEventKind::Drag(MouseButton::Left), doc.x + 3, doc.y); + press_char(&mut app, 'y'); + click(&mut app, MouseEventKind::Up(MouseButton::Left), doc.x + 3, doc.y); + assert_eq!(selected_text(&app), Some("alph")); +} + +#[test] +fn a_motion_bound_to_a_mark_key_still_moves_a_selection() { + let mut app = two_row_paragraph_app(); + app.keys = toml::from_str("word-end = [\"a\"]\n").expect("keys"); + press_char(&mut app, 'v'); + press_char(&mut app, 'a'); + assert_eq!(app.cursor, (0, 4)); + assert!(app.open.store.placed().is_empty()); + assert!(app.selection.is_some_and(|s| s.dragging), "still extending"); +} + #[test] fn repeating_a_mark_on_a_block_adds_nothing() { let mut app = two_row_paragraph_app(); From 2132643f2b023acb72f11d68fe6dce1c147bbc91 Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 15:01:27 +0100 Subject: [PATCH 5/7] feat(keys): y copies a selection without Enter first Like a, c and d, the copy key now finishes a selection still being extended. The text stays selected, as it does when copying from the toolbar. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/plannotator-tui/README.md | 2 +- crates/plannotator-tui/src/app/input.rs | 7 +++++- crates/plannotator-tui/src/app/tests.rs | 32 ++++++++++++++++++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 335c7c3..d7d1d2b 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ dimmed. The keys also work without opening the menu. | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit | | reply review | `S` send and close | | gated review | `A` approve; `E` send and finish; `q` dismiss | -| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, then `a`, `c` or `d` to act on it, or `Enter` for the toolbar; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | +| document | `j`/`k` block; `c` comment on the block; `a` looks good; `d` delete; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `e` `0` `$`, then `a`, `c` or `d` to act on it, `y` to copy it, or `Enter` for the toolbar; `V` select whole rows; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | | toolbar | `a` looks good · `c` comment · `d` delete · `y` copy · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; 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 963b899..422c075 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` `e` / `0` `$` to select; `a`, `c` or `d` acts on it, `Enter` opens the toolbar · `V` selects whole rows · `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 · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` `e` / `0` `$` to select; `a`, `c` or `d` acts on it, `y` copies it, `Enter` opens the toolbar · `V` selects whole rows · `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 · `a` marks it looks good · `d` marks it for deletion · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | | 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 · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it | | reply review | `S` send feedback and quit; stays open if the agent is at a dialog | diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index d8447b7..dee0271 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -317,9 +317,11 @@ impl App { fn start_visual(&mut self, selection: Selection) { self.clear_selection(); self.selection = Some(selection); + let copy = (!self.ui.copy_on_select).then(|| self.keys.hint(Action::CopySelection)); let marks = TOOLBAR .map(|item| self.keys.hint(item.2)) .into_iter() + .chain(copy) .filter(|hint| !hint.is_empty()) .collect::>() .join("/"); @@ -343,7 +345,10 @@ impl App { } } _ => { - if let Some(kind) = self.toolbar_kind(&key) { + if self.keys.action(&key, keys::TOOLBAR) == Some(Action::CopySelection) { + self.finish_selection(); + self.copy_pending(); + } else if let Some(kind) = self.toolbar_kind(&key) { self.finish_selection(); return self.act(kind); } diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index ec17180..ca8c06e 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -770,11 +770,41 @@ fn a_key_that_does_nothing_during_a_mouse_drag_keeps_the_selection() { let doc = app.geometry.doc; click(&mut app, MouseEventKind::Down(MouseButton::Left), doc.x, doc.y); click(&mut app, MouseEventKind::Drag(MouseButton::Left), doc.x + 3, doc.y); - press_char(&mut app, 'y'); + press_char(&mut app, 'z'); click(&mut app, MouseEventKind::Up(MouseButton::Left), doc.x + 3, doc.y); assert_eq!(selected_text(&app), Some("alph")); } +#[test] +fn y_copies_a_selection_still_being_extended_and_keeps_it_selected() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'V'); + press_char(&mut app, 'y'); + assert_eq!(app.status.as_deref(), Some("copied")); + assert_eq!(selected_text(&app), Some("alpha beta gamma")); + assert!(app.open.store.placed().is_empty()); +} + +#[test] +fn y_on_a_selection_that_never_moved_copies_nothing() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'y'); + assert!(app.selection.is_none() && app.pending.is_none()); + assert_eq!(app.status, None); +} + +#[test] +fn the_visual_hint_names_the_copy_key_only_when_selecting_does_not_copy() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + assert!(app.status.as_deref().is_some_and(|s| s.contains(" a/c/d to act")), "{:?}", app.status); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + app.ui.copy_on_select = false; + press_char(&mut app, 'v'); + assert!(app.status.as_deref().is_some_and(|s| s.contains(" a/c/d/y to act")), "{:?}", app.status); +} + #[test] fn a_motion_bound_to_a_mark_key_still_moves_a_selection() { let mut app = two_row_paragraph_app(); From df4b996f9d3f2c06f5d2ea3161385d0e6c80b80f Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 16:16:33 +0100 Subject: [PATCH 6/7] fix(keys): a held Ctrl does not trigger a mark on a block or a selection A plain binding answers with Ctrl held, which would make Ctrl+A mark a block and Ctrl+D mark a selection instead of paging. These new surfaces ignore such a press, while a mark bound to a Ctrl key still fires. Co-Authored-By: Claude Opus 5.5 --- crates/plannotator-tui/src/app/input.rs | 7 ++++-- crates/plannotator-tui/src/app/tests.rs | 32 +++++++++++++++++++++++++ crates/plannotator-tui/src/keys.rs | 9 +++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index dee0271..5550064 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -267,8 +267,10 @@ impl App { return Ok(()); } } - let action = - self.keys.action(&key, keys::DOCUMENT).or_else(|| self.keys.action(&key, keys::BLOCK_MARKS)); + let action = self + .keys + .action(&key, keys::DOCUMENT) + .or_else(|| self.keys.action_without_ctrl_leak(&key, keys::BLOCK_MARKS)); match action { Some(Action::Back) => { if self.pending.is_some() || self.selection.is_some() { @@ -344,6 +346,7 @@ impl App { sel.set_head(self.cursor); } } + _ if self.keys.action_without_ctrl_leak(&key, keys::TOOLBAR).is_none() => {} _ => { if self.keys.action(&key, keys::TOOLBAR) == Some(Action::CopySelection) { self.finish_selection(); diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index ca8c06e..36b2d6c 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -805,6 +805,38 @@ fn the_visual_hint_names_the_copy_key_only_when_selecting_does_not_copy() { assert!(app.status.as_deref().is_some_and(|s| s.contains(" a/c/d/y to act")), "{:?}", app.status); } +#[test] +fn ctrl_a_marks_nothing() { + let mut app = two_row_paragraph_app(); + app.handle_event(&key(KeyCode::Char('a'), KeyModifiers::CONTROL)).expect("ctrl+a"); + assert!(app.open.store.placed().is_empty()); +} + +#[test] +fn ctrl_d_marks_nothing_on_a_selection_being_extended() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Char('d'), KeyModifiers::CONTROL)).expect("ctrl+d"); + assert!(app.open.store.placed().is_empty()); + assert!(app.selection.is_some(), "the selection keeps growing"); +} + +#[test] +fn a_mark_bound_to_ctrl_still_fires_on_a_block_and_on_a_selection() { + let mut app = two_row_paragraph_app(); + app.keys = toml::from_str("looks-good = [\"ctrl+z\"]\n").expect("keys"); + app.handle_event(&key(KeyCode::Char('z'), KeyModifiers::CONTROL)).expect("ctrl+z"); + assert_eq!(app.open.store.len(), 1); + + let mut app = two_row_paragraph_app(); + app.keys = toml::from_str("delete = [\"ctrl+d\"]\n").expect("keys"); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Char('d'), KeyModifiers::CONTROL)).expect("ctrl+d"); + assert_eq!(app.open.store.len(), 1); +} + #[test] fn a_motion_bound_to_a_mark_key_still_moves_a_selection() { let mut app = two_row_paragraph_app(); diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index 7be3633..af62a05 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -348,6 +348,15 @@ impl Keymap { .or_else(|| context.iter().copied().find(|&action| self.is(action, key))) } + /// `action`, except a plain binding does not answer with Ctrl held: `ctrl+d` pages, it + /// does not mark. Ctrl with Alt still counts as plain, because Windows reports `AltGr` + /// that way and `AltGr` types ordinary characters. + pub(crate) fn action_without_ctrl_leak(&self, key: &KeyEvent, context: &[Action]) -> Option { + let held_ctrl = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT) == KeyModifiers::CONTROL; + self.action(key, context) + .filter(|&action| !held_ctrl || self.keys(action).iter().any(|k| k.ctrl && k.matches(key))) + } + pub(crate) fn is(&self, action: Action, key: &KeyEvent) -> bool { self.keys(action).iter().any(|k| k.matches(key)) } From 70e32f5a3525b9250e72e82f081658263fd7be59 Mon Sep 17 00:00:00 2001 From: Sebastian Stammler Date: Wed, 4 Nov 2026 14:32:24 +0100 Subject: [PATCH 7/7] fix(ui): footer key hints flow onto up to three rows Hints that didn't fit ran into the file and status text. They now move to rows above it, at most three in total, and the document keeps what the keys act on in view when the footer grows. A text box shows no hints, since it takes every key and its border names the ones it answers. Co-Authored-By: Claude Opus 5.5 --- .../plannotator-tui/src/app/approve/tests.rs | 3 +- crates/plannotator-tui/src/app/draw.rs | 116 ++++++++++++--- crates/plannotator-tui/src/app/mod.rs | 18 +++ crates/plannotator-tui/src/app/send/tests.rs | 3 +- crates/plannotator-tui/src/app/tests.rs | 140 +++++++++++++++++- 5 files changed, 249 insertions(+), 31 deletions(-) diff --git a/crates/plannotator-tui/src/app/approve/tests.rs b/crates/plannotator-tui/src/app/approve/tests.rs index f93a83e..285ef73 100644 --- a/crates/plannotator-tui/src/app/approve/tests.rs +++ b/crates/plannotator-tui/src/app/approve/tests.rs @@ -135,8 +135,7 @@ fn the_approve_button_sits_on_the_edge_and_approves_when_clicked() { let screen = draw(&mut app, 100, 12); let header = screen.lines().next().unwrap_or_default(); assert!(header.trim_end().ends_with("Approve (A)"), "{header}"); - let footer = screen.lines().last().unwrap_or_default(); - assert!(footer.contains("A approve"), "{footer}"); + assert!(screen.contains("A approve"), "{screen}"); let rect = app.geometry.approve_button.expect("drawn"); assert_eq!(rect.right(), 100); click(&mut app, rect); diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index 0b344fd..ff47906 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -42,13 +42,15 @@ fn priority(kind: Kind) -> u8 { } } +const FOOTER_MAX_ROWS: usize = 3; + impl App { pub(crate) fn draw(&mut self, frame: &mut Frame) { let area = self.viewport(frame.area()); let [header, body, footer] = Layout::vertical([ Constraint::Length(self.header_height(area.width)), Constraint::Min(1), - Constraint::Length(1), + Constraint::Length(self.footer_height(area.width)), ]) .areas(area); @@ -77,12 +79,20 @@ impl App { Constraint::Length(rail_width), ]) .areas(body); + let old_height = self.geometry.doc.height; self.geometry = Geometry { tree, doc, ..Geometry::default() }; - if self.open.layout.width != usize::from(doc.width) { - self.open.layout.reflow(usize::from(doc.width)); + // A layout is at least one column wide, so an empty pane must not count as a resize. + let layout_width = usize::from(doc.width).max(1); + if self.open.layout.width != layout_width { + self.open.layout.reflow(layout_width); self.clear_selection(); self.scroll_by(0); + // Clearing the selection can change the footer's height, so lay out again. + return self.draw(frame); + } + if doc.height < old_height { + self.keep_focus_in_view(old_height); } self.draw_header(frame, header); @@ -422,16 +432,54 @@ impl App { frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area); return; } - if self.mode == Mode::Browse && !self.undo_archive.is_empty() { - let label = self.keys.labelled(Action::UndoFinish, "Undo finish"); - let label = format!(" {} ", label.as_deref().unwrap_or("Undo finish")); + let (text, hint_rows) = self.footer_text(area.width); + // A pane too short for any footer has no row to put the button on. + if let Some(label) = self.undo_label().filter(|_| area.height > 0) { let width = (label.width() as u16).min(area.width); - let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height }; + let y = area.bottom().saturating_sub(1); + let rect = Rect { x: area.right() - width, y, width, height: 1 }; frame .render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(palette().accent)), rect); self.geometry.undo_button = Some(rect); area.width = area.width.saturating_sub(width); } + // Paired from the bottom, so a pane too short for every row keeps the one with the text. + for (index, (hints, y)) in hint_rows.iter().rev().zip((area.y..area.bottom()).rev()).enumerate() { + let row = Rect { y, height: 1, ..area }; + let hints = if hints.is_empty() { String::new() } else { format!("{hints} ") }; + let help_area = if index == 0 { + let [text_area, help_area] = + Layout::horizontal([Constraint::Min(0), Constraint::Length(hints.width() as u16)]) + .areas(row); + frame.render_widget(Paragraph::new(Line::from(Span::raw(text.as_str()).dim())), text_area); + help_area + } else { + row + }; + frame + .render_widget(Paragraph::new(Line::from(Span::raw(hints).dim()).right_aligned()), help_area); + } + } + + /// The footer's rows at `width`; the questions that take it over need one. + fn footer_height(&self, width: u16) -> u16 { + if matches!(self.mode, Mode::ConfirmQuit | Mode::ConfirmClearAll) { + return 1; + } + self.footer_text(width).1.len() as u16 + } + + /// The button that undoes a finished review, as drawn at the end of the footer. + fn undo_label(&self) -> Option { + if self.mode != Mode::Browse || self.undo_archive.is_empty() { + return None; + } + let label = self.keys.labelled(Action::UndoFinish, "Undo finish"); + Some(format!(" {} ", label.as_deref().unwrap_or("Undo finish"))) + } + + /// The footer's left text and its key hints in rows; the last row sits beside the text. + fn footer_text(&self, viewport_width: u16) -> (String, Vec) { let orphans = self.open.store.orphans(); // The status leads: it is the transient half of the line, and the name and counters // it pushes right are on screen for the whole session anyway. @@ -451,26 +499,23 @@ impl App { None => format!("block {}/{}", self.selected + 1, self.open.doc.blocks.len()), }, ]); - if self.viewport(frame.area()).width < RAIL_MIN_TOTAL_WIDTH { + if viewport_width < RAIL_MIN_TOTAL_WIDTH { parts.push("rail hidden: widen to ≥80 cols".into()); } - let help = self.footer_help(); - let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; - // 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. - let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16; - let help_width = (help.width() as u16).min(area.width.saturating_sub(status_width.max(10))); - let [left_area, right_area] = - Layout::horizontal([Constraint::Min(10), Constraint::Length(help_width)]).areas(area); - frame.render_widget( - Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())), - left_area, - ); - frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area); + let text = format!(" {}", parts.join(" · ")); + let undo = self.undo_label().map_or(0, |label| label.width()); + // One column stays free after the hints, and one between them and the text. + let width = usize::from(viewport_width).saturating_sub(undo + 1); + let beside_text = width.saturating_sub(text.width() + 1); + (text, flow_hints(&self.footer_help(), width, beside_text)) } /// The key help for the focused pane, in the user's keys. fn footer_help(&self) -> Vec { + // A text box takes every key and its border names the ones it answers. + if matches!(self.mode, Mode::Compose | Mode::Edit(_) | Mode::AttachImage(_)) { + return Vec::new(); + } // Each hint names its action's first key; an unbound action drops out of the line. let key = |action, label| self.keys.labelled(action, label); let bare = |hint: String| (!hint.is_empty()).then_some(hint); @@ -561,6 +606,35 @@ impl App { } } +/// Hints in the fewest rows that hold them all, up to `FOOTER_MAX_ROWS`; past that, the last +/// ones are dropped. Rows are `width` wide, except the last, which has `last_width`. +fn flow_hints(hints: &[String], width: usize, last_width: usize) -> Vec { + let fill = |rows: usize| { + let mut left = hints.iter().peekable(); + let filled: Vec = (1..=rows) + .map(|row| { + let limit = if row == rows { last_width } else { width }; + let mut line = String::new(); + while let Some(hint) = left.peek() { + let joined = + if line.is_empty() { (*hint).clone() } else { format!("{line} \u{b7} {hint}") }; + if joined.width() > limit { + break; + } + line = joined; + left.next(); + } + line + }) + .collect(); + (filled, left.next().is_none()) + }; + (1..FOOTER_MAX_ROWS) + .map(fill) + .find_map(|(filled, complete)| complete.then_some(filled)) + .unwrap_or_else(|| fill(FOOTER_MAX_ROWS).0) +} + /// The tail of an id, enough to tell bubbles apart: `anno_…F0123` → `F0123`. fn short_id(id: &str) -> String { let tail: Vec = id.chars().rev().take(5).collect(); diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 3a93610..51adb53 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -580,6 +580,24 @@ impl App { } } + /// The footer took rows from a document `old_height` tall: scroll just enough to keep what + /// the keys act on in view. Anything already off screen stays there, so a reader who + /// scrolled away is not pulled back. + fn keep_focus_in_view(&mut self, old_height: u16) { + let height = usize::from(self.geometry.doc.height.max(1)); + let row = match &self.pending { + Some(pending) => pending.at.0, + None if self.roam || self.selection.is_some_and(|s| s.dragging) => self.cursor.0, + None => { + let Some(block) = self.open.layout.blocks.get(self.selected) else { return }; + block.first_row + block.rows.len().saturating_sub(1) + } + }; + if row >= self.scroll + height && row < self.scroll + usize::from(old_height) { + self.scroll = row + 1 - height; + } + } + fn scroll_by(&mut self, delta: i64) { let height = usize::from(self.geometry.doc.height.max(1)); let max = self.open.layout.total_rows.saturating_sub(height); diff --git a/crates/plannotator-tui/src/app/send/tests.rs b/crates/plannotator-tui/src/app/send/tests.rs index 08aefd9..d468c65 100644 --- a/crates/plannotator-tui/src/app/send/tests.rs +++ b/crates/plannotator-tui/src/app/send/tests.rs @@ -227,13 +227,12 @@ fn the_footer_names_s_only_in_a_reply_review_and_fits_at_80_columns() { let (root, mut app, _) = if reply { reply_app("s-footer") } else { file_app("s-footer-file") }; app.roam = true; let screen = draw(&mut app, 80, 24); - let footer = screen.lines().last().expect("footer"); let help = if reply { "hjkl move · v select · c comment · esc blocks · S send+quit · q quit" } else { "hjkl move · v select · c comment · esc blocks · q quit" }; - assert!(footer.contains(help), "reply={reply}: {footer:?}"); + assert!(screen.contains(help), "reply={reply}: {screen}"); std::fs::remove_dir_all(root).expect("cleanup"); } } diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 36b2d6c..a8534ca 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -247,7 +247,8 @@ fn a_status_leads_the_footer_and_the_name_counters_and_key_help_stay() { footer.trim_start().starts_with("comment saved · plan.md · 1 annotations · block 1/"), "footer was {footer:?}" ); - assert!(footer.trim_end().ends_with("· q quit"), "key help is still drawn: {footer:?}"); + let help = row(&rows, 18); + assert!(help.trim_end().ends_with("· q quit"), "key help is still drawn: {help:?}"); // Once the status is cleared the same line continues with the name. app.status = None; let rows = draw_sized(&mut app, 140, 20); @@ -879,6 +880,133 @@ fn repeating_a_mark_on_the_same_selection_adds_nothing() { assert_eq!(app.open.store.len(), 1); } +#[test] +fn the_footer_names_no_browse_keys_while_a_text_box_is_open() { + let mut app = two_row_paragraph_app(); + press_char(&mut app, 'c'); + let screen = draw(&mut app).join("\n"); + assert!(!screen.contains("· d delete") && !screen.contains("esc clear"), "{screen}"); + app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("esc"); + + press_char(&mut app, 'a'); + app.handle_event(&key(KeyCode::Tab, KeyModifiers::NONE)).expect("tab"); + let screen = draw(&mut app).join("\n"); + assert!(screen.contains("e edit"), "the rail names its keys: {screen}"); + press_char(&mut app, 'e'); + let screen = draw(&mut app).join("\n"); + assert!(!screen.contains("e edit"), "{screen}"); +} + +#[test] +fn a_footer_too_long_for_one_row_flows_upward_and_loses_nothing() { + let mut app = two_row_paragraph_app(); + let rows = draw_sized(&mut app, 140, 20); + assert_eq!(20 - app.geometry.doc.bottom(), 1, "one row while it fits: {rows:#?}"); + + let rows = draw_sized(&mut app, 60, 20); + let height = 20 - app.geometry.doc.bottom(); + assert!(height > 1, "{rows:#?}"); + let footer: Vec = rows.iter().skip(usize::from(20 - height)).cloned().collect(); + let text = footer.join("\n"); + for expected in ["i move", "v select", "q quit"] { + assert!(text.contains(expected), "{expected} missing from {text}"); + } + assert!( + footer.last().is_some_and(|last| last.trim_start().starts_with("doc.md · 0 annotations · block 1/")), + "{text}" + ); +} + +#[test] +fn a_footer_that_grows_keeps_the_marked_block_in_view() { + let mut app = long_app(); + let mut footer_grew = false; + for block in 0..app.open.doc.blocks.len() { + app.status = None; + draw_sized(&mut app, 127, 11); + app.select_block(block); + draw_sized(&mut app, 127, 11); + let rendered = &app.open.layout.blocks[block]; + let last = rendered.first_row + rendered.rows.len() - 1; + let before = app.geometry.doc.height; + let shown_before = last < app.scroll + usize::from(before); + press_char(&mut app, 'a'); + draw_sized(&mut app, 127, 11); + footer_grew |= app.geometry.doc.height < before; + if shown_before { + let shown = last < app.scroll + usize::from(app.geometry.doc.height); + assert!(shown, "block {block}: row {last} left the view at scroll {}", app.scroll); + } + } + assert!(footer_grew, "the status never grew the footer, so this tested nothing"); +} + +#[test] +fn a_footer_that_grows_leaves_a_block_the_reader_scrolled_away_from() { + let mut app = long_app(); + draw_sized(&mut app, 127, 11); + app.scroll_by(6); + let scroll = app.scroll; + let before = app.geometry.doc.height; + app.set_status("a status long enough to push the key hints up onto a row of their own".to_owned()); + draw_sized(&mut app, 127, 11); + assert!(app.geometry.doc.height < before, "the status grew the footer"); + assert_eq!(app.scroll, scroll); +} + +#[test] +fn a_footer_that_grows_keeps_the_toolbar_on_screen() { + let mut app = long_app(); + draw_sized(&mut app, 127, 11); + app.select_block(4); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + draw_sized(&mut app, 127, 11); + assert!(app.geometry.toolbar.is_some()); + app.set_status("a status long enough to push the key hints up onto a row of their own".to_owned()); + draw_sized(&mut app, 127, 11); + assert!(app.geometry.toolbar.is_some(), "the selection is still waiting for an action"); +} + +#[test] +fn a_pane_too_narrow_for_the_document_still_draws() { + let mut app = two_row_paragraph_app(); + draw_sized(&mut app, 0, 5); + assert_eq!(app.geometry.doc.width, 0); +} + +fn long_app() -> App { + let text = (1..=12).map(|n| format!("para {n}")).collect::>().join("\n\n"); + let source = DocumentSource::new(text, "doc.md", true, Provenance::Stdin); + let mut app = App::open(source, 60, Box::new(Discard)).expect("app opens"); + app.data_dir = scratch_data_dir(); + app +} + +#[test] +fn a_resize_that_clears_the_selection_sizes_the_footer_without_it() { + let mut app = two_row_paragraph_app(); + draw_sized(&mut app, 100, 20); + press_char(&mut app, 'v'); + press_char(&mut app, 'e'); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter"); + draw_sized(&mut app, 100, 20); + assert!(app.pending.is_some()); + draw_sized(&mut app, 60, 20); + assert!(app.pending.is_none(), "the reflow cleared it"); + let height = app.geometry.doc.height; + draw_sized(&mut app, 60, 20); + assert_eq!(app.geometry.doc.height, height); +} + +#[test] +fn the_footer_stops_at_three_rows() { + let mut app = two_row_paragraph_app(); + draw_sized(&mut app, 24, 20); + assert_eq!(20 - app.geometry.doc.bottom(), 3); +} + #[test] fn esc_from_a_block_comment_returns_to_the_block_not_to_a_toolbar() { let mut app = two_row_paragraph_app(); @@ -1205,8 +1333,8 @@ fn rebound_movement_keys_move_and_the_footer_names_them() { keys(&mut app, &[KeyCode::Esc, KeyCode::Char('k')]); assert_eq!(app.selected, 1); app.roam = true; - let footer = draw(&mut app).pop().expect("footer"); - assert!(footer.contains("jkl; move"), "{footer:?}"); + let screen = draw(&mut app).join("\n"); + assert!(screen.contains("jkl; move"), "{screen}"); } /// An unbound action leaves no bare label behind in the footer. @@ -1214,9 +1342,9 @@ fn rebound_movement_keys_move_and_the_footer_names_them() { fn an_unbound_action_drops_out_of_the_footer() { let mut app = app(Box::new(Discard)); app.keys = toml::from_str("quit = []\n").expect("keys"); - let footer = draw(&mut app).pop().expect("footer"); - assert!(footer.contains("S send+quit "), "{footer:?}"); - assert!(!footer.contains("quit · ") && !footer.contains(" · quit"), "{footer:?}"); + let screen = draw(&mut app).join("\n"); + assert!(screen.contains("S send+quit "), "{screen}"); + assert!(!screen.contains("quit · ") && !screen.contains(" · quit"), "{screen}"); } /// A ctrl binding whose letter is also a plain binding earlier in the context still runs.