feat: add gt and gb document navigation
This commit is contained in:
+185
-9
@@ -257,6 +257,8 @@ enum Action {
|
||||
JoinLines,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
DocumentStart,
|
||||
DocumentEnd,
|
||||
Delete,
|
||||
Undo,
|
||||
Redo,
|
||||
@@ -408,6 +410,16 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
Action::LineEnd,
|
||||
&[Mode::Normal, Mode::Insert, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.document_start",
|
||||
Action::DocumentStart,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.document_end",
|
||||
Action::DocumentEnd,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.word_next",
|
||||
Action::WordNext,
|
||||
@@ -1626,7 +1638,7 @@ impl Editor {
|
||||
(!self.leader_display.is_empty()).then_some(self.leader_display.as_str())
|
||||
}
|
||||
|
||||
/// Whether the built-in Normal `gf` prefix is awaiting its second key.
|
||||
/// Whether the built-in `g` navigation prefix is awaiting its second key.
|
||||
pub fn g_pending(&self) -> bool {
|
||||
self.g_pending
|
||||
}
|
||||
@@ -2043,15 +2055,28 @@ impl Editor {
|
||||
})
|
||||
.map(|(input, _)| display_key(input))
|
||||
.collect();
|
||||
if action == Some(Action::FollowLink)
|
||||
&& self.active.mode == Mode::Normal
|
||||
let native = match action {
|
||||
Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"),
|
||||
Some(Action::DocumentStart)
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual) =>
|
||||
{
|
||||
Some("gt")
|
||||
}
|
||||
Some(Action::DocumentEnd)
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual) =>
|
||||
{
|
||||
Some("gb")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(native) = native
|
||||
&& self.leader != Input::Char('g')
|
||||
&& !self
|
||||
.bindings
|
||||
.get(&Mode::Normal)
|
||||
.get(&self.active.mode)
|
||||
.is_some_and(|keys| keys.contains_key(&Input::Char('g')))
|
||||
{
|
||||
bindings.push("gf".into());
|
||||
bindings.push(native.into());
|
||||
}
|
||||
if !self
|
||||
.bindings
|
||||
@@ -2274,7 +2299,7 @@ impl Editor {
|
||||
input => input,
|
||||
};
|
||||
if std::mem::take(&mut self.g_pending) {
|
||||
if self.active.mode == Mode::Normal
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
|
||||
&& self.startup_diagnostic.is_none()
|
||||
&& self.hover().is_none()
|
||||
&& self.palette.is_none()
|
||||
@@ -2282,9 +2307,18 @@ impl Editor {
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
&& input == Input::Char('f')
|
||||
{
|
||||
return self.act(Action::FollowLink);
|
||||
let action = match input {
|
||||
Input::Char('f') if self.active.mode == Mode::Normal => {
|
||||
Some(Action::FollowLink)
|
||||
}
|
||||
Input::Char('t') => Some(Action::DocumentStart),
|
||||
Input::Char('b') => Some(Action::DocumentEnd),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
return self.act(action);
|
||||
}
|
||||
}
|
||||
self.count = None;
|
||||
if input == Input::Escape {
|
||||
@@ -2395,7 +2429,7 @@ impl Editor {
|
||||
append_key(&mut self.leader_display, &input);
|
||||
return None;
|
||||
}
|
||||
if self.active.mode == Mode::Normal && input == Input::Char('g') {
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') {
|
||||
self.count = None;
|
||||
self.g_pending = true;
|
||||
return None;
|
||||
@@ -2492,6 +2526,8 @@ impl Editor {
|
||||
| Action::Up
|
||||
| Action::LineStart
|
||||
| Action::LineEnd
|
||||
| Action::DocumentStart
|
||||
| Action::DocumentEnd
|
||||
| Action::WordNext
|
||||
| Action::WordPrevious
|
||||
| Action::WordEnd
|
||||
@@ -4418,6 +4454,14 @@ impl Editor {
|
||||
}
|
||||
Action::LineStart => cursor.column = 0,
|
||||
Action::LineEnd => cursor.column = self.max_column(cursor.row),
|
||||
Action::DocumentStart | Action::DocumentEnd => {
|
||||
cursor.row = if action == Action::DocumentStart {
|
||||
0
|
||||
} else {
|
||||
self.active.lines.len() - 1
|
||||
};
|
||||
cursor.column = first_nonblank(&self.active.lines[cursor.row]);
|
||||
}
|
||||
_ => cursor = self.word_destination(cursor, action, count),
|
||||
}
|
||||
self.active.targets[index].active = cursor;
|
||||
@@ -5674,6 +5718,138 @@ mod tests {
|
||||
assert_eq!(editor.lines(), ["fn main() {}"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_navigation_moves_all_carets_to_first_nonblank_without_edit_history() {
|
||||
let mut editor = editor();
|
||||
let text = " e\u{301}top\nmiddle\n\t 界bottom";
|
||||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||||
type_text(&mut editor, "j");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
type_text(&mut editor, "gt");
|
||||
assert_eq!(editor.carets(), vec![Cursor { row: 0, column: 2 }]);
|
||||
type_text(&mut editor, "gb");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 });
|
||||
assert_eq!(editor.context().text, text);
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "");
|
||||
|
||||
for (text, last_row) in [("", 0), ("\n", 1), ("top\n", 1)] {
|
||||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||||
type_text(&mut editor, "gb");
|
||||
assert_eq!(
|
||||
editor.cursor(),
|
||||
Cursor {
|
||||
row: last_row,
|
||||
column: 0
|
||||
}
|
||||
);
|
||||
type_text(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
assert_eq!(editor.context().text, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_navigation_extends_both_visual_shapes_and_escape_cancels_prefix_only() {
|
||||
for selector in ['v', 'V'] {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(" top\nmiddle\n\t end".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "jl");
|
||||
editor.handle(Input::Char(selector));
|
||||
type_text(&mut editor, "gb");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 });
|
||||
assert_eq!(editor.visual_linewise(), selector == 'V');
|
||||
assert_eq!(
|
||||
editor.selections(),
|
||||
vec![if selector == 'V' {
|
||||
Selection {
|
||||
start: Cursor { row: 1, column: 0 },
|
||||
end: Cursor { row: 2, column: 5 },
|
||||
}
|
||||
} else {
|
||||
Selection {
|
||||
start: Cursor { row: 1, column: 1 },
|
||||
end: Cursor { row: 2, column: 3 },
|
||||
}
|
||||
}]
|
||||
);
|
||||
let selected = editor.selections();
|
||||
type_text(&mut editor, "g");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selected);
|
||||
type_text(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
|
||||
assert_eq!(
|
||||
editor.selections(),
|
||||
vec![if selector == 'V' {
|
||||
Selection {
|
||||
start: Cursor { row: 0, column: 0 },
|
||||
end: Cursor { row: 2, column: 0 },
|
||||
}
|
||||
} else {
|
||||
Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 1, column: 2 },
|
||||
}
|
||||
}]
|
||||
);
|
||||
assert_eq!(editor.context().text, " top\nmiddle\n\t end");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_navigation_prefix_respects_bindings_and_literal_insert_input() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("top\nmiddle\nbottom".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "jv");
|
||||
editor
|
||||
.bind("visual", "g", Some("core.right".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "gb");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
editor.handle(Input::Escape);
|
||||
editor.bind("normal", "g", None).unwrap();
|
||||
type_text(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor().row, 1);
|
||||
type_text(&mut editor, "igtgb");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "top\ngtgbmiddle\nbottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_navigation_actions_remap_and_yield_to_g_as_leader() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("top\nmiddle\nbottom".into())])
|
||||
.unwrap();
|
||||
editor.set_leader("g").unwrap();
|
||||
editor
|
||||
.bind("normal", "leader t", Some("core.document_end".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
editor
|
||||
.bind("normal", "h", Some("core.document_start".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "h");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
editor
|
||||
.bind("normal", "g", Some("core.document_end".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
type_text(&mut editor, "igtgb");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "top\nmiddle\ngtgbbottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() {
|
||||
let mut editor = editor();
|
||||
|
||||
Reference in New Issue
Block a user