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
+1
View File
@@ -20,6 +20,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Task | Default keys | | Task | Default keys |
| --- | --- | | --- | --- |
| Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words | | Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words |
| First/last line in Normal or Visual | `gt` / `gb`, at first nonblank; Visual extends the selection |
| Enter/leave Insert | `i` / Esc | | Enter/leave Insert | `i` / Esc |
| Save in Normal | Space then `w`, or `:w` then Enter | | Save in Normal | Space then `w`, or `:w` then Enter |
| Undo/redo in Normal | `u` / `U` (Shift+u) | | Undo/redo in Normal | `u` / `U` (Shift+u) |
+1 -1
View File
@@ -29,7 +29,7 @@ Frontends inspect `palette()` for the optional read-only query, filtered command
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy. Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned. Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
Frontends inspect `g_pending()` for the built-in Normal `gf` prefix. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned. Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply. Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
+2 -1
View File
@@ -57,7 +57,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab remains literal text. Override or disable these keys with the ordinary per-mode binding interface; both jump actions also appear in the command palette. Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab remains literal text. Override or disable these keys with the ordinary per-mode binding interface; both jump actions also appear in the command palette.
The built-in Normal `gf` sequence follows Markdown file links. It is not a configurable general sequence prefix: bind `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled Normal `g`, or choosing `g` as the leader, overrides the built-in prefix. The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. This is not a configurable general sequence prefix: bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled `g` overrides the built-in prefix in that binding mode; choosing `g` as the leader overrides it in both modes.
## Action reference ## Action reference
@@ -74,6 +74,7 @@ The built-in Normal `gf` sequence follows Markdown file links. It is not a confi
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` | | Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
| Normal, Visual | `repeat_last_edit` | | Normal, Visual | `repeat_last_edit` |
| Normal, Visual | `file_picker`, `file_explorer` | | Normal, Visual | `file_picker`, `file_explorer` |
| Normal, Visual | `document_start`, `document_end` |
| Normal, Insert, Visual | `line_start`, `line_end` | | Normal, Insert, Visual | `line_start`, `line_end` |
| Normal, Insert, Visual, Tree | `left`, `right` | | Normal, Insert, Visual, Tree | `left`, `right` |
| Normal, Insert, Visual, Selection, Tree | `down`, `up` | | Normal, Insert, Visual, Selection, Tree | `down`, `up` |
+4 -1
View File
@@ -17,6 +17,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End | | 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 | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret | | Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations | | Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | | 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 | `J` (Shift+j) joins each targeted line with the next line |
@@ -33,6 +34,8 @@ Default controls and editing behavior. For custom bindings, see [configuration](
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing. `J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
## Word motions ## Word motions
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD. Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
@@ -198,7 +201,7 @@ Local paths resolve relative to the source file, not the working directory; perc
External URL schemes/network paths are rejected; this action never launches a browser or shell. Numeric HTML entities and common named entities (`amp`, `lt`, `gt`, `quot`, `apos`, `nbsp` and their supported aliases) are decoded; other recognized named entities report an unsupported-entity error rather than guessing. Unicode slugs/reference matching use standard-library case conversion, not a complete Unicode case-fold/category table. External URL schemes/network paths are rejected; this action never launches a browser or shell. Numeric HTML entities and common named entities (`amp`, `lt`, `gt`, `quot`, `apos`, `nbsp` and their supported aliases) are decoded; other recognized named entities report an unsupported-entity error rather than guessing. Unicode slugs/reference matching use standard-library case conversion, not a complete Unicode case-fold/category table.
`g` waits for `f`, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured Normal `g` binding overrides this built-in prefix. Custom bindings use the generated action: `g` waits for `f` in Normal, or `t`/`b` in Normal and Visual, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured `g` binding overrides this built-in prefix in its mode; choosing `g` as leader also takes precedence. Custom bindings use the generated action:
```ruby ```ruby
config.bind "leader f", config.core.follow_link config.bind "leader f", config.core.follow_link
+185 -9
View File
@@ -257,6 +257,8 @@ enum Action {
JoinLines, JoinLines,
LineStart, LineStart,
LineEnd, LineEnd,
DocumentStart,
DocumentEnd,
Delete, Delete,
Undo, Undo,
Redo, Redo,
@@ -408,6 +410,16 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
Action::LineEnd, Action::LineEnd,
&[Mode::Normal, Mode::Insert, Mode::Visual], &[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", "core.word_next",
Action::WordNext, Action::WordNext,
@@ -1626,7 +1638,7 @@ impl Editor {
(!self.leader_display.is_empty()).then_some(self.leader_display.as_str()) (!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 { pub fn g_pending(&self) -> bool {
self.g_pending self.g_pending
} }
@@ -2043,15 +2055,28 @@ impl Editor {
}) })
.map(|(input, _)| display_key(input)) .map(|(input, _)| display_key(input))
.collect(); .collect();
if action == Some(Action::FollowLink) let native = match action {
&& self.active.mode == Mode::Normal 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.leader != Input::Char('g')
&& !self && !self
.bindings .bindings
.get(&Mode::Normal) .get(&self.active.mode)
.is_some_and(|keys| keys.contains_key(&Input::Char('g'))) .is_some_and(|keys| keys.contains_key(&Input::Char('g')))
{ {
bindings.push("gf".into()); bindings.push(native.into());
} }
if !self if !self
.bindings .bindings
@@ -2274,7 +2299,7 @@ impl Editor {
input => input, input => input,
}; };
if std::mem::take(&mut self.g_pending) { 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.startup_diagnostic.is_none()
&& self.hover().is_none() && self.hover().is_none()
&& self.palette.is_none() && self.palette.is_none()
@@ -2282,9 +2307,18 @@ impl Editor {
.file_picker .file_picker
.as_ref() .as_ref()
.is_some_and(|picker| picker.focused) .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; self.count = None;
if input == Input::Escape { if input == Input::Escape {
@@ -2395,7 +2429,7 @@ impl Editor {
append_key(&mut self.leader_display, &input); append_key(&mut self.leader_display, &input);
return None; 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.count = None;
self.g_pending = true; self.g_pending = true;
return None; return None;
@@ -2492,6 +2526,8 @@ impl Editor {
| Action::Up | Action::Up
| Action::LineStart | Action::LineStart
| Action::LineEnd | Action::LineEnd
| Action::DocumentStart
| Action::DocumentEnd
| Action::WordNext | Action::WordNext
| Action::WordPrevious | Action::WordPrevious
| Action::WordEnd | Action::WordEnd
@@ -4418,6 +4454,14 @@ impl Editor {
} }
Action::LineStart => cursor.column = 0, Action::LineStart => cursor.column = 0,
Action::LineEnd => cursor.column = self.max_column(cursor.row), 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), _ => cursor = self.word_destination(cursor, action, count),
} }
self.active.targets[index].active = cursor; self.active.targets[index].active = cursor;
@@ -5674,6 +5718,138 @@ mod tests {
assert_eq!(editor.lines(), ["fn main() {}"]); 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] #[test]
fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() {
let mut editor = editor(); let mut editor = editor();
+1
View File
@@ -18,6 +18,7 @@ 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 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 w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
gf follows Markdown file links/reference links and heading fragments (no LSP) gf follows Markdown file links/reference links and heading fragments (no LSP)
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged) Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged)
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match u undo, U redo, : command, / search, n/N next/previous match