feat: add counted motions in Normal and Visual modes
This commit is contained in:
+293
-76
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user