feat: add gt and gb document navigation

This commit is contained in:
2026-10-10 17:56:39 +02:00
parent 287865735f
commit 9d3b25e9d3
6 changed files with 194 additions and 12 deletions
+185 -9
View File
@@ -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();