Add Vim buffer jump motions (#40958)

## What changed

- Add `gg` and `G` motions to jump to the first and last buffer lines.
- Support the motions with delete, change, and yank operators, including dot-repeat for edits.
- Expose normal-mode and operator-motion bindings in the configurable keymap while preserving conflicting user bindings.

## Testing

- Cover buffer jumps, operator combinations, repeat behavior, chord routing, and custom remapping.

GitOrigin-RevId: 24a9178cf7964053aaf9a8cd268c5434dda0778e
This commit is contained in:
Benjamin Carlsson
2026-08-26 19:02:56 +00:00
committed by copyberry
parent 6ac012a0d4
commit d47e5cc0e2
17 changed files with 433 additions and 13 deletions

View File

@@ -243,6 +243,10 @@ pub struct TuiVimNormalKeymap {
pub till_forward: Option<KeybindingsSpec>,
/// Stop after the previous character on the current line (`T`).
pub till_backward: Option<KeybindingsSpec>,
/// Begin a jump to the first buffer line (`gg`).
pub jump_top: Option<KeybindingsSpec>,
/// Jump to the last buffer line (`G`).
pub jump_bottom: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Replace the character under the cursor (`r`).
@@ -308,6 +312,10 @@ pub struct TuiVimOperatorKeymap {
pub motion_till_forward: Option<KeybindingsSpec>,
/// Motion: stop after the previous character on the current line (`T`).
pub motion_till_backward: Option<KeybindingsSpec>,
/// Motion: begin a jump to the first buffer line (`gg`).
pub motion_jump_top: Option<KeybindingsSpec>,
/// Motion: jump to the last buffer line (`G`).
pub motion_jump_bottom: Option<KeybindingsSpec>,
/// Select an inner text object after an operator.
pub select_inner_text_object: Option<KeybindingsSpec>,
/// Select an around text object after an operator.

View File

@@ -3818,6 +3818,8 @@
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"jump_bottom": null,
"jump_top": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -3846,6 +3848,8 @@
"motion_down": null,
"motion_find_backward": null,
"motion_find_forward": null,
"motion_jump_bottom": null,
"motion_jump_top": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
@@ -4634,6 +4638,8 @@
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"jump_bottom": null,
"jump_top": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -4669,6 +4675,8 @@
"motion_down": null,
"motion_find_backward": null,
"motion_find_forward": null,
"motion_jump_bottom": null,
"motion_jump_top": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
@@ -4981,6 +4989,22 @@
],
"description": "Enter insert mode at first non-blank of line (`I`)."
},
"jump_bottom": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the last buffer line (`G`)."
},
"jump_top": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Begin a jump to the first buffer line (`gg`)."
},
"move_down": {
"allOf": [
{
@@ -5196,6 +5220,22 @@
],
"description": "Motion: find the next character on the current line (`f`)."
},
"motion_jump_bottom": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: jump to the last buffer line (`G`)."
},
"motion_jump_top": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: begin a jump to the first buffer line (`gg`)."
},
"motion_left": {
"allOf": [
{

View File

@@ -60,6 +60,38 @@ fn ctrl(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
}
#[tokio::test]
async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> {
for (input, command, expected) in [
("one\ntwo\nthree", "gg", "!one\ntwo\nthree"),
("one\ntwo\nthree", "dgg", "!"),
("ag bg", "0fg", "a!g bg"),
("ag bg", "0dfg", "! bg"),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str(input);
for (index, key) in command.chars().enumerate() {
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(key).into(),
)
.await?;
if key == 'g' && index + 1 < command.len() {
assert!(app.key_chord_matcher.is_pending());
}
}
assert!(!app.key_chord_matcher.is_pending());
app.chat_widget.insert_str("!");
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
}
Ok(())
}
#[tokio::test]
async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;

View File

@@ -52,6 +52,9 @@ pub(super) enum VimEditTarget {
motion: VimFindMotion,
target: char,
},
BufferJump {
last: bool,
},
}
#[derive(Clone, Copy, Debug)]
@@ -262,6 +265,9 @@ impl TextArea {
return false;
}
}
VimEditTarget::BufferJump { last } => {
self.jump_to_vim_buffer_line(last, Some(operator));
}
}
if operator == VimOperator::Change {
return self.vim_mode == VimMode::Insert;
@@ -330,6 +336,10 @@ impl TextArea {
self.start_vim_find(VimFindMotion::TillForward, /*operator*/ None);
} else if self.vim_normal_keymap.till_backward.is_pressed(event) {
self.start_vim_find(VimFindMotion::TillBackward, /*operator*/ None);
} else if self.vim_normal_keymap.jump_top.is_pressed(event) {
self.jump_to_vim_buffer_line(/*last*/ false, /*operator*/ None);
} else if self.vim_normal_keymap.jump_bottom.is_pressed(event) {
self.jump_to_vim_buffer_line(/*last*/ true, /*operator*/ None);
} else {
return false;
}
@@ -365,6 +375,25 @@ impl TextArea {
.is_pressed(event)
{
self.start_vim_find(VimFindMotion::TillBackward, Some(operator));
} else if self.vim_operator_keymap.motion_jump_top.is_pressed(event)
|| self
.vim_operator_keymap
.motion_jump_bottom
.is_pressed(event)
{
let last = self
.vim_operator_keymap
.motion_jump_bottom
.is_pressed(event);
match operator {
VimOperator::Delete => {
self.start_vim_edit(VimAction::Delete(VimEditTarget::BufferJump { last }));
}
VimOperator::Change => {
self.start_vim_edit(VimAction::Change(VimEditTarget::BufferJump { last }));
}
VimOperator::Yank => self.jump_to_vim_buffer_line(last, Some(operator)),
}
} else {
return false;
}
@@ -475,6 +504,33 @@ impl TextArea {
true
}
fn jump_to_vim_buffer_line(&mut self, last: bool, operator: Option<VimOperator>) {
if let Some(operator) = operator {
let current = self.current_line_range_with_newline();
let range = if last {
current.start..self.text.len()
} else {
0..current.end
};
match operator {
VimOperator::Delete => self.kill_line_range(range),
VimOperator::Yank => self.yank_line_range(range),
VimOperator::Change => {
self.kill_line_range(range);
self.vim_mode = VimMode::Insert;
}
}
return;
}
let start = if last {
self.beginning_of_line(self.text.len())
} else {
0
};
self.set_cursor(start);
self.set_cursor(self.first_non_blank_of_current_line());
}
fn is_vim_command_target(&self, position: usize) -> bool {
!self
.elements

View File

@@ -1,9 +1,14 @@
use super::super::TextArea;
use super::VimAction;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::time::Instant;
fn vim_textarea(text: &str, cursor: usize) -> TextArea {
let mut textarea = TextArea::new();
@@ -14,13 +19,25 @@ fn vim_textarea(text: &str, cursor: usize) -> TextArea {
}
fn keys(textarea: &mut TextArea, keys: &str) {
let keymap = RuntimeKeymap::defaults();
let mut matcher = KeyChordMatcher::default();
for key in keys.chars() {
let code = if key == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(key)
};
textarea.input(KeyEvent::new(code, KeyModifiers::NONE));
let event = KeyEvent::new(code, KeyModifiers::NONE);
match matcher.advance(
event,
&keymap.chords,
KeymapContextSet::new(textarea.keymap_context()),
Instant::now(),
) {
KeyChordMatch::PassThrough => textarea.input(event),
KeyChordMatch::Completed(event) => textarea.input(event),
KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {}
}
}
}
@@ -383,7 +400,7 @@ fn find_and_till_handle_missing_cancelled_and_adjacent_targets() {
}
#[test]
fn dot_repeat_replays_character_find_operators() {
fn dot_repeat_replays_character_find_and_buffer_jump_operators() {
let mut textarea = vim_textarea("one:two:three", /*cursor*/ 0);
keys(&mut textarea, "df:.");
assert_eq!(textarea.text(), "three");
@@ -403,6 +420,10 @@ fn dot_repeat_replays_character_find_operators() {
(expected, Some("Normal"))
);
}
let mut textarea = vim_textarea("one\ntwo\nthree\nfour\nfive", "one\n".len());
keys(&mut textarea, "dggj.");
assert_eq!(textarea.text(), "five");
}
#[test]
@@ -470,6 +491,9 @@ fn character_find_and_operator_motion_use_configured_bindings() {
#[test]
fn uppercase_commands_accept_shift_only_terminal_events() {
let mut textarea = vim_textarea("alpha\nbeta", /*cursor*/ 0);
textarea.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT));
assert_eq!(textarea.cursor(), "alpha\n".len());
textarea.set_cursor("alpha\nbe".len());
textarea.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT));
keys(&mut textarea, "b");
@@ -480,6 +504,35 @@ fn uppercase_commands_accept_shift_only_terminal_events() {
assert_eq!(textarea.cursor(), "alpha\nb".len());
}
#[test]
fn buffer_jumps_target_first_non_blank_and_support_operators() {
let mut textarea = vim_textarea(" first\n second\n third", /*cursor*/ 2);
keys(&mut textarea, "G");
assert_eq!(textarea.cursor(), " first\n second\n ".len());
keys(&mut textarea, "gg");
assert_eq!(textarea.cursor(), 2);
let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len());
keys(&mut textarea, "dG");
assert_eq!(textarea.text(), "first\n");
let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len());
keys(&mut textarea, "dgg");
assert_eq!(textarea.text(), "third");
let mut textarea = vim_textarea("one\ntwo\nthree\nfour", "one\n".len());
keys(&mut textarea, "dggp");
assert_eq!(textarea.text(), "three\none\ntwo\nfour");
let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len());
keys(&mut textarea, "yGp");
assert_eq!(textarea.text(), "one\ntwo\ntwo\nthree\nthree");
let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len());
keys(&mut textarea, "cG");
assert_eq!(textarea.vim_mode_label(), Some("Insert"));
}
#[test]
fn pending_replacement_owns_escape_before_turn_interruption() {
let mut textarea = vim_textarea("alpha", /*cursor*/ 0);
@@ -539,7 +592,7 @@ fn dot_repeat_has_visual_snapshot_coverage() {
fn find_and_navigation_have_visual_snapshot_coverage() {
let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0);
let mut states = Vec::new();
for command in ["tb", "fb", "Ta", "Fa"] {
for command in ["tb", "fb", "Ta", "Fa", "G", "gg"] {
keys(&mut textarea, command);
states.push(format!(
"{command}: {}\n{}^",
@@ -560,5 +613,10 @@ fn find_and_navigation_have_visual_snapshot_coverage() {
Fa: alpha beta\ngamma delta
^
G: alpha beta\ngamma delta
^
gg: alpha beta\ngamma delta
^
"###);
}

View File

@@ -190,6 +190,8 @@ pub(crate) struct VimNormalKeymap {
pub(crate) find_backward: Vec<KeyBinding>,
pub(crate) till_forward: Vec<KeyBinding>,
pub(crate) till_backward: Vec<KeyBinding>,
pub(crate) jump_top: Vec<KeyBinding>,
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) replace_char: Vec<KeyBinding>,
pub(crate) repeat_last_change: Vec<KeyBinding>,
@@ -227,6 +229,8 @@ pub(crate) struct VimOperatorKeymap {
pub(crate) motion_find_backward: Vec<KeyBinding>,
pub(crate) motion_till_forward: Vec<KeyBinding>,
pub(crate) motion_till_backward: Vec<KeyBinding>,
pub(crate) motion_jump_top: Vec<KeyBinding>,
pub(crate) motion_jump_bottom: Vec<KeyBinding>,
pub(crate) select_inner_text_object: Vec<KeyBinding>,
pub(crate) select_around_text_object: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
@@ -558,7 +562,15 @@ impl RuntimeKeymap {
/// parsing `TuiKeymap`, because doing so would ignore explicit user
/// unbindings and conflict diagnostics.
pub(crate) fn defaults() -> Self {
Self::built_in_defaults()
static DEFAULTS: std::sync::OnceLock<RuntimeKeymap> = std::sync::OnceLock::new();
DEFAULTS
.get_or_init(|| {
Self::from_config(&TuiKeymap::default()).unwrap_or_else(|error| {
panic!("built-in keymap defaults must be valid: {error}")
})
})
.clone()
}
/// Resolve a runtime keymap from config, applying precedence and validation.
@@ -728,6 +740,8 @@ impl RuntimeKeymap {
find_backward: resolve_local!(keymap, defaults, vim_normal, find_backward),
till_forward: resolve_local!(keymap, defaults, vim_normal, till_forward),
till_backward: resolve_local!(keymap, defaults, vim_normal, till_backward),
jump_top: resolve_local!(keymap, defaults, vim_normal, jump_top),
jump_bottom: resolve_local!(keymap, defaults, vim_normal, jump_bottom),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
replace_char: resolve_local!(keymap, defaults, vim_normal, replace_char),
repeat_last_change: resolve_local!(keymap, defaults, vim_normal, repeat_last_change),
@@ -829,6 +843,14 @@ impl RuntimeKeymap {
keymap.vim_normal.till_backward.as_ref(),
vim_normal.till_backward.as_slice(),
),
(
keymap.vim_normal.jump_top.as_ref(),
vim_normal.jump_top.as_slice(),
),
(
keymap.vim_normal.jump_bottom.as_ref(),
vim_normal.jump_bottom.as_slice(),
),
(
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
@@ -924,6 +946,14 @@ impl RuntimeKeymap {
keymap.vim_normal.till_backward.as_ref(),
&mut vim_normal.till_backward,
),
(
keymap.vim_normal.jump_top.as_ref(),
&mut vim_normal.jump_top,
),
(
keymap.vim_normal.jump_bottom.as_ref(),
&mut vim_normal.jump_bottom,
),
] {
if configured.is_none() {
bindings.retain(|binding| {
@@ -982,6 +1012,8 @@ impl RuntimeKeymap {
vim_operator,
motion_till_backward
),
motion_jump_top: resolve_local!(keymap, defaults, vim_operator, motion_jump_top),
motion_jump_bottom: resolve_local!(keymap, defaults, vim_operator, motion_jump_bottom),
select_inner_text_object: resolve_local!(
keymap,
defaults,
@@ -1058,6 +1090,14 @@ impl RuntimeKeymap {
keymap.vim_operator.motion_till_backward.as_ref(),
vim_operator.motion_till_backward.as_slice(),
),
(
keymap.vim_operator.motion_jump_top.as_ref(),
vim_operator.motion_jump_top.as_slice(),
),
(
keymap.vim_operator.motion_jump_bottom.as_ref(),
vim_operator.motion_jump_bottom.as_slice(),
),
(
keymap.vim_operator.cancel.as_ref(),
vim_operator.cancel.as_slice(),
@@ -1091,6 +1131,14 @@ impl RuntimeKeymap {
keymap.vim_operator.motion_till_backward.as_ref(),
&mut vim_operator.motion_till_backward,
),
(
keymap.vim_operator.motion_jump_top.as_ref(),
&mut vim_operator.motion_jump_top,
),
(
keymap.vim_operator.motion_jump_bottom.as_ref(),
&mut vim_operator.motion_jump_bottom,
),
] {
if configured.is_none() {
bindings.retain(|binding| {
@@ -1468,6 +1516,11 @@ impl RuntimeKeymap {
find_backward: default_bindings![shift(KeyCode::Char('f'))],
till_forward: default_bindings![plain(KeyCode::Char('t'))],
till_backward: default_bindings![shift(KeyCode::Char('t'))],
jump_top: default_bindings![],
jump_bottom: default_bindings![
shift(KeyCode::Char('g')),
plain(KeyCode::Char('G'))
],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
replace_char: default_bindings![plain(KeyCode::Char('r'))],
repeat_last_change: default_bindings![plain(KeyCode::Char('.'))],
@@ -1506,6 +1559,11 @@ impl RuntimeKeymap {
motion_find_backward: default_bindings![shift(KeyCode::Char('f'))],
motion_till_forward: default_bindings![plain(KeyCode::Char('t'))],
motion_till_backward: default_bindings![shift(KeyCode::Char('t'))],
motion_jump_top: default_bindings![],
motion_jump_bottom: default_bindings![
shift(KeyCode::Char('g')),
plain(KeyCode::Char('G'))
],
select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))],
select_around_text_object: default_bindings![plain(KeyCode::Char('a'))],
cancel: default_bindings![plain(KeyCode::Esc)],
@@ -2011,6 +2069,8 @@ impl RuntimeKeymap {
("find_backward", self.vim_normal.find_backward.as_slice()),
("till_forward", self.vim_normal.till_forward.as_slice()),
("till_backward", self.vim_normal.till_backward.as_slice()),
("jump_top", self.vim_normal.jump_top.as_slice()),
("jump_bottom", self.vim_normal.jump_bottom.as_slice()),
("delete_char", self.vim_normal.delete_char.as_slice()),
("replace_char", self.vim_normal.replace_char.as_slice()),
(
@@ -2095,6 +2155,14 @@ impl RuntimeKeymap {
"motion_till_backward",
self.vim_operator.motion_till_backward.as_slice(),
),
(
"motion_jump_top",
self.vim_operator.motion_jump_top.as_slice(),
),
(
"motion_jump_bottom",
self.vim_operator.motion_jump_bottom.as_slice(),
),
(
"select_inner_text_object",
self.vim_operator.select_inner_text_object.as_slice(),
@@ -3144,6 +3212,7 @@ mod tests {
fn configured_legacy_vim_bindings_prune_new_navigation_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("f"));
keymap.vim_operator.motion_left = Some(one("g"));
keymap.vim_normal.move_right = Some(one("t"));
keymap.vim_normal.move_up = Some(one("shift-f"));
keymap.vim_operator.motion_right = Some(one("shift-t"));
@@ -3152,6 +3221,7 @@ mod tests {
assert_eq!(runtime.vim_normal.find_forward, Vec::new());
assert_eq!(runtime.vim_normal.find_backward, Vec::new());
assert_eq!(runtime.vim_operator.motion_jump_top, Vec::new());
assert_eq!(runtime.vim_normal.till_forward, Vec::new());
assert_eq!(runtime.vim_operator.motion_till_backward, Vec::new());
}

View File

@@ -289,6 +289,8 @@ define_runtime_action_bindings! {
find_backward,
till_forward,
till_backward,
jump_top,
jump_bottom,
delete_char,
replace_char,
repeat_last_change,
@@ -318,6 +320,8 @@ define_runtime_action_bindings! {
motion_find_backward,
motion_till_forward,
motion_till_backward,
motion_jump_top,
motion_jump_bottom,
select_inner_text_object,
select_around_text_object,
cancel,

View File

@@ -131,6 +131,42 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.",
.configured_specs
.push((action, configured_specs));
}
let g = crate::key_hint::plain(KeyCode::Char('g'));
let jump_top = KeyChord {
prefix: g,
completion: g,
};
for action in keymap_action_ids().filter(|action| {
matches!(
(action.context, action.action),
(KeymapContext::VimNormal, "jump_top")
| (KeymapContext::VimOperator, "motion_jump_top")
)
}) {
if effective_configured_binding(keymap, action).is_some()
|| keymap_chords.bindings.iter().any(|configured| {
action.context.overlaps(configured.action.context)
&& configured.chord == jump_top
})
|| keymap_action_ids()
.filter(|configured| action.context.overlaps(configured.context))
.filter_map(|configured| effective_configured_binding(keymap, configured))
.flat_map(KeybindingsSpec::specs)
.any(|spec| {
parse_keybinding(spec.as_str())
.is_some_and(|binding| binding.parts() == g.parts())
})
{
continue;
}
keymap_chords.bindings.push(RuntimeChordBinding {
action,
chord: jump_top,
spec: "g g".to_string(),
});
}
Ok(keymap_chords)
}
@@ -162,7 +198,17 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.",
};
}
super::primary_binding(bindings).map(crate::key_hint::ShortcutHint::Single)
super::primary_binding(bindings)
.map(crate::key_hint::ShortcutHint::Single)
.or_else(|| {
self.bindings
.iter()
.find(|binding| binding.action == action)
.map(|binding| crate::key_hint::ShortcutHint::Chord {
prefix: binding.chord.prefix,
completion: binding.chord.completion,
})
})
}
}

View File

@@ -91,6 +91,84 @@ fn resolves_chords_for_actions_in_different_contexts() {
}
}
#[test]
fn default_vim_jump_top_uses_contextual_gg_chords() {
let runtime = RuntimeKeymap::defaults();
let g = key_event(key_hint::plain(KeyCode::Char('g')));
for (context, action, target) in [
(
KeymapContext::VimNormal,
"jump_top",
runtime.vim_normal.jump_top.as_slice(),
),
(
KeymapContext::VimOperator,
"motion_jump_top",
runtime.vim_operator.motion_jump_top.as_slice(),
),
] {
let action_id = keymap_action_id(context.config_name(), action).expect("known Vim action");
assert!(runtime.chords.bindings.iter().any(|binding| {
binding.action == action_id
&& binding.chord.prefix == key_hint::plain(KeyCode::Char('g'))
&& binding.chord.completion == key_hint::plain(KeyCode::Char('g'))
}));
assert!(matches!(
runtime.primary_hint(context, action),
Some(crate::key_hint::ShortcutHint::Chord { .. })
));
let mut matcher = KeyChordMatcher::default();
let contexts = KeymapContextSet::new(context);
assert!(matches!(
matcher.advance(g, &runtime.chords, contexts, Instant::now()),
KeyChordMatch::Pending(_)
));
let KeyChordMatch::Completed(event) =
matcher.advance(g, &runtime.chords, contexts, Instant::now())
else {
panic!("gg must dispatch the active Vim jump action");
};
assert!(target.is_pressed(event));
}
}
#[test]
fn default_vim_jump_chords_yield_to_configured_singles_and_chords() {
let mut config = TuiKeymap::default();
config.vim_normal.move_line_start = Some(binding("g g"));
config.vim_operator.motion_line_start = Some(binding("g"));
let runtime = RuntimeKeymap::from_config(&config).expect("configured legacy bindings win");
assert!(runtime.vim_normal.jump_top.is_empty());
assert!(runtime.vim_operator.motion_jump_top.is_empty());
assert!(
runtime
.vim_operator
.motion_line_start
.is_pressed(key_event(key_hint::plain(KeyCode::Char('g'))))
);
}
#[test]
fn vim_jump_top_supports_single_and_custom_chord_remaps() {
let mut config = TuiKeymap::default();
config.vim_normal.jump_top = Some(binding("home"));
config.vim_operator.motion_jump_top = Some(binding("ctrl-x g"));
let runtime = RuntimeKeymap::from_config(&config).expect("custom Vim jumps are valid");
assert!(runtime.vim_normal.jump_top.is_pressed(KeyCode::Home.into()));
let action = keymap_action_id("vim_operator", "motion_jump_top").expect("known Vim action");
assert!(runtime.chords.bindings.iter().any(|binding| {
binding.action == action
&& binding.chord.prefix == key_hint::ctrl(KeyCode::Char('x'))
&& binding.chord.completion == key_hint::plain(KeyCode::Char('g'))
}));
}
#[test]
fn composer_chord_inherits_global_fallback() {
let mut config = TuiKeymap::default();

View File

@@ -564,7 +564,8 @@ pub(crate) fn active_binding_specs(
context: &str,
action: &str,
) -> Result<Vec<String>, String> {
if let Some(action_id) = keymap_action_id(context, action)
let action_id = keymap_action_id(context, action);
if let Some(action_id) = action_id
&& let Some(specs) = runtime_keymap.chords.configured_specs(action_id)
{
return Ok(specs.to_vec());
@@ -573,6 +574,16 @@ pub(crate) fn active_binding_specs(
let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| {
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
})?;
if let Some(action_id) = action_id
&& let Some(crate::key_hint::ShortcutHint::Chord { prefix, completion }) =
runtime_keymap.chords.primary_hint(action_id, bindings)
{
return Ok(vec![format!(
"{} {}",
binding_to_config_key_spec(prefix)?,
binding_to_config_key_spec(completion)?
)]);
}
bindings
.iter()
.map(|binding| binding_to_config_key_spec(*binding))
@@ -1037,6 +1048,15 @@ mod tests {
#[test]
fn picker_unbound_tab_lists_default_unbound_actions() {
let runtime = RuntimeKeymap::defaults();
for (context, action) in [
("vim_normal", "jump_top"),
("vim_operator", "motion_jump_top"),
] {
assert_eq!(
active_binding_specs(&runtime, context, action).expect("native Vim chord"),
["g g"]
);
}
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let unbound_tab = selection_tab(&params, KEYMAP_UNBOUND_TAB_ID);

View File

@@ -140,6 +140,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "find_backward", "Find the previous character on the current line."),
action("vim_normal", "Vim normal", "till_forward", "Stop before the next character on the current line."),
action("vim_normal", "Vim normal", "till_backward", "Stop after the previous character on the current line."),
action("vim_normal", "Vim normal", "jump_top", "Jump to the first buffer line."),
action("vim_normal", "Vim normal", "jump_bottom", "Jump to the last buffer line."),
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
action("vim_normal", "Vim normal", "replace_char", "Replace the character under the cursor."),
action("vim_normal", "Vim normal", "repeat_last_change", "Repeat the last complete edit."),
@@ -167,6 +169,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_operator", "Vim operator", "motion_find_backward", "Operator motion to the previous character on the current line."),
action("vim_operator", "Vim operator", "motion_till_forward", "Stop before the next character on the current line."),
action("vim_operator", "Vim operator", "motion_till_backward", "Stop after the previous character on the current line."),
action("vim_operator", "Vim operator", "motion_jump_top", "Operator motion to the first buffer line."),
action("vim_operator", "Vim operator", "motion_jump_bottom", "Operator motion to the last buffer line."),
action("vim_operator", "Vim operator", "select_inner_text_object", "Select an inner text object."),
action("vim_operator", "Vim operator", "select_around_text_object", "Select an around text object."),
action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."),
@@ -302,6 +306,8 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "find_backward") => Some(&mut keymap.vim_normal.find_backward),
("vim_normal", "till_forward") => Some(&mut keymap.vim_normal.till_forward),
("vim_normal", "till_backward") => Some(&mut keymap.vim_normal.till_backward),
("vim_normal", "jump_top") => Some(&mut keymap.vim_normal.jump_top),
("vim_normal", "jump_bottom") => Some(&mut keymap.vim_normal.jump_bottom),
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
("vim_normal", "replace_char") => Some(&mut keymap.vim_normal.replace_char),
("vim_normal", "repeat_last_change") => Some(&mut keymap.vim_normal.repeat_last_change),
@@ -329,6 +335,8 @@ pub(super) fn binding_slot<'a>(
("vim_operator", "motion_find_backward") => Some(&mut keymap.vim_operator.motion_find_backward),
("vim_operator", "motion_till_forward") => Some(&mut keymap.vim_operator.motion_till_forward),
("vim_operator", "motion_till_backward") => Some(&mut keymap.vim_operator.motion_till_backward),
("vim_operator", "motion_jump_top") => Some(&mut keymap.vim_operator.motion_jump_top),
("vim_operator", "motion_jump_bottom") => Some(&mut keymap.vim_operator.motion_jump_bottom),
("vim_operator", "select_inner_text_object") => Some(&mut keymap.vim_operator.select_inner_text_object),
("vim_operator", "select_around_text_object") => Some(&mut keymap.vim_operator.select_around_text_object),
("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel),

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
127 actions, 1 customized, 4 unbound.
131 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
128 actions, 0 customized, 5 unbound.
132 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug

View File

@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (127 selectable)
tab: All (131 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (4) (4 selectable)
tab: App (14 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (58 selectable)
tab: Vim (62 selectable)
tab: Navigation (20 selectable)
tab: Agents (5 selectable)
tab: Approval (8 selectable)

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
127 actions, 1 customized, 4 unbound.
131 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
127 actions, 0 customized, 4 unbound.
131 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (4) App Composer Editor Vim
Navigation Agents Approval Debug

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
127 actions, 0 customized, 4 unbound.
131 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug