diff --git a/README.md b/README.md index d4f139f..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; `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` `$`, 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) | @@ -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..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` / `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; `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/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..5550064 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 { @@ -239,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. @@ -261,7 +267,11 @@ impl App { return Ok(()); } } - match self.keys.action(&key, keys::DOCUMENT) { + 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() { self.clear_selection(); @@ -269,15 +279,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 +296,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,18 +316,54 @@ impl App { Ok(()) } - /// Keys while a keyboard selection is being extended. - fn visual_key(&mut self, key: KeyEvent) { + 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("/"); + self.status = Some(format!( + "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. 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) => self.finish_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); + } + } + _ if self.keys.action_without_ctrl_leak(&key, keys::TOOLBAR).is_none() => {} _ => { - self.motion_key(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); + } } } - 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. @@ -338,10 +372,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 +393,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,14 +435,27 @@ 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 = 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()) + }; + 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 }; - 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; @@ -418,9 +467,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 +568,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) { @@ -610,6 +667,7 @@ impl App { sel.dragging = false; if sel.is_empty() { self.selection = None; + self.status = None; return; } let sel = *sel; @@ -625,6 +683,7 @@ impl App { } let Some((lo, hi)) = bounds else { self.selection = None; + self.status = None; return; }; let source = &self.open.doc.source; @@ -640,6 +699,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..3a93610 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. @@ -480,14 +483,34 @@ 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(()) } + /// 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..36b2d6c 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -600,6 +600,335 @@ 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 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(); + 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 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, '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 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(); + 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(); + 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(); + 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..af62a05 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. @@ -339,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)) } @@ -399,6 +417,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 +448,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();