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/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 a2c4002..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); @@ -493,6 +538,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 +587,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"), @@ -552,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/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..51adb53 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 }; @@ -557,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/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/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 7dd4743..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); @@ -600,6 +601,462 @@ 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 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(); + 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)); @@ -876,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. @@ -885,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. 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();