feat: add counted motions in Normal and Visual modes

This commit is contained in:
Valdemar Lorenzen
2026-10-08 12:19:38 +02:00
parent bd78656691
commit 38006c1eca
4 changed files with 335 additions and 78 deletions
+9 -1
View File
@@ -22,6 +22,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
@@ -41,6 +42,12 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight.
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches.
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
@@ -86,6 +93,7 @@ One crate, four modules, no workspace or frontend-specific types in the editing
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
@@ -188,7 +196,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
## Current limits
- One text buffer; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim counts/operators, splits, regex/multiline search, LSP, or graphical frontend.
- One text buffer; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim operators, splits, regex/multiline search, LSP, or graphical frontend.
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
+293 -76
View File
@@ -609,6 +609,7 @@ pub struct Editor {
features: HashMap<String, Feature>,
bindings: HashMap<Mode, HashMap<Input, Binding>>,
palette: Option<Palette>,
count: Option<usize>,
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
@@ -644,6 +645,7 @@ impl Editor {
features: HashMap::new(),
bindings: default_bindings(),
palette: None,
count: None,
undo: Vec::new(),
redo: Vec::new(),
insert_start: None,
@@ -678,6 +680,10 @@ impl Editor {
pub fn palette(&self) -> Option<&Palette> {
self.palette.as_ref()
}
/// Pending decimal prefix for the next Normal/Visual motion.
pub fn count(&self) -> Option<usize> {
self.count
}
/// Editable Ex/search prompt, without its `:` or `/` prefix.
pub fn command_line(&self) -> &str {
&self.command_line
@@ -958,6 +964,7 @@ impl Editor {
}
// Paste is literal input, never a configurable key or command.
if let Input::Paste(text) = input {
self.count = None;
if self.mode == Mode::Insert {
self.paste(&text);
}
@@ -968,6 +975,30 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if input == Input::Escape && self.count.take().is_some() {
return None;
}
if matches!(self.mode, Mode::Normal | Mode::Visual)
&& let Input::Char(ch @ '0'..='9') = input
{
let binding = self
.bindings
.get(&self.mode)
.and_then(|keys| keys.get(&input));
let extends_count = ch == '0'
&& self.count.is_some()
&& matches!(binding, Some(Binding::Action(Action::LineStart)));
if (binding.is_none() || extends_count) && (ch != '0' || self.count.is_some()) {
let digit = usize::from(ch as u8 - b'0');
self.count = Some(
self.count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(digit),
);
return None;
}
}
match self
.bindings
.get(&self.mode)
@@ -978,9 +1009,13 @@ impl Editor {
let command = command.clone();
return self.execute(&command);
}
Some(Binding::Disabled) => return None,
Some(Binding::Disabled) => {
self.count = None;
return None;
}
None => {}
}
self.count = None;
match (self.mode, input) {
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
let mut bytes = [0; 4];
@@ -997,6 +1032,7 @@ impl Editor {
}
fn act(&mut self, action: Action) -> Option<Invocation> {
let count = self.count.take().unwrap_or(1);
match action {
Action::Palette => {
self.palette = Some(Palette {
@@ -1049,7 +1085,7 @@ impl Editor {
| Action::WordEnd
| Action::BigWordNext
| Action::BigWordPrevious
| Action::BigWordEnd => self.move_targets(action),
| Action::BigWordEnd => self.move_targets(action, count),
Action::JoinLines => self.join_lines(),
Action::Delete => {
let before = (self.mode != Mode::Insert).then(|| self.snapshot());
@@ -1068,8 +1104,8 @@ impl Editor {
};
self.command_line.clear();
}
Action::SearchNext => self.seek_search(true),
Action::SearchPrevious => self.seek_search(false),
Action::SearchNext => self.seek_search(true, count),
Action::SearchPrevious => self.seek_search(false, count),
Action::Select => {
if self.mode == Mode::Visual {
self.clear_selection();
@@ -1124,7 +1160,7 @@ impl Editor {
if !command.is_empty() {
self.search_query = command;
}
self.seek_search(true);
self.seek_search(true, 1);
} else {
return self.execute(&command);
}
@@ -1133,7 +1169,7 @@ impl Editor {
None
}
fn seek_search(&mut self, forward: bool) {
fn seek_search(&mut self, forward: bool, count: usize) {
self.search_match = None;
if self.search_query.is_empty() {
self.set_message("No previous search");
@@ -1164,12 +1200,21 @@ impl Editor {
target.active.row,
grapheme_byte(&self.lines[target.active.row], target.active.column),
);
let found = if forward {
select_search_match(matches.iter().copied(), origin, true)
let first = if forward {
matches.partition_point(|position| *position <= origin) % matches.len()
} else {
select_search_match(matches.iter().copied().rev(), origin, false)
}
.expect("nonempty matches");
matches
.partition_point(|position| *position < origin)
.checked_sub(1)
.unwrap_or(matches.len() - 1)
};
let distance = (count - 1) % matches.len();
let destination = if forward {
(first + distance) % matches.len()
} else {
(first + matches.len() - distance) % matches.len()
};
let found = matches[destination];
target.active = Cursor {
row: found.0,
column: column_after_byte(&self.lines[found.0], found.1),
@@ -1187,6 +1232,7 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.count = None;
self.palette = None;
if self.mode == Mode::Insert {
self.finish_insert();
@@ -1283,6 +1329,7 @@ impl Editor {
})
.then(|| self.snapshot());
if before.is_some() {
self.count = None;
self.clear_selection();
self.collapse_primary();
// Plugin inserts use one primary insertion point, not the selection set.
@@ -1805,32 +1852,42 @@ impl Editor {
}
}
fn move_targets(&mut self, action: Action) {
fn move_targets(&mut self, action: Action, count: usize) {
for index in 0..self.targets.len() {
let mut cursor = self.targets[index].active;
match action {
Action::Left => cursor.column = cursor.column.saturating_sub(1),
Action::Left => cursor.column = cursor.column.saturating_sub(count),
Action::Right => {
cursor.column = (cursor.column + 1).min(self.max_column(cursor.row))
cursor.column = cursor
.column
.saturating_add(count)
.min(self.max_column(cursor.row))
}
Action::Down => {
cursor.row = (cursor.row + 1).min(self.lines.len() - 1);
cursor.column = cursor.column.min(self.max_column(cursor.row));
}
Action::Up => {
cursor.row = cursor.row.saturating_sub(1);
cursor.column = cursor.column.min(self.max_column(cursor.row));
Action::Down | Action::Up => {
let row = if matches!(action, Action::Down) {
cursor.row.saturating_add(count).min(self.lines.len() - 1)
} else {
cursor.row.saturating_sub(count)
};
let first = cursor.row.min(row);
let last = cursor.row.max(row);
cursor.column = (first..=last)
.map(|row| self.max_column(row))
.min()
.unwrap_or(0)
.min(cursor.column);
cursor.row = row;
}
Action::LineStart => cursor.column = 0,
Action::LineEnd => cursor.column = self.max_column(cursor.row),
_ => cursor = self.word_destination(cursor, action),
_ => cursor = self.word_destination(cursor, action, count),
}
self.targets[index].active = cursor;
self.targets[index].preferred_columns = None;
}
self.normalize_targets();
}
fn word_destination(&self, cursor: Cursor, action: Action) -> Cursor {
fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor {
let row = cursor.row;
let byte = grapheme_byte(&self.lines[row], cursor.column);
let big = matches!(
@@ -1841,51 +1898,57 @@ impl Editor {
Action::WordPrevious | Action::BigWordPrevious => {
let mut tokens = word_tokens(&self.lines[..=row], big)
.rev()
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte));
tokens
.find(|(_, _, kind)| *kind != WordKind::Whitespace)
.map(|first| {
if first.2 == WordKind::EmptyLine {
first
} else {
tokens
.take_while(|(_, _, kind)| *kind == first.2)
.last()
.unwrap_or(first)
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
.peekable();
let mut destination = (0, 0);
for _ in 0..count {
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
else {
destination = (0, 0);
break;
};
if first.2 != WordKind::EmptyLine {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == first.2) {
first = tokens.next().expect("peeked token");
}
})
.map(|(r, b, _)| (r, b))
.unwrap_or((0, 0))
}
destination = (first.0, first.1);
}
destination
}
_ => {
let mut tokens = word_tokens(&self.lines[row..], big)
.skip_while(|(r, b, _)| (*r, *b) < (0, byte));
let current = tokens
.skip_while(|(r, b, _)| (*r, *b) < (0, byte))
.peekable();
let mut current = tokens
.next()
.expect("cursor has a grapheme or line-end token")
.2;
let found = match action {
Action::WordNext | Action::BigWordNext => tokens
.skip_while(|(_, _, kind)| {
*kind == current && current != WordKind::EmptyLine
})
.find(|(_, _, kind)| *kind != WordKind::Whitespace),
Action::WordEnd | Action::BigWordEnd => tokens
.find(|(_, _, kind)| {
!matches!(kind, WordKind::Whitespace | WordKind::EmptyLine)
})
.map(|first| {
tokens
.take_while(|(_, _, kind)| *kind == first.2)
.last()
.unwrap_or(first)
}),
_ => unreachable!("word motion actions only"),
};
found.map(|(r, b, _)| (row + r, b)).unwrap_or_else(|| {
let last = self.lines.len() - 1;
(last, self.lines[last].len())
})
let last = self.lines.len() - 1;
let mut destination = (last, self.lines[last].len());
for _ in 0..count {
let next_start = matches!(action, Action::WordNext | Action::BigWordNext);
if next_start && current != WordKind::EmptyLine {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
tokens.next();
}
}
let Some(mut first) = tokens.find(|(_, _, kind)| {
*kind != WordKind::Whitespace
&& (next_start || *kind != WordKind::EmptyLine)
}) else {
destination = (last, self.lines[last].len());
break;
};
current = first.2;
if !next_start {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
first = tokens.next().expect("peeked token");
}
}
destination = (row + first.0, first.1);
}
destination
}
};
let line = &self.lines[destination.0];
@@ -2205,21 +2268,6 @@ fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: boo
Cursor { row, column }
}
fn select_search_match(
matches: impl Iterator<Item = (usize, usize)>,
origin: (usize, usize),
forward: bool,
) -> Option<(usize, usize)> {
let mut wrapped = None;
for position in matches {
wrapped.get_or_insert(position);
if (forward && position > origin) || (!forward && position < origin) {
return Some(position);
}
}
wrapped
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum WordKind {
Whitespace,
@@ -2409,6 +2457,175 @@ mod tests {
});
}
}
#[test]
fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(),
)])
.unwrap();
set_carets(&mut editor, &[(0, 3), (4, 1)], 1);
let undo = editor.undo.len();
type_text(&mut editor, "2j");
assert_eq!(
editor.carets(),
vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }]
);
assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 });
type_text(&mut editor, "100k");
assert_eq!(editor.carets(), vec![Cursor::default()]);
type_text(&mut editor, "2l");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
type_text(&mut editor, "9h");
assert_eq!(editor.cursor(), Cursor::default());
type_text(&mut editor, "999999999999999999999999999999999999");
assert_eq!(editor.count(), Some(usize::MAX));
editor.handle(Input::Down);
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
assert_eq!(editor.count(), None);
assert_eq!(editor.undo.len(), undo);
assert_eq!(
editor.lines(),
&["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"]
);
}
#[test]
fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() {
let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n";
for key in ['w', 'b', 'e', 'W', 'B', 'E'] {
for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] {
let mut counted = editor();
counted.apply(vec![Effect::Replace(text.into())]).unwrap();
set_carets(&mut counted, positions, 1);
let mut repeated = editor();
repeated.apply(vec![Effect::Replace(text.into())]).unwrap();
set_carets(&mut repeated, positions, 1);
type_text(&mut counted, &format!("3{key}"));
for _ in 0..3 {
repeated.handle(Input::Char(key));
}
assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}");
assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}");
}
}
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"one two three\none longer third".into(),
)])
.unwrap();
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Char('v'));
type_text(&mut editor, "2e");
assert_eq!(
editor.selections(),
vec![
Selection {
start: Cursor { row: 0, column: 0 },
end: Cursor { row: 0, column: 7 }
},
Selection {
start: Cursor { row: 1, column: 0 },
end: Cursor { row: 1, column: 10 }
},
]
);
editor.handle(Input::Char('c'));
type_text(&mut editor, "item");
editor.handle(Input::Escape);
assert_eq!(editor.lines(), &["item three", "item third"]);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), &["one two three", "one longer third"]);
assert_eq!(editor.selection_count(), 2);
type_text(&mut editor, &format!("{}w", usize::MAX));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 });
}
#[test]
fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(["abc"; 12].join("\n"))])
.unwrap();
type_text(&mut editor, "10j");
assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 });
editor.handle(Input::End);
type_text(&mut editor, "0");
assert_eq!(editor.cursor().column, 0);
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Char('v'));
let selected = editor.selections();
type_text(&mut editor, "4");
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selections(), selected);
assert_eq!(editor.count(), None);
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection_count(), 2);
type_text(&mut editor, "3g");
assert_eq!(editor.count(), None);
editor.bind("normal", "2", None).unwrap();
type_text(&mut editor, "42");
assert_eq!(editor.count(), None);
editor
.bind("normal", "3", Some("core.down".into()))
.unwrap();
type_text(&mut editor, "3");
assert_eq!(editor.cursor().row, 10);
assert_eq!(editor.count(), None);
type_text(&mut editor, "5i12");
editor.handle(Input::Escape);
assert_eq!(editor.lines()[10], "12abc");
editor.handle(Input::Char('u'));
assert_eq!(editor.lines()[10], "abc");
editor.handle(Input::Char('/'));
type_text(&mut editor, "123");
assert_eq!(editor.command_line(), "123");
assert_eq!(editor.count(), None);
}
#[test]
fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())])
.unwrap();
set_carets(&mut editor, &[(0, 0), (1, 0)], 1);
submit_search(&mut editor, "cat");
type_text(&mut editor, "2n");
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }]
);
type_text(&mut editor, "2N");
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }]
);
editor.register(plugin("once", &["once"])).unwrap();
editor.bind("normal", "g", Some("once".into())).unwrap();
type_text(&mut editor, "8");
let invocation = editor.handle(Input::Char('g')).unwrap();
assert_eq!(invocation.command, "once");
assert_eq!(editor.count(), None);
type_text(&mut editor, "7");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.word_next");
editor.handle(Input::Enter);
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }]
);
assert_eq!(editor.count(), None);
type_text(&mut editor, "9");
editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Char('h'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 });
}
fn submit_search(editor: &mut Editor, query: &str) {
editor.handle(Input::Char('/'));
type_text(editor, query);
+1
View File
@@ -11,6 +11,7 @@ All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept,
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
Esc keeps only the primary caret
+32 -1
View File
@@ -326,7 +326,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
.map(|c| if c.is_control() { '�' } else { c })
.collect();
let status = format!(
" {} {}{} {}:{}{}",
" {} {}{} {}:{}{}{}",
editor.mode(),
path,
if editor.dirty() { " [+]" } else { "" },
@@ -336,6 +336,11 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
format!(" {} selections", editor.selection_count())
} else {
String::new()
},
if let Some(count) = editor.count() {
format!(" count: {count}")
} else {
String::new()
}
);
frame.render_widget(
@@ -488,6 +493,32 @@ mod tests {
.join("\n")
}
#[test]
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))])
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap();
let (mut top, mut left) = (0, 0);
editor.handle(Input::Char('1'));
editor.handle(Input::Char('0'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(screen_text(&terminal).contains("count: 10"));
editor.handle(Input::Escape);
editor.handle(Input::Down);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(!screen_text(&terminal).contains("count:"));
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(3, 1)
);
}
#[test]
fn gutter_tracks_scrolled_rows_without_scrolling_with_text() {
let mut editor = Editor::open(None, vec![]).unwrap();