mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
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:
committed by
copyberry
parent
6ac012a0d4
commit
d47e5cc0e2
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
^
|
||||
"###);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user