feat: add counted forward and backward character seek motions
This commit is contained in:
+427
-11
@@ -262,12 +262,26 @@ enum EditStart {
|
||||
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
enum SelectionIntent {
|
||||
Motions(Vec<(Action, usize)>),
|
||||
Motions {
|
||||
motions: Vec<Motion>,
|
||||
linewise: bool,
|
||||
},
|
||||
// Whole graphemes, with a newline counting as one boundary.
|
||||
Spans(Vec<usize>),
|
||||
Rows(Vec<isize>),
|
||||
}
|
||||
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
#[derive(Clone, Copy)]
|
||||
enum Motion {
|
||||
Action(Action, usize),
|
||||
Seek {
|
||||
forward: bool,
|
||||
target: char,
|
||||
count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
enum InsertStep {
|
||||
Typed(String),
|
||||
@@ -442,6 +456,16 @@ core_actions![
|
||||
Action::DocumentEnd,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.seek_forward",
|
||||
Action::SeekForward,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.seek_backward",
|
||||
Action::SeekBackward,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.word_next",
|
||||
Action::WordNext,
|
||||
@@ -842,6 +866,8 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
('W', BigWordNext),
|
||||
('B', BigWordPrevious),
|
||||
('E', BigWordEnd),
|
||||
('f', SeekForward),
|
||||
('F', SeekBackward),
|
||||
('n', SearchNext),
|
||||
('N', SearchPrevious),
|
||||
('v', Select),
|
||||
@@ -1007,7 +1033,7 @@ struct Buffer {
|
||||
redo: Vec<Snapshot>,
|
||||
insert_start: Option<Snapshot>,
|
||||
pending_edit: Option<EditRecipe>,
|
||||
visual_motions: Option<Vec<(Action, usize)>>,
|
||||
visual_motions: Option<Vec<Motion>>,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
@@ -1125,6 +1151,7 @@ pub struct Editor {
|
||||
leader_display: String,
|
||||
g_pending: bool,
|
||||
line_pending: Option<char>,
|
||||
seek_pending: Option<(bool, usize)>,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
completion: Option<CompletionState>,
|
||||
@@ -1199,6 +1226,7 @@ impl Editor {
|
||||
leader_display: String::new(),
|
||||
g_pending: false,
|
||||
line_pending: None,
|
||||
seek_pending: None,
|
||||
palette: None,
|
||||
hover: None,
|
||||
completion: None,
|
||||
@@ -1732,6 +1760,7 @@ impl Editor {
|
||||
|| self.count.is_some()
|
||||
|| self.g_pending
|
||||
|| self.line_pending.is_some()
|
||||
|| self.seek_pending.is_some()
|
||||
{
|
||||
return self.handle_input(input);
|
||||
}
|
||||
@@ -2801,15 +2830,26 @@ impl Editor {
|
||||
self.line_pending
|
||||
}
|
||||
|
||||
/// Native character seek awaiting a literal target.
|
||||
pub fn seek_pending(&self) -> Option<char> {
|
||||
self.seek_pending
|
||||
.map(|(forward, _)| if forward { 'f' } else { 'F' })
|
||||
}
|
||||
|
||||
fn clear_leader(&mut self) {
|
||||
self.g_pending = false;
|
||||
self.line_pending = None;
|
||||
self.seek_pending = None;
|
||||
self.leader_suffix.clear();
|
||||
self.leader_display.clear();
|
||||
}
|
||||
|
||||
fn cancel_leader(&mut self) {
|
||||
if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() {
|
||||
if self.leader_pending().is_some()
|
||||
|| self.g_pending
|
||||
|| self.line_pending.is_some()
|
||||
|| self.seek_pending.is_some()
|
||||
{
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
}
|
||||
@@ -3004,6 +3044,9 @@ impl Editor {
|
||||
Some("core.right") if mode == "tree" => Binding::Action(Action::Right),
|
||||
Some(target) => return Err(format!("Unknown {mode} action: {target}")),
|
||||
};
|
||||
if self.seek_pending.is_some() {
|
||||
self.cancel_leader();
|
||||
}
|
||||
if mode == "tree" {
|
||||
self.tree_bindings.insert(input, binding);
|
||||
} else {
|
||||
@@ -3560,6 +3603,34 @@ impl Editor {
|
||||
_ => self.finish_completion(),
|
||||
}
|
||||
}
|
||||
if let Some((forward, count)) = self.seek_pending.take() {
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
|
||||
&& self.startup_diagnostic.is_none()
|
||||
&& self.hover().is_none()
|
||||
&& self.palette.is_none()
|
||||
&& !self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
&& let Input::Char(target) = input
|
||||
&& (!target.is_control() || target == '\t')
|
||||
{
|
||||
let motion = Motion::Seek {
|
||||
forward,
|
||||
target,
|
||||
count,
|
||||
};
|
||||
if let Some(motions) = &mut self.active.visual_motions {
|
||||
motions.push(motion);
|
||||
}
|
||||
self.move_motion(motion);
|
||||
return None;
|
||||
}
|
||||
self.count = None;
|
||||
if input == Input::Escape {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if std::mem::take(&mut self.g_pending) {
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
|
||||
&& self.startup_diagnostic.is_none()
|
||||
@@ -3861,6 +3932,9 @@ impl Editor {
|
||||
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
||||
Action::JumpBack => self.traverse_jumps(false, count),
|
||||
Action::JumpForward => self.traverse_jumps(true, count),
|
||||
Action::SeekForward | Action::SeekBackward => {
|
||||
self.seek_pending = Some((action == Action::SeekForward, count));
|
||||
}
|
||||
Action::DeleteLine | Action::ChangeLine => {
|
||||
self.edit_lines(matches!(action, Action::ChangeLine), count);
|
||||
}
|
||||
@@ -3902,7 +3976,7 @@ impl Editor {
|
||||
| Action::BigWordEnd => {
|
||||
if self.active.mode == Mode::Visual {
|
||||
if let Some(motions) = &mut self.active.visual_motions {
|
||||
motions.push((action, count));
|
||||
motions.push(Motion::Action(action, count));
|
||||
}
|
||||
} else if self.active.mode == Mode::Insert {
|
||||
self.push_insert_step(InsertStep::Move(action, count));
|
||||
@@ -5693,8 +5767,13 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent {
|
||||
if let Some(motions) = self.active.visual_motions.take() {
|
||||
return SelectionIntent::Motions {
|
||||
motions,
|
||||
linewise: self.visual_linewise(),
|
||||
};
|
||||
}
|
||||
if self.visual_linewise() {
|
||||
self.active.visual_motions = None;
|
||||
let mut rows: Vec<_> = self
|
||||
.active
|
||||
.targets
|
||||
@@ -5713,9 +5792,6 @@ impl Editor {
|
||||
}
|
||||
return SelectionIntent::Rows(rows);
|
||||
}
|
||||
if let Some(motions) = self.active.visual_motions.take() {
|
||||
return SelectionIntent::Motions(motions);
|
||||
}
|
||||
let mut spans: Vec<_> = ranges
|
||||
.iter()
|
||||
.map(|range| {
|
||||
@@ -5951,12 +6027,13 @@ impl Editor {
|
||||
self.normalize_targets();
|
||||
self.selections()
|
||||
}
|
||||
SelectionIntent::Motions(motions) => {
|
||||
SelectionIntent::Motions { motions, linewise } => {
|
||||
self.active.linewise = *linewise;
|
||||
for target in &mut self.active.targets {
|
||||
target.anchor = target.active;
|
||||
}
|
||||
for (action, count) in motions {
|
||||
self.move_targets(*action, *count);
|
||||
for motion in motions {
|
||||
self.move_motion(*motion);
|
||||
}
|
||||
self.normalize_targets();
|
||||
self.selections()
|
||||
@@ -6068,6 +6145,32 @@ impl Editor {
|
||||
)
|
||||
}
|
||||
|
||||
fn move_motion(&mut self, motion: Motion) {
|
||||
match motion {
|
||||
Motion::Action(action, count) => self.move_targets(action, count),
|
||||
Motion::Seek {
|
||||
forward,
|
||||
target,
|
||||
count,
|
||||
} => {
|
||||
self.reveal_caret();
|
||||
for caret in &mut self.active.targets {
|
||||
if let Some(column) = seek_column(
|
||||
&self.active.lines[caret.active.row],
|
||||
caret.active.column,
|
||||
forward,
|
||||
target,
|
||||
count,
|
||||
) {
|
||||
caret.active.column = column;
|
||||
caret.preferred_columns = None;
|
||||
}
|
||||
}
|
||||
self.normalize_targets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_targets(&mut self, action: Action, count: usize) {
|
||||
self.reveal_caret();
|
||||
for index in 0..self.active.targets.len() {
|
||||
@@ -7006,6 +7109,40 @@ fn split_lines(text: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn seek_column(
|
||||
line: &str,
|
||||
column: usize,
|
||||
forward: bool,
|
||||
target: char,
|
||||
count: usize,
|
||||
) -> Option<usize> {
|
||||
let occurrence = count.saturating_sub(1);
|
||||
if forward {
|
||||
line.graphemes(true)
|
||||
.enumerate()
|
||||
.skip(column.saturating_add(1))
|
||||
.flat_map(|(column, grapheme)| {
|
||||
grapheme
|
||||
.chars()
|
||||
.filter(move |ch| *ch == target)
|
||||
.map(move |_| column)
|
||||
})
|
||||
.nth(occurrence)
|
||||
} else {
|
||||
line[..grapheme_byte(line, column)]
|
||||
.graphemes(true)
|
||||
.rev()
|
||||
.enumerate()
|
||||
.flat_map(|(offset, grapheme)| {
|
||||
grapheme
|
||||
.chars()
|
||||
.filter(move |ch| *ch == target)
|
||||
.map(move |_| column - offset - 1)
|
||||
})
|
||||
.nth(occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
fn grapheme_count(line: &str) -> usize {
|
||||
if line.is_ascii() {
|
||||
// CRLF is the only multi-byte ASCII grapheme.
|
||||
@@ -12156,6 +12293,285 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())])
|
||||
.unwrap();
|
||||
let undo = editor.active.undo.len();
|
||||
for (column, keys, expected) in [
|
||||
(0, "fx", 2),
|
||||
(0, "2fx", 5),
|
||||
(0, "3fx", 0),
|
||||
(5, "Fx", 2),
|
||||
(5, "2Fx", 0),
|
||||
(5, "3Fx", 5),
|
||||
(2, "fz", 2),
|
||||
(0, "999999999999999999999999fx", 0),
|
||||
] {
|
||||
set_carets(&mut editor, &[(0, column)], 0);
|
||||
type_text(&mut editor, keys);
|
||||
assert_eq!(
|
||||
editor.cursor(),
|
||||
Cursor {
|
||||
row: 0,
|
||||
column: expected
|
||||
}
|
||||
);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
}
|
||||
for row in [1, 2, 3] {
|
||||
set_carets(&mut editor, &[(row, 0)], 0);
|
||||
type_text(&mut editor, "fxFx");
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 0 });
|
||||
}
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() {
|
||||
for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(format!("a{target}b{target}"))])
|
||||
.unwrap();
|
||||
let key = display_key(&Input::Char(target));
|
||||
editor
|
||||
.bind("normal", &key, Some("core.right".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("normal", "z", Some("core.seek_forward".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "2z");
|
||||
editor.handle(Input::Char(target));
|
||||
assert_eq!(editor.cursor().column, 3);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert!(!editor.g_pending());
|
||||
assert!(editor.leader_pending().is_none());
|
||||
assert!(editor.seek_pending().is_none());
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor.apply(vec![Effect::Replace("axbx".into())]).unwrap();
|
||||
editor
|
||||
.bind("normal", "f", Some("core.right".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "f");
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
assert!(editor.seek_pending().is_none());
|
||||
editor
|
||||
.bind("normal", "leader f", Some("core.seek_forward".into()))
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
type_text(&mut editor, "2 fx");
|
||||
assert_eq!(editor.cursor().column, 3);
|
||||
for mode in ["insert", "command", "search", "selection", "tree"] {
|
||||
assert!(
|
||||
editor
|
||||
.bind(mode, "z", Some("core.seek_forward".into()))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
editor
|
||||
.bind(mode, "z", Some("core.seek_backward".into()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
editor.apply(vec![Effect::Replace(String::new())]).unwrap();
|
||||
type_text(&mut editor, "ifFgf");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "fFgf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
"界e\u{301}👩🚀e\u{301}x\n\naxax".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "f\u{301}");
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
type_text(&mut editor, "f🚀");
|
||||
assert_eq!(editor.cursor().column, 2);
|
||||
type_text(&mut editor, "F\u{301}");
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
type_text(&mut editor, "F\u{301}");
|
||||
assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded.
|
||||
for selector in ["", "v", "V"] {
|
||||
set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2);
|
||||
type_text(&mut editor, selector);
|
||||
type_text(&mut editor, "2fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
vec![
|
||||
Cursor { row: 0, column: 0 },
|
||||
Cursor { row: 1, column: 0 },
|
||||
Cursor { row: 2, column: 3 },
|
||||
]
|
||||
);
|
||||
assert_eq!(editor.visual_linewise(), selector == "V");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("axbx\nnext".into())])
|
||||
.unwrap();
|
||||
for selector in ["", "v", "V"] {
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
type_text(&mut editor, selector);
|
||||
let mode = editor.mode();
|
||||
let targets = editor.active.targets.clone();
|
||||
type_text(&mut editor, "2f");
|
||||
assert_eq!(editor.seek_pending(), Some('f'));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), mode);
|
||||
assert_eq!(editor.active.targets, targets);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
}
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
type_text(&mut editor, "2f");
|
||||
editor.handle(Input::Right);
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
type_text(&mut editor, "2f");
|
||||
editor.handle(Input::Paste("x".into()));
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
for command in ["plugins", "bnext", "bprev", "buffer 1"] {
|
||||
type_text(&mut editor, "f");
|
||||
editor.execute(command);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
}
|
||||
type_text(&mut editor, "f");
|
||||
editor
|
||||
.bind("normal", "z", Some("core.right".into()))
|
||||
.unwrap();
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
type_text(&mut editor, "f");
|
||||
editor
|
||||
.bind("selection", "z", Some("core.down".into()))
|
||||
.unwrap();
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
type_text(&mut editor, "f");
|
||||
editor.set_leader(",").unwrap();
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
type_text(&mut editor, "f");
|
||||
editor
|
||||
.navigate_to(
|
||||
directory.path("missing"),
|
||||
TextPosition { row: 0, column: 0 },
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
type_text(&mut editor, "f");
|
||||
editor.execute("palette");
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "f");
|
||||
editor
|
||||
.apply(vec![Effect::Replace("replacement".into())])
|
||||
.unwrap();
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() {
|
||||
for (seek, original, replacement, column) in [
|
||||
("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0),
|
||||
("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11),
|
||||
] {
|
||||
for change in [false, true] {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(original.into())])
|
||||
.unwrap();
|
||||
let start = if seek == "2Fx" { 3 } else { 0 };
|
||||
set_carets(&mut editor, &[(0, start)], 0);
|
||||
type_text(
|
||||
&mut editor,
|
||||
&format!("v{seek}{}", if change { "c!" } else { "d" }),
|
||||
);
|
||||
if change {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
editor
|
||||
.bind("visual", "f", Some("core.left".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("visual", "F", Some("core.right".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("visual", "x", Some("core.line_end".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(replacement.into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, column)], 0);
|
||||
editor.handle(Input::Char('.'));
|
||||
assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.seek_pending(), None);
|
||||
}
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("ax\nkeep".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "Vfxd");
|
||||
editor
|
||||
.apply(vec![Effect::Replace("alongx\nkeep".into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
editor.handle(Input::Char('.'));
|
||||
assert_eq!(editor.context().text, "keep");
|
||||
assert!(matches!(
|
||||
&editor.last_edit.as_ref().unwrap().start,
|
||||
EditStart::Selection {
|
||||
selector: SelectionIntent::Motions { linewise: true, .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["a[b]\tc".into()],
|
||||
vec!["a[b]\td".into()],
|
||||
);
|
||||
for focus in [Focus::Before, Focus::After] {
|
||||
if editor.diff().unwrap().focus != focus {
|
||||
editor.handle(Input::Char('\t'));
|
||||
}
|
||||
let text = editor.context().text;
|
||||
let rows = editor.diff().unwrap().display_rows.clone();
|
||||
type_text(&mut editor, "f[");
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
type_text(&mut editor, "f]");
|
||||
assert_eq!(editor.cursor().column, 3);
|
||||
type_text(&mut editor, "f\t");
|
||||
assert_eq!(editor.cursor().column, 4);
|
||||
type_text(&mut editor, "F[");
|
||||
assert_eq!(editor.cursor().column, 1);
|
||||
assert_eq!(editor.diff().unwrap().focus, focus);
|
||||
assert_eq!(editor.diff().unwrap().display_rows, rows);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert!(editor.active.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
||||
editor.active.mode = Mode::Normal;
|
||||
editor.active.visual_motions = None;
|
||||
|
||||
Reference in New Issue
Block a user