Compare commits

...

16 Commits

Author SHA1 Message Date
Felipe Coury
f3d0c003ea feat(tui): add vim dot repeat 2026-05-25 19:35:59 -03:00
Felipe Coury
50fbb55995 feat(tui): add vim visual modes 2026-05-25 19:10:47 -03:00
Felipe Coury
4494bfe368 feat(tui): add vim named registers 2026-05-25 18:42:20 -03:00
Felipe Coury
43472a6f02 feat(tui): add vim command counts 2026-05-25 18:18:55 -03:00
Felipe Coury
9d9e89ca29 feat(tui): add vim tag text objects 2026-05-25 17:58:21 -03:00
Felipe Coury
cbdae45c3f feat(tui): add vim prose text objects 2026-05-25 17:34:45 -03:00
Felipe Coury
ff077a6a07 feat(tui): add vim find and till motions 2026-05-25 17:09:34 -03:00
Felipe Coury
e98e93e277 feat(tui): complete vim change operations 2026-05-25 16:42:09 -03:00
Felipe Coury
ac5067f921 test(tui): update combined vim keymap snapshots 2026-05-25 16:12:34 -03:00
Felipe Coury
fd0e287d96 test(tui): ignore intentional vim fixture spelling 2026-05-25 15:48:24 -03:00
Felipe Coury
5f86c5259b feat(tui): add vim change-to-line-end binding 2026-05-25 15:48:12 -03:00
Felipe Coury
11bf378594 fix(tui): advance vim word end motion 2026-05-25 15:47:41 -03:00
Felipe Coury
727b57e73e fix(tui): handle vim word text objects at cursor end 2026-05-24 20:54:39 -03:00
Felipe Coury
26b9335406 fix(config): reject unknown vim text object keys 2026-05-24 20:47:35 -03:00
Felipe Coury
5f69d89d94 fix(tui): handle vim text object review feedback 2026-05-24 20:39:38 -03:00
Felipe Coury
4e3f68330a feat(tui): add vim text object bindings 2026-05-24 20:17:11 -03:00
23 changed files with 4767 additions and 162 deletions

View File

@@ -219,26 +219,54 @@ pub struct TuiVimNormalKeymap {
pub move_line_start: Option<KeybindingsSpec>,
/// Move cursor to end of line (`$`).
pub move_line_end: Option<KeybindingsSpec>,
/// Find the next target character on the current line (`f{char}`).
pub find_forward: Option<KeybindingsSpec>,
/// Find the previous target character on the current line (`F{char}`).
pub find_backward: Option<KeybindingsSpec>,
/// Move until before the next target character on the current line (`t{char}`).
pub till_forward: Option<KeybindingsSpec>,
/// Move until after the previous target character on the current line (`T{char}`).
pub till_backward: Option<KeybindingsSpec>,
/// Repeat the most recent find/till motion (`;`).
pub repeat_find: Option<KeybindingsSpec>,
/// Repeat the most recent find/till motion in the opposite direction (`,`).
pub repeat_find_reverse: Option<KeybindingsSpec>,
/// Repeat the most recent text-changing Vim command (`.`).
pub repeat_change: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
pub delete_to_line_end: Option<KeybindingsSpec>,
/// Change from cursor to end of line and enter insert mode (`C`).
pub change_to_line_end: Option<KeybindingsSpec>,
/// Substitute the character under the cursor and enter insert mode (`s`).
pub substitute_char: Option<KeybindingsSpec>,
/// Substitute the current line and enter insert mode (`S`).
pub substitute_line: Option<KeybindingsSpec>,
/// Yank the entire line (`Y`).
pub yank_line: Option<KeybindingsSpec>,
/// Paste after cursor (`p`).
pub paste_after: Option<KeybindingsSpec>,
/// Select a named register for the next Vim command (`"{register}`).
pub select_register: Option<KeybindingsSpec>,
/// Enter characterwise visual mode (`v`).
pub enter_visual: Option<KeybindingsSpec>,
/// Enter linewise visual mode (`V`).
pub enter_visual_line: Option<KeybindingsSpec>,
/// Begin delete operator; next key selects motion (`d`).
pub start_delete_operator: Option<KeybindingsSpec>,
/// Begin yank operator; next key selects motion (`y`).
pub start_yank_operator: Option<KeybindingsSpec>,
/// Begin change operator; next keys select a motion or text object.
pub start_change_operator: Option<KeybindingsSpec>,
/// Cancel a pending operator and return to normal mode.
pub cancel_operator: Option<KeybindingsSpec>,
}
/// Vim operator-pending keybindings for modal editing inside text areas.
///
/// This context is active only while waiting for a motion after `d` or `y`.
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
/// This context is active only while waiting for a motion after `d`, `y`, or `c`.
/// Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing
/// `Esc` cancels the pending operator and returns to normal mode without
/// modifying text.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
@@ -248,6 +276,8 @@ pub struct TuiVimOperatorKeymap {
pub delete_line: Option<KeybindingsSpec>,
/// Repeat yank operator to yank the whole line (`yy`).
pub yank_line: Option<KeybindingsSpec>,
/// Repeat change operator to change the whole line (`cc`).
pub change_line: Option<KeybindingsSpec>,
/// Motion: left (`h`).
pub motion_left: Option<KeybindingsSpec>,
/// Motion: right (`l`).
@@ -266,10 +296,75 @@ pub struct TuiVimOperatorKeymap {
pub motion_line_start: Option<KeybindingsSpec>,
/// Motion: to end of line (`$`).
pub motion_line_end: Option<KeybindingsSpec>,
/// Enter a forward find motion and await its literal target (`f{char}`).
pub find_forward: Option<KeybindingsSpec>,
/// Enter a backward find motion and await its literal target (`F{char}`).
pub find_backward: Option<KeybindingsSpec>,
/// Enter a forward till motion and await its literal target (`t{char}`).
pub till_forward: Option<KeybindingsSpec>,
/// Enter a backward till motion and await its literal target (`T{char}`).
pub till_backward: Option<KeybindingsSpec>,
/// Repeat the most recent find/till motion (`;`).
pub repeat_find: Option<KeybindingsSpec>,
/// Repeat the most recent find/till motion in the opposite direction (`,`).
pub repeat_find_reverse: 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.
pub select_around_text_object: Option<KeybindingsSpec>,
/// Cancel the pending operator and return to normal mode.
pub cancel: Option<KeybindingsSpec>,
}
/// Vim text-object keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimTextObjectKeymap {
/// Text object: word.
pub word: Option<KeybindingsSpec>,
/// Text object: whitespace-delimited WORD.
pub big_word: Option<KeybindingsSpec>,
/// Text object: parentheses.
pub parentheses: Option<KeybindingsSpec>,
/// Text object: brackets.
pub brackets: Option<KeybindingsSpec>,
/// Text object: braces.
pub braces: Option<KeybindingsSpec>,
/// Text object: double quotes.
pub double_quote: Option<KeybindingsSpec>,
/// Text object: single quotes.
pub single_quote: Option<KeybindingsSpec>,
/// Text object: backticks.
pub backtick: Option<KeybindingsSpec>,
/// Text object: sentence.
pub sentence: Option<KeybindingsSpec>,
/// Text object: paragraph.
pub paragraph: Option<KeybindingsSpec>,
/// Text object: enclosing paired tag.
pub tag: Option<KeybindingsSpec>,
/// Cancel the pending text-object command.
pub cancel: Option<KeybindingsSpec>,
}
/// Vim visual-mode keybindings for selected-text operations.
///
/// Normal-mode movement, find, and text-object bindings continue to extend
/// selections while this context supplies actions specific to an active
/// selection.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimVisualKeymap {
/// Delete the visual selection (`d`).
pub delete: Option<KeybindingsSpec>,
/// Yank the visual selection (`y`).
pub yank: Option<KeybindingsSpec>,
/// Change the visual selection and enter insert mode (`c`).
pub change: Option<KeybindingsSpec>,
/// Cancel visual mode and return to normal mode (`Esc`).
pub cancel: Option<KeybindingsSpec>,
}
/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -374,6 +469,10 @@ pub struct TuiKeymap {
#[serde(default)]
pub vim_operator: TuiVimOperatorKeymap,
#[serde(default)]
pub vim_text_object: TuiVimTextObjectKeymap,
#[serde(default)]
pub vim_visual: TuiVimVisualKeymap,
#[serde(default)]
pub pager: TuiPagerKeymap,
#[serde(default)]
pub list: TuiListKeymap,
@@ -560,6 +659,20 @@ mod tests {
);
}
#[test]
fn misspelled_vim_text_object_action_is_rejected() {
let toml_input = r#"
[vim_text_object]
double_quotes = "shift-quote"
"#;
let err = toml::from_str::<TuiKeymap>(toml_input)
.expect_err("expected unknown vim text object action");
assert!(
err.to_string().contains("double_quotes"),
"expected error to mention misspelled field, got: {err}"
);
}
#[test]
fn removed_backtrack_actions_are_rejected() {
for (context, action) in [

View File

@@ -2824,9 +2824,14 @@
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"change_to_line_end": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"enter_visual": null,
"enter_visual_line": null,
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
@@ -2840,13 +2845,25 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_change": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_register": null,
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"substitute_line": null,
"till_backward": null,
"till_forward": null,
"yank_line": null
},
"vim_operator": {
"cancel": null,
"change_line": null,
"delete_line": null,
"find_backward": null,
"find_forward": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
@@ -2856,7 +2873,33 @@
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_around_text_object": null,
"select_inner_text_object": null,
"till_backward": null,
"till_forward": null,
"yank_line": null
},
"vim_text_object": {
"backtick": null,
"big_word": null,
"braces": null,
"brackets": null,
"cancel": null,
"double_quote": null,
"paragraph": null,
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
},
"vim_visual": {
"cancel": null,
"change": null,
"delete": null,
"yank": null
}
},
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
@@ -3490,9 +3533,14 @@
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"change_to_line_end": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"enter_visual": null,
"enter_visual_line": null,
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
@@ -3506,8 +3554,17 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_change": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_register": null,
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"substitute_line": null,
"till_backward": null,
"till_forward": null,
"yank_line": null
}
},
@@ -3519,7 +3576,10 @@
],
"default": {
"cancel": null,
"change_line": null,
"delete_line": null,
"find_backward": null,
"find_forward": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
@@ -3529,8 +3589,48 @@
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_around_text_object": null,
"select_inner_text_object": null,
"till_backward": null,
"till_forward": null,
"yank_line": null
}
},
"vim_text_object": {
"allOf": [
{
"$ref": "#/definitions/TuiVimTextObjectKeymap"
}
],
"default": {
"backtick": null,
"big_word": null,
"braces": null,
"brackets": null,
"cancel": null,
"double_quote": null,
"paragraph": null,
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
}
},
"vim_visual": {
"allOf": [
{
"$ref": "#/definitions/TuiVimVisualKeymap"
}
],
"default": {
"cancel": null,
"change": null,
"delete": null,
"yank": null
}
}
},
"type": "object"
@@ -3755,6 +3855,14 @@
],
"description": "Cancel a pending operator and return to normal mode."
},
"change_to_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Change from cursor to end of line and enter insert mode (`C`)."
},
"delete_char": {
"allOf": [
{
@@ -3779,6 +3887,38 @@
],
"description": "Enter insert mode at cursor (`i`)."
},
"enter_visual": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter characterwise visual mode (`v`)."
},
"enter_visual_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter linewise visual mode (`V`)."
},
"find_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Find the previous target character on the current line (`F{char}`)."
},
"find_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Find the next target character on the current line (`f{char}`)."
},
"insert_line_start": {
"allOf": [
{
@@ -3883,6 +4023,46 @@
],
"description": "Paste after cursor (`p`)."
},
"repeat_change": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent text-changing Vim command (`.`)."
},
"repeat_find": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent find/till motion (`;`)."
},
"repeat_find_reverse": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent find/till motion in the opposite direction (`,`)."
},
"select_register": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Select a named register for the next Vim command (`\"{register}`)."
},
"start_change_operator": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Begin change operator; next keys select a motion or text object."
},
"start_delete_operator": {
"allOf": [
{
@@ -3899,6 +4079,38 @@
],
"description": "Begin yank operator; next key selects motion (`y`)."
},
"substitute_char": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Substitute the character under the cursor and enter insert mode (`s`)."
},
"substitute_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Substitute the current line and enter insert mode (`S`)."
},
"till_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move until after the previous target character on the current line (`T{char}`)."
},
"till_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move until before the next target character on the current line (`t{char}`)."
},
"yank_line": {
"allOf": [
{
@@ -3912,7 +4124,7 @@
},
"TuiVimOperatorKeymap": {
"additionalProperties": false,
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d`, `y`, or `c`. Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
"properties": {
"cancel": {
"allOf": [
@@ -3922,6 +4134,14 @@
],
"description": "Cancel the pending operator and return to normal mode."
},
"change_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat change operator to change the whole line (`cc`)."
},
"delete_line": {
"allOf": [
{
@@ -3930,6 +4150,22 @@
],
"description": "Repeat delete operator to delete the whole line (`dd`)."
},
"find_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter a backward find motion and await its literal target (`F{char}`)."
},
"find_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter a forward find motion and await its literal target (`f{char}`)."
},
"motion_down": {
"allOf": [
{
@@ -4002,6 +4238,54 @@
],
"description": "Motion: to start of next word (`w`)."
},
"repeat_find": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent find/till motion (`;`)."
},
"repeat_find_reverse": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent find/till motion in the opposite direction (`,`)."
},
"select_around_text_object": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Select an around text object after an operator."
},
"select_inner_text_object": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Select an inner text object after an operator."
},
"till_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter a backward till motion and await its literal target (`T{char}`)."
},
"till_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter a forward till motion and await its literal target (`t{char}`)."
},
"yank_line": {
"allOf": [
{
@@ -4013,6 +4297,148 @@
},
"type": "object"
},
"TuiVimTextObjectKeymap": {
"additionalProperties": false,
"description": "Vim text-object keybindings for modal editing inside text areas.",
"properties": {
"backtick": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: backticks."
},
"big_word": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: whitespace-delimited WORD."
},
"braces": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: braces."
},
"brackets": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: brackets."
},
"cancel": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Cancel the pending text-object command."
},
"double_quote": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: double quotes."
},
"paragraph": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: paragraph."
},
"parentheses": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: parentheses."
},
"sentence": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: sentence."
},
"single_quote": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: single quotes."
},
"tag": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: enclosing paired tag."
},
"word": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: word."
}
},
"type": "object"
},
"TuiVimVisualKeymap": {
"additionalProperties": false,
"description": "Vim visual-mode keybindings for selected-text operations.\n\nNormal-mode movement, find, and text-object bindings continue to extend selections while this context supplies actions specific to an active selection.",
"properties": {
"cancel": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Cancel visual mode and return to normal mode (`Esc`)."
},
"change": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Change the visual selection and enter insert mode (`c`)."
},
"delete": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Delete the visual selection (`d`)."
},
"yank": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Yank the visual selection (`y`)."
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{

View File

@@ -1074,6 +1074,8 @@ impl ChatComposer {
.map(|label| match label {
"Normal" => "Vim: Normal".magenta(),
"Insert" => "Vim: Insert".green(),
"Visual" => "Vim: Visual".cyan(),
"Visual Line" => "Vim: Visual Line".cyan(),
_ => unreachable!(),
})
}
@@ -3179,6 +3181,9 @@ impl ChatComposer {
if self.should_handle_vim_insert_escape(key_event) {
return self.handle_input_basic(key_event);
}
if self.draft.textarea.is_vim_visual_mode() {
return self.handle_input_basic(key_event);
}
if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending()
{
return self.handle_input_basic(key_event);
@@ -4651,8 +4656,9 @@ impl ChatComposer {
.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
let highlight_ranges = self.history_search_highlight_ranges();
if highlight_ranges.is_empty() {
let history_highlight_ranges = self.history_search_highlight_ranges();
let visual_highlight_range = self.draft.textarea.vim_visual_selection_range();
if history_highlight_ranges.is_empty() && visual_highlight_range.is_none() {
StatefulWidgetRef::render_ref(
&(&self.draft.textarea),
textarea_rect,
@@ -4662,10 +4668,13 @@ impl ChatComposer {
} else {
let highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
let highlights = highlight_ranges
let mut highlights = history_highlight_ranges
.into_iter()
.map(|range| (range, highlight_style))
.collect::<Vec<_>>();
if let Some(range) = visual_highlight_range {
highlights.push((range, Style::default().add_modifier(Modifier::REVERSED)));
}
self.draft.textarea.render_ref_styled_with_highlights(
textarea_rect,
buf,
@@ -5499,6 +5508,68 @@ mod tests {
assert!(!composer.footer.esc_backtrack_hint);
}
#[test]
fn vim_visual_mode_selection_snapshots() {
snapshot_composer_state(
"vim_visual_characterwise_selection",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_text_content("alpha beta".to_string(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"vim_visual_line_selection",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_text_content("alpha\nbeta\ngamma".to_string(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
},
);
}
#[test]
fn vim_visual_selection_renders_reversed_cells() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("alpha beta".to_string(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
let area = Rect::new(0, 0, 100, 9);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
assert!(
buf[(2, 1)]
.style()
.add_modifier
.contains(Modifier::REVERSED)
);
assert!(
!buf[(8, 1)]
.style()
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn slash_opens_command_popup_in_vim_normal_mode() {
use crossterm::event::KeyCode;

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" alpha beta "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Visual "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" alpha "
" beta "
" gamma "
" "
" "
" "
" "
" 100% context left | Vim: Visual Line "

View File

@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "states.join(\"\\n\\n\")"
---
alpha beta gamma
^
alpha beta gamma
^
alpha beta gamma
^

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
use super::TextArea;
use super::split_word_pieces;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyEvent;
use std::ops::Range;
mod count;
mod find;
mod prose;
mod register;
mod repeat;
mod tag;
mod visual;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimMode {
/// Normal mode routes printable keys to movement, operators, and mode transitions.
Normal,
/// Insert mode routes input through the regular editor keymap until Escape is pressed.
Insert,
/// Visual mode extends a characterwise or linewise selection from an anchor.
Visual(VimVisualKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimOperator {
Delete,
Yank,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimPending {
None,
Register,
Operator(VimOperator),
TextObject {
operator: VimOperator,
scope: VimTextObjectScope,
},
Find {
operator: Option<VimOperator>,
kind: VimFindKind,
},
VisualTextObject {
scope: VimTextObjectScope,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct VimRegisterSelection {
pub(super) name: char,
pub(super) append: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimVisualKind {
Characterwise,
Linewise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimMotion {
Left,
Right,
Up,
Down,
WordForward,
WordBackward,
WordEnd,
LineStart,
LineEnd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimFindKind {
FindForward,
FindBackward,
TillForward,
TillBackward,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct VimFind {
pub(super) kind: VimFindKind,
pub(super) target: char,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimRepeatTarget {
Characters(usize),
Lines(usize),
Motion {
motion: VimMotion,
count: usize,
},
TextObject {
object: VimTextObject,
scope: VimTextObjectScope,
count: usize,
},
Find {
find: VimFind,
count: usize,
},
Visual {
kind: VimVisualKind,
atomic_units: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimInsertEdit {
Insert(String),
DeleteBackward,
DeleteForward,
DeleteBackwardWord,
DeleteForwardWord,
KillLineStart,
KillWholeLine,
KillLineEnd,
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
MoveWordLeft,
MoveWordRight,
MoveLineStart { move_up_at_bol: bool },
MoveLineEnd { move_down_at_eol: bool },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct VimInsertRecording {
pub(super) target: VimRepeatTarget,
pub(super) edits: Vec<VimInsertEdit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimRepeatChange {
Delete(VimRepeatTarget),
Change {
target: VimRepeatTarget,
edits: Vec<VimInsertEdit>,
},
Paste {
text: String,
kind: super::KillBufferKind,
count: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimTextObjectScope {
Inner,
Around,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimTextObject {
Word,
BigWord,
Parentheses,
Brackets,
Braces,
DoubleQuote,
SingleQuote,
Backtick,
Sentence,
Paragraph,
Tag,
}
impl TextArea {
pub(super) fn vim_text_object_scope_for_event(
&self,
event: KeyEvent,
) -> Option<VimTextObjectScope> {
if self
.vim_operator_keymap
.select_inner_text_object
.is_pressed(event)
{
return Some(VimTextObjectScope::Inner);
}
if self
.vim_operator_keymap
.select_around_text_object
.is_pressed(event)
{
return Some(VimTextObjectScope::Around);
}
None
}
pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option<VimTextObject> {
if self.vim_text_object_keymap.word.is_pressed(event) {
return Some(VimTextObject::Word);
}
if self.vim_text_object_keymap.big_word.is_pressed(event) {
return Some(VimTextObject::BigWord);
}
if self.vim_text_object_keymap.parentheses.is_pressed(event) {
return Some(VimTextObject::Parentheses);
}
if self.vim_text_object_keymap.brackets.is_pressed(event) {
return Some(VimTextObject::Brackets);
}
if self.vim_text_object_keymap.braces.is_pressed(event) {
return Some(VimTextObject::Braces);
}
if self.vim_text_object_keymap.double_quote.is_pressed(event) {
return Some(VimTextObject::DoubleQuote);
}
if self.vim_text_object_keymap.single_quote.is_pressed(event) {
return Some(VimTextObject::SingleQuote);
}
if self.vim_text_object_keymap.backtick.is_pressed(event) {
return Some(VimTextObject::Backtick);
}
if self.vim_text_object_keymap.sentence.is_pressed(event) {
return Some(VimTextObject::Sentence);
}
if self.vim_text_object_keymap.paragraph.is_pressed(event) {
return Some(VimTextObject::Paragraph);
}
if self.vim_text_object_keymap.tag.is_pressed(event) {
return Some(VimTextObject::Tag);
}
None
}
pub(super) fn text_object_range(
&self,
object: VimTextObject,
scope: VimTextObjectScope,
) -> Option<Range<usize>> {
match object {
VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false),
VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true),
VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'),
VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'),
VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'),
VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'),
VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''),
VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'),
VimTextObject::Sentence => self.sentence_text_object_range(scope),
VimTextObject::Paragraph => self.paragraph_text_object_range(scope),
VimTextObject::Tag => self.tag_text_object_range(scope),
}
}
fn word_text_object_range(
&self,
scope: VimTextObjectScope,
big_word: bool,
) -> Option<Range<usize>> {
let inner = if big_word {
self.big_word_range_at_cursor()?
} else {
self.small_word_range_at_cursor()?
};
Some(match scope {
VimTextObjectScope::Inner => inner,
VimTextObjectScope::Around => self.expand_word_around(inner),
})
}
fn big_word_range_at_cursor(&self) -> Option<Range<usize>> {
self.non_ws_runs()
.into_iter()
.find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range))
}
fn small_word_range_at_cursor(&self) -> Option<Range<usize>> {
for run in self.non_ws_runs() {
if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) {
continue;
}
let mut last_piece = None;
for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) {
let piece = run.start + piece_start..run.start + piece_start + piece.len();
if self.cursor_overlaps_range(&piece) {
return Some(piece);
}
last_piece = Some(piece);
}
if self.cursor_is_at_range_end(&run) {
return last_piece.or(Some(run));
}
return Some(run);
}
None
}
fn non_ws_runs(&self) -> Vec<Range<usize>> {
let mut runs = Vec::new();
let mut start = None;
for (idx, ch) in self.text.char_indices() {
if ch.is_whitespace() {
if let Some(run_start) = start.take() {
runs.push(run_start..idx);
}
} else if start.is_none() {
start = Some(idx);
}
}
if let Some(run_start) = start {
runs.push(run_start..self.text.len());
}
runs
}
fn cursor_overlaps_range(&self, range: &Range<usize>) -> bool {
range.start <= self.cursor_pos && self.cursor_pos < range.end
}
fn cursor_is_at_range_end(&self, range: &Range<usize>) -> bool {
range.start < range.end && self.cursor_pos == range.end
}
fn expand_word_around(&self, inner: Range<usize>) -> Range<usize> {
let following = self.following_whitespace_end(inner.end);
if following > inner.end {
return inner.start..following;
}
self.preceding_whitespace_start(inner.start)..inner.end
}
fn following_whitespace_end(&self, start: usize) -> usize {
let mut end = start;
for (offset, ch) in self.text[start..].char_indices() {
if !ch.is_whitespace() {
break;
}
end = start + offset + ch.len_utf8();
}
end
}
fn preceding_whitespace_start(&self, end: usize) -> usize {
let mut start = end;
for (idx, ch) in self.text[..end].char_indices().rev() {
if !ch.is_whitespace() {
break;
}
start = idx;
}
start
}
fn paired_text_object_range(
&self,
scope: VimTextObjectScope,
open: char,
close: char,
) -> Option<Range<usize>> {
let mut stack: Vec<usize> = Vec::new();
let mut best: Option<Range<usize>> = None;
for (idx, ch) in self.text.char_indices() {
if self.is_inside_element(idx) {
continue;
}
if ch == open {
stack.push(idx);
} else if ch == close {
let Some(open_idx) = stack.pop() else {
continue;
};
let close_end = idx + ch.len_utf8();
if open_idx <= self.cursor_pos && self.cursor_pos <= idx {
let candidate = match scope {
VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx,
VimTextObjectScope::Around => open_idx..close_end,
};
if candidate.start <= candidate.end
&& best
.as_ref()
.is_none_or(|current| candidate.len() < current.len())
{
best = Some(candidate);
}
}
}
}
best
}
fn quoted_text_object_range(
&self,
scope: VimTextObjectScope,
quote: char,
) -> Option<Range<usize>> {
let line = self.beginning_of_current_line()..self.end_of_current_line();
let mut open = None;
let mut best: Option<Range<usize>> = None;
for (offset, ch) in self.text[line.clone()].char_indices() {
let idx = line.start + offset;
if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) {
continue;
}
if let Some(open_idx) = open.take() {
if open_idx <= self.cursor_pos && self.cursor_pos <= idx {
let candidate = match scope {
VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx,
VimTextObjectScope::Around => idx_range(open_idx, idx, quote),
};
if candidate.start <= candidate.end
&& best
.as_ref()
.is_none_or(|current| candidate.len() < current.len())
{
best = Some(candidate);
}
}
} else {
open = Some(idx);
}
}
best
}
fn is_inside_element(&self, pos: usize) -> bool {
self.elements
.iter()
.any(|element| pos >= element.range.start && pos < element.range.end)
}
fn is_escaped(&self, pos: usize) -> bool {
let mut backslashes = 0;
for ch in self.text[..pos].chars().rev() {
if ch != '\\' {
break;
}
backslashes += 1;
}
backslashes % 2 == 1
}
}
fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range<usize> {
open_idx..close_idx + quote.len_utf8()
}

View File

@@ -0,0 +1,160 @@
use super::super::TextArea;
use super::VimMotion;
use super::VimOperator;
use super::VimTextObject;
use super::VimTextObjectScope;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::ops::Range;
impl TextArea {
pub(in super::super) fn capture_vim_count_digit(&mut self, event: KeyEvent) -> bool {
let KeyEvent {
code: KeyCode::Char(digit @ '0'..='9'),
modifiers: KeyModifiers::NONE,
..
} = event
else {
return false;
};
if digit == '0' && self.vim_count.is_none() {
return false;
}
let digit = digit.to_digit(10).unwrap_or_default() as usize;
self.vim_count = Some(
self.vim_count
.unwrap_or_default()
.saturating_mul(10)
.saturating_add(digit),
);
true
}
pub(in super::super) fn begin_vim_operator(&mut self, operator: VimOperator) {
self.vim_operator_count = self.vim_count.take();
self.vim_pending = super::VimPending::Operator(operator);
}
pub(in super::super) fn take_vim_count(&mut self) -> usize {
self.vim_count.take().unwrap_or(1).max(1)
}
pub(in super::super) fn take_vim_operator_count(&mut self) -> usize {
let operator = self.vim_operator_count.take().unwrap_or(1);
operator.saturating_mul(self.take_vim_count()).max(1)
}
pub(in super::super) fn clear_vim_counts(&mut self) {
self.vim_count = None;
self.vim_operator_count = None;
}
pub(in super::super) fn apply_counted_vim_normal_motion(
&mut self,
motion: VimMotion,
count: usize,
) {
self.clear_vim_register_selection();
if motion == VimMotion::LineEnd {
for _ in 1..count {
self.move_cursor_down();
}
self.set_cursor(self.vim_line_end_cursor());
return;
}
for _ in 0..count {
match motion {
VimMotion::Left => self.move_cursor_left(),
VimMotion::Right => self.move_cursor_right(),
VimMotion::Up => self.move_cursor_up(),
VimMotion::Down => self.move_cursor_down(),
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
VimMotion::WordEnd => self.set_cursor(self.vim_word_end_cursor()),
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
VimMotion::LineEnd => unreachable!("handled before count loop"),
}
}
}
pub(in super::super) fn counted_text_object_range(
&mut self,
object: VimTextObject,
scope: VimTextObjectScope,
count: usize,
) -> Option<Range<usize>> {
let original_cursor = self.cursor_pos;
let mut aggregate = self.text_object_range(object, scope)?;
for _ in 1..count {
let previous_end = aggregate.end;
let mut probe = previous_end;
let mut next_range = None;
while probe <= self.text.len() {
self.cursor_pos = probe;
if let Some(candidate) = self.text_object_range(object, scope)
&& candidate.end > previous_end
{
next_range = Some(candidate);
break;
}
if probe == self.text.len() {
break;
}
let next = self.next_atomic_boundary(probe);
if next <= probe {
break;
}
probe = next;
}
let Some(next_range) = next_range else {
break;
};
aggregate.start = aggregate.start.min(next_range.start);
aggregate.end = aggregate.end.max(next_range.end);
}
self.cursor_pos = original_cursor;
Some(aggregate)
}
pub(in super::super) fn vim_yank_current_lines(&mut self, count: usize) {
let range = self.current_line_range_with_count(count);
self.yank_line_range(range);
}
pub(in super::super) fn vim_kill_current_lines(&mut self, count: usize) {
let range = self.current_line_range_with_count(count);
self.kill_line_range(range);
}
pub(in super::super) fn vim_change_current_lines(&mut self, count: usize) {
let range = self.current_lines_change_range(count);
self.kill_line_range(range);
self.vim_mode = super::VimMode::Insert;
}
pub(in super::super) fn current_line_range_with_count(&self, count: usize) -> Range<usize> {
let start = self.beginning_of_current_line();
let mut end = self.current_line_range_with_newline().end;
for _ in 1..count {
if end >= self.text.len() {
break;
}
let eol = self.end_of_line(end);
end = if eol < self.text.len() { eol + 1 } else { eol };
}
start..end
}
fn current_lines_change_range(&self, count: usize) -> Range<usize> {
let start = self.beginning_of_current_line();
let mut end = self.end_of_current_line();
for _ in 1..count {
if end >= self.text.len() {
break;
}
end = self.end_of_line(end + 1);
}
start..end
}
}

View File

@@ -0,0 +1,169 @@
use super::super::TextArea;
use super::VimFind;
use super::VimFindKind;
use super::VimOperator;
use super::VimRepeatTarget;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use std::ops::Range;
impl VimFindKind {
pub(in super::super) fn reverse(self) -> Self {
match self {
Self::FindForward => Self::FindBackward,
Self::FindBackward => Self::FindForward,
Self::TillForward => Self::TillBackward,
Self::TillBackward => Self::TillForward,
}
}
}
impl TextArea {
pub(in super::super) fn handle_vim_find_target(
&mut self,
operator: Option<VimOperator>,
kind: VimFindKind,
event: KeyEvent,
) -> bool {
if self.vim_normal_keymap.cancel_operator.is_pressed(event)
|| self.vim_operator_keymap.cancel.is_pressed(event)
{
self.clear_vim_counts();
return true;
}
let KeyCode::Char(target) = event.code else {
self.clear_vim_counts();
return true;
};
let find = VimFind { kind, target };
self.vim_last_find = Some(find);
let count = if operator.is_some() {
self.take_vim_operator_count()
} else {
self.take_vim_count()
};
self.execute_vim_find(operator, find, count);
true
}
pub(in super::super) fn vim_normal_find_kind_for_event(
&self,
event: KeyEvent,
) -> Option<VimFindKind> {
if self.vim_normal_keymap.find_forward.is_pressed(event) {
return Some(VimFindKind::FindForward);
}
if self.vim_normal_keymap.find_backward.is_pressed(event) {
return Some(VimFindKind::FindBackward);
}
if self.vim_normal_keymap.till_forward.is_pressed(event) {
return Some(VimFindKind::TillForward);
}
if self.vim_normal_keymap.till_backward.is_pressed(event) {
return Some(VimFindKind::TillBackward);
}
None
}
pub(in super::super) fn vim_operator_find_kind_for_event(
&self,
event: KeyEvent,
) -> Option<VimFindKind> {
if self.vim_operator_keymap.find_forward.is_pressed(event) {
return Some(VimFindKind::FindForward);
}
if self.vim_operator_keymap.find_backward.is_pressed(event) {
return Some(VimFindKind::FindBackward);
}
if self.vim_operator_keymap.till_forward.is_pressed(event) {
return Some(VimFindKind::TillForward);
}
if self.vim_operator_keymap.till_backward.is_pressed(event) {
return Some(VimFindKind::TillBackward);
}
None
}
pub(in super::super) fn repeat_vim_find(
&mut self,
operator: Option<VimOperator>,
reverse: bool,
count: usize,
) {
let Some(mut find) = self.vim_last_find else {
return;
};
if reverse {
find.kind = find.kind.reverse();
}
self.execute_vim_find(operator, find, count);
}
fn execute_vim_find(&mut self, operator: Option<VimOperator>, find: VimFind, count: usize) {
if let Some(operator) = operator {
if let Some(range) = self.range_for_find(find, count) {
self.apply_vim_operator_to_range(
operator,
range,
Some(VimRepeatTarget::Find { find, count }),
);
}
} else if let Some(target) = self.target_for_find(find, count) {
self.set_cursor(target);
}
self.clear_vim_register_selection();
}
pub(in super::super) fn target_for_find(&self, find: VimFind, count: usize) -> Option<usize> {
let matched = self.find_match(find, count)?;
Some(match find.kind {
VimFindKind::FindForward | VimFindKind::FindBackward => matched,
VimFindKind::TillForward => self.prev_atomic_boundary(matched),
VimFindKind::TillBackward => self.next_atomic_boundary(matched),
})
}
pub(in super::super) fn range_for_find(
&self,
find: VimFind,
count: usize,
) -> Option<Range<usize>> {
let matched = self.find_match(find, count)?;
let range = match find.kind {
VimFindKind::FindForward => self.cursor_pos..self.next_atomic_boundary(matched),
VimFindKind::TillForward => self.cursor_pos..matched,
VimFindKind::FindBackward => matched..self.cursor_pos,
VimFindKind::TillBackward => self.next_atomic_boundary(matched)..self.cursor_pos,
};
(range.start < range.end).then_some(range)
}
fn find_match(&self, find: VimFind, count: usize) -> Option<usize> {
let line_start = self.beginning_of_current_line();
let line_end = self.end_of_current_line();
match find.kind {
VimFindKind::FindForward | VimFindKind::TillForward => {
let start = self.next_atomic_boundary(self.cursor_pos).min(line_end);
self.text[start..line_end]
.char_indices()
.map(|(offset, _)| start + offset)
.filter(|&idx| self.matches_find_target(idx, find.target))
.nth(count.saturating_sub(1))
}
VimFindKind::FindBackward | VimFindKind::TillBackward => self.text
[line_start..self.cursor_pos]
.char_indices()
.map(|(offset, _)| line_start + offset)
.rev()
.filter(|&idx| self.matches_find_target(idx, find.target))
.nth(count.saturating_sub(1)),
}
}
fn matches_find_target(&self, idx: usize, target: char) -> bool {
!self.is_inside_element(idx)
&& self.clamp_pos_to_nearest_boundary(idx) == idx
&& self.text[idx..].starts_with(target)
}
}

View File

@@ -0,0 +1,138 @@
use super::super::TextArea;
use super::VimTextObjectScope;
use std::ops::Range;
impl TextArea {
pub(in super::super) fn sentence_text_object_range(
&self,
scope: VimTextObjectScope,
) -> Option<Range<usize>> {
let inner = self.prose_range_at_cursor(self.sentence_ranges())?;
Some(self.expand_prose_range(scope, inner))
}
pub(in super::super) fn paragraph_text_object_range(
&self,
scope: VimTextObjectScope,
) -> Option<Range<usize>> {
let inner = self.prose_range_at_cursor(self.paragraph_ranges())?;
Some(self.expand_prose_range(scope, inner))
}
fn sentence_ranges(&self) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut start = self.skip_prose_separator(/*pos*/ 0);
let mut pos = start;
while pos < self.text.len() {
let Some(ch) = self.text[pos..].chars().next() else {
break;
};
let next = pos + ch.len_utf8();
if !self.is_inside_element(pos) && matches!(ch, '.' | '!' | '?') {
let end = self.sentence_closing_punctuation_end(next);
if end == self.text.len()
|| self.text[end..]
.chars()
.next()
.is_some_and(char::is_whitespace)
{
ranges.push(start..end);
start = self.skip_prose_separator(end);
pos = start;
continue;
}
}
pos = next;
}
if start < self.text.len() {
ranges.push(start..self.text.len());
}
ranges
}
fn sentence_closing_punctuation_end(&self, mut pos: usize) -> usize {
while pos < self.text.len() && !self.is_inside_element(pos) {
let Some(ch) = self.text[pos..].chars().next() else {
break;
};
if !matches!(ch, ')' | ']' | '}' | '"' | '\'' | '`') {
break;
}
pos += ch.len_utf8();
}
pos
}
fn paragraph_ranges(&self) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut block_start = None;
let mut block_end = 0;
let mut line_start = 0;
loop {
let line_end = self.text[line_start..]
.find('\n')
.map_or(self.text.len(), |offset| line_start + offset);
if self.line_has_prose_content(line_start..line_end) {
block_start.get_or_insert(line_start);
block_end = line_end;
} else if let Some(start) = block_start.take() {
ranges.push(start..block_end);
}
if line_end == self.text.len() {
break;
}
line_start = line_end + '\n'.len_utf8();
}
if let Some(start) = block_start {
ranges.push(start..block_end);
}
ranges
}
fn line_has_prose_content(&self, range: Range<usize>) -> bool {
self.text[range.clone()]
.char_indices()
.any(|(offset, ch)| !ch.is_whitespace() || self.is_inside_element(range.start + offset))
}
fn prose_range_at_cursor(&self, ranges: Vec<Range<usize>>) -> Option<Range<usize>> {
ranges
.iter()
.find(|range| range.start <= self.cursor_pos && self.cursor_pos <= range.end)
.cloned()
.or_else(|| {
ranges
.iter()
.find(|range| self.cursor_pos < range.start)
.cloned()
})
.or_else(|| ranges.last().cloned())
}
fn expand_prose_range(&self, scope: VimTextObjectScope, inner: Range<usize>) -> Range<usize> {
match scope {
VimTextObjectScope::Inner => inner,
VimTextObjectScope::Around => {
let following = self.following_whitespace_end(inner.end);
if following > inner.end {
inner.start..following
} else {
self.preceding_whitespace_start(inner.start)..inner.end
}
}
}
}
fn skip_prose_separator(&self, mut pos: usize) -> usize {
while pos < self.text.len() && !self.is_inside_element(pos) {
let Some(ch) = self.text[pos..].chars().next() else {
break;
};
if !ch.is_whitespace() {
break;
}
pos += ch.len_utf8();
}
pos
}
}

View File

@@ -0,0 +1,75 @@
use super::super::KillBufferKind;
use super::super::TextArea;
use super::VimRegisterSelection;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
impl TextArea {
pub(in super::super) fn handle_vim_register_name(&mut self, event: KeyEvent) -> bool {
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
self.clear_vim_register_selection();
self.clear_vim_counts();
return true;
}
let KeyCode::Char(name) = event.code else {
self.clear_vim_register_selection();
self.clear_vim_counts();
return true;
};
if !name.is_ascii_alphabetic() {
self.clear_vim_register_selection();
self.clear_vim_counts();
return true;
}
self.vim_selected_register = Some(VimRegisterSelection {
name: name.to_ascii_lowercase(),
append: name.is_ascii_uppercase(),
});
true
}
pub(in super::super) fn clear_vim_register_selection(&mut self) {
self.vim_selected_register = None;
}
pub(in super::super) fn write_selected_vim_register(
&mut self,
text: &str,
kind: KillBufferKind,
) {
let Some(selection) = self.vim_selected_register.take() else {
return;
};
if selection.append {
let entry = self
.vim_named_registers
.entry(selection.name)
.or_insert_with(|| (String::new(), kind));
entry.0.push_str(text);
entry.1 = kind;
} else {
self.vim_named_registers
.insert(selection.name, (text.to_string(), kind));
}
}
pub(in super::super) fn vim_paste_after_cursor_counted(&mut self, count: usize) {
let source = if let Some(selection) = self.vim_selected_register.take() {
self.vim_named_registers
.get(&selection.name)
.cloned()
.unwrap_or_else(|| (String::new(), KillBufferKind::Characterwise))
} else {
(self.kill_buffer.clone(), self.kill_buffer_kind)
};
if source.0.is_empty() {
return;
}
let previous_len = self.text.len();
for _ in 0..count {
self.paste_text_after_cursor(&source.0, source.1);
}
self.record_vim_paste_if_changed(source.0, source.1, count, previous_len);
}
}

View File

@@ -0,0 +1,337 @@
use super::super::KillBufferKind;
use super::super::TextArea;
use super::VimInsertEdit;
use super::VimInsertRecording;
use super::VimMode;
use super::VimOperator;
use super::VimRepeatChange;
use super::VimRepeatTarget;
use super::VimVisualKind;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::ops::Range;
impl TextArea {
pub(in super::super) fn invalidate_vim_repeat_change(&mut self) {
if !self.vim_replaying_change {
self.vim_last_change = None;
self.vim_insert_recording = None;
}
}
pub(in super::super) fn record_vim_delete_if_changed(
&mut self,
target: VimRepeatTarget,
previous_len: usize,
) {
if !self.vim_replaying_change && self.text.len() < previous_len {
self.vim_last_change = Some(VimRepeatChange::Delete(target));
self.vim_insert_recording = None;
}
}
pub(in super::super) fn begin_vim_change_recording(&mut self, target: VimRepeatTarget) {
if !self.vim_replaying_change {
self.vim_insert_recording = Some(VimInsertRecording {
target,
edits: Vec::new(),
});
}
}
pub(in super::super) fn complete_vim_change_recording(&mut self) {
if self.vim_replaying_change {
return;
}
if let Some(recording) = self.vim_insert_recording.take() {
self.vim_last_change = Some(VimRepeatChange::Change {
target: recording.target,
edits: recording.edits,
});
}
}
pub(in super::super) fn record_vim_paste_if_changed(
&mut self,
text: String,
kind: KillBufferKind,
count: usize,
previous_len: usize,
) {
if !self.vim_replaying_change && self.text.len() > previous_len {
self.vim_last_change = Some(VimRepeatChange::Paste { text, kind, count });
self.vim_insert_recording = None;
}
}
pub(in super::super) fn record_vim_insert_edit(&mut self, edit: Option<VimInsertEdit>) {
if self.vim_replaying_change {
return;
}
if let Some(recording) = self.vim_insert_recording.as_mut()
&& let Some(edit) = edit
{
recording.edits.push(edit);
}
}
pub(in super::super) fn vim_insert_edit_for_event(
&self,
event: KeyEvent,
) -> Option<VimInsertEdit> {
let keymap = &self.editor_keymap;
if keymap.insert_newline.is_pressed(event) {
return Some(VimInsertEdit::Insert("\n".to_string()));
}
if keymap.delete_backward_word.is_pressed(event) {
return Some(VimInsertEdit::DeleteBackwardWord);
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = event
&& is_altgr(modifiers)
{
return Some(VimInsertEdit::Insert(c.to_string()));
}
if keymap.delete_backward.is_pressed(event) {
return Some(VimInsertEdit::DeleteBackward);
}
if keymap.delete_forward_word.is_pressed(event) {
return Some(VimInsertEdit::DeleteForwardWord);
}
if keymap.delete_forward.is_pressed(event) {
return Some(VimInsertEdit::DeleteForward);
}
if keymap.kill_line_start.is_pressed(event) {
return Some(VimInsertEdit::KillLineStart);
}
if keymap.kill_whole_line.is_pressed(event) {
return Some(VimInsertEdit::KillWholeLine);
}
if keymap.kill_line_end.is_pressed(event) {
return Some(VimInsertEdit::KillLineEnd);
}
if keymap.yank.is_pressed(event) {
return (!self.kill_buffer.is_empty())
.then(|| VimInsertEdit::Insert(self.kill_buffer.clone()));
}
if keymap.move_word_left.is_pressed(event) {
return Some(VimInsertEdit::MoveWordLeft);
}
if keymap.move_word_right.is_pressed(event) {
return Some(VimInsertEdit::MoveWordRight);
}
if keymap.move_left.is_pressed(event) {
return Some(VimInsertEdit::MoveLeft);
}
if keymap.move_right.is_pressed(event) {
return Some(VimInsertEdit::MoveRight);
}
if keymap.move_up.is_pressed(event) {
return Some(VimInsertEdit::MoveUp);
}
if keymap.move_down.is_pressed(event) {
return Some(VimInsertEdit::MoveDown);
}
if keymap.move_line_start.is_pressed(event) {
let move_up_at_bol = matches!(
event,
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
}
);
return Some(VimInsertEdit::MoveLineStart { move_up_at_bol });
}
if keymap.move_line_end.is_pressed(event) {
let move_down_at_eol = matches!(
event,
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
}
);
return Some(VimInsertEdit::MoveLineEnd { move_down_at_eol });
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} = event
&& !c.is_ascii_control()
{
return Some(VimInsertEdit::Insert(c.to_string()));
}
None
}
pub(in super::super) fn repeat_vim_change(&mut self) {
let Some(change) = self.vim_last_change.clone() else {
return;
};
self.vim_replaying_change = true;
match change {
VimRepeatChange::Delete(target) => {
self.execute_vim_repeat_target(VimOperator::Delete, target);
}
VimRepeatChange::Change { target, edits } => {
if self.execute_vim_repeat_target(VimOperator::Change, target) {
for edit in edits {
self.apply_vim_insert_edit(edit);
}
self.exit_vim_insert_after_change();
}
}
VimRepeatChange::Paste { text, kind, count } => {
for _ in 0..count {
self.paste_text_after_cursor(&text, kind);
}
}
}
self.vim_replaying_change = false;
self.clear_vim_counts();
self.clear_vim_register_selection();
}
fn execute_vim_repeat_target(
&mut self,
operator: VimOperator,
target: VimRepeatTarget,
) -> bool {
let previous_len = self.text.len();
match target {
VimRepeatTarget::Characters(count) => {
self.delete_forward_kill(count);
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
}
}
VimRepeatTarget::Lines(count) => match operator {
VimOperator::Delete => self.vim_kill_current_lines(count),
VimOperator::Change => self.vim_change_current_lines(count),
VimOperator::Yank => unreachable!("repeat changes never yank"),
},
VimRepeatTarget::Motion { motion, count } => {
self.apply_vim_operator(operator, motion, count);
}
VimRepeatTarget::TextObject {
object,
scope,
count,
} => {
if let Some(range) = self.counted_text_object_range(object, scope, count) {
self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None);
}
}
VimRepeatTarget::Find { find, count } => {
if let Some(range) = self.range_for_find(find, count) {
self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None);
}
}
VimRepeatTarget::Visual { kind, atomic_units } => {
if let Some(mut range) = self.visual_repeat_range(kind, atomic_units) {
if operator == VimOperator::Change
&& kind == VimVisualKind::Linewise
&& range.end > range.start
&& self.text[..range.end].ends_with('\n')
{
range.end -= 1;
}
let buffer_kind = if kind == VimVisualKind::Linewise {
KillBufferKind::Linewise
} else {
KillBufferKind::Characterwise
};
self.kill_range_with_kind(range, buffer_kind);
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
}
}
}
}
self.text.len() < previous_len
|| operator == VimOperator::Change && self.vim_mode == VimMode::Insert
}
pub(in super::super) fn visual_repeat_target(
&self,
kind: VimVisualKind,
range: Range<usize>,
) -> VimRepeatTarget {
let atomic_units = match kind {
VimVisualKind::Characterwise => {
let mut pos = range.start;
let mut units = 0;
while pos < range.end {
let next = self.next_atomic_boundary(pos);
if next <= pos {
break;
}
units += 1;
pos = next;
}
units.max(1)
}
VimVisualKind::Linewise => self.text[range].lines().count().max(1),
};
VimRepeatTarget::Visual { kind, atomic_units }
}
fn visual_repeat_range(
&self,
kind: VimVisualKind,
atomic_units: usize,
) -> Option<Range<usize>> {
let range = match kind {
VimVisualKind::Characterwise => {
let mut end = self.cursor_pos;
for _ in 0..atomic_units {
end = self.next_atomic_boundary(end);
}
self.cursor_pos..end
}
VimVisualKind::Linewise => self.current_line_range_with_count(atomic_units),
};
(range.start < range.end).then(|| self.expand_range_to_element_boundaries(range))
}
fn apply_vim_insert_edit(&mut self, edit: VimInsertEdit) {
match edit {
VimInsertEdit::Insert(text) => self.insert_str(&text),
VimInsertEdit::DeleteBackward => self.delete_backward(/*n*/ 1),
VimInsertEdit::DeleteForward => self.delete_forward(/*n*/ 1),
VimInsertEdit::DeleteBackwardWord => self.delete_backward_word(),
VimInsertEdit::DeleteForwardWord => self.delete_forward_word(),
VimInsertEdit::KillLineStart => self.kill_to_beginning_of_line(),
VimInsertEdit::KillWholeLine => self.kill_current_line(),
VimInsertEdit::KillLineEnd => self.kill_to_end_of_line(),
VimInsertEdit::MoveLeft => self.move_cursor_left(),
VimInsertEdit::MoveRight => self.move_cursor_right(),
VimInsertEdit::MoveUp => self.move_cursor_up(),
VimInsertEdit::MoveDown => self.move_cursor_down(),
VimInsertEdit::MoveWordLeft => self.set_cursor(self.beginning_of_previous_word()),
VimInsertEdit::MoveWordRight => self.set_cursor(self.end_of_next_word()),
VimInsertEdit::MoveLineStart { move_up_at_bol } => {
self.move_cursor_to_beginning_of_line(move_up_at_bol);
}
VimInsertEdit::MoveLineEnd { move_down_at_eol } => {
self.move_cursor_to_end_of_line(move_down_at_eol);
}
}
}
pub(in super::super) fn exit_vim_insert_after_change(&mut self) {
let bol = self.beginning_of_current_line();
if self.cursor_pos > bol {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
}
self.enter_vim_normal_mode();
}
}

View File

@@ -0,0 +1,148 @@
use super::super::TextArea;
use super::VimTextObjectScope;
use std::ops::Range;
#[derive(Debug)]
enum ParsedTag {
Open { name: String, range: Range<usize> },
Close { name: String, range: Range<usize> },
Ignored { end: usize },
}
impl ParsedTag {
fn end(&self) -> usize {
match self {
Self::Open { range, .. } | Self::Close { range, .. } => range.end,
Self::Ignored { end } => *end,
}
}
}
impl TextArea {
pub(in super::super) fn tag_text_object_range(
&self,
scope: VimTextObjectScope,
) -> Option<Range<usize>> {
let mut open_tags: Vec<(String, Range<usize>)> = Vec::new();
let mut best = None;
let mut pos = 0;
while let Some(offset) = self.text[pos..].find('<') {
let start = pos + offset;
if self.is_inside_element(start) {
pos = start + '<'.len_utf8();
continue;
}
let Some(tag) = self.parse_tag(start) else {
pos = start + '<'.len_utf8();
continue;
};
pos = tag.end();
match tag {
ParsedTag::Open { name, range } => open_tags.push((name, range)),
ParsedTag::Close { name, range } => {
let Some((open_name, open_range)) = open_tags.last() else {
continue;
};
if *open_name != name {
continue;
}
let open_range = open_range.clone();
open_tags.pop();
if open_range.start <= self.cursor_pos && self.cursor_pos <= range.end {
let candidate = match scope {
VimTextObjectScope::Inner => open_range.end..range.start,
VimTextObjectScope::Around => open_range.start..range.end,
};
if best
.as_ref()
.is_none_or(|current: &Range<usize>| candidate.len() < current.len())
{
best = Some(candidate);
}
}
}
ParsedTag::Ignored { .. } => {}
}
}
best
}
fn parse_tag(&self, start: usize) -> Option<ParsedTag> {
let rest = &self.text[start..];
if rest.starts_with("<!--") {
return rest.find("-->").map(|offset| ParsedTag::Ignored {
end: start + offset + "-->".len(),
});
}
if rest.starts_with("<!") || rest.starts_with("<?") {
return self
.tag_end(start + '<'.len_utf8())
.map(|end| ParsedTag::Ignored { end });
}
let mut pos = start + '<'.len_utf8();
let closing = self.text[pos..].starts_with('/');
if closing {
pos += '/'.len_utf8();
}
let name_start = pos;
let first = self.text[pos..].chars().next()?;
if !is_tag_name_start(first) {
return None;
}
pos += first.len_utf8();
while let Some(ch) = self.text[pos..].chars().next() {
if !is_tag_name_char(ch) {
break;
}
pos += ch.len_utf8();
}
let name = self.text[name_start..pos].to_string();
let end = self.tag_end(pos)?;
if closing {
if !self.text[pos..end - '>'.len_utf8()].trim().is_empty() {
return None;
}
return Some(ParsedTag::Close {
name,
range: start..end,
});
}
let body = self.text[pos..end - '>'.len_utf8()].trim_end();
if body.ends_with('/') {
return Some(ParsedTag::Ignored { end });
}
Some(ParsedTag::Open {
name,
range: start..end,
})
}
fn tag_end(&self, mut pos: usize) -> Option<usize> {
let mut quote = None;
while let Some(ch) = self.text[pos..].chars().next() {
if self.is_inside_element(pos) {
return None;
}
if let Some(open_quote) = quote {
if ch == open_quote {
quote = None;
}
} else if matches!(ch, '"' | '\'') {
quote = Some(ch);
} else if ch == '>' {
return Some(pos + ch.len_utf8());
}
pos += ch.len_utf8();
}
None
}
}
fn is_tag_name_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_'
}
fn is_tag_name_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':')
}

View File

@@ -0,0 +1,265 @@
use super::super::KillBufferKind;
use super::super::TextArea;
use super::VimMode;
use super::VimMotion;
use super::VimOperator;
use super::VimPending;
use super::VimTextObjectScope;
use super::VimVisualKind;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyEvent;
use std::ops::Range;
impl TextArea {
pub(in super::super) fn enter_vim_visual_mode(&mut self, kind: VimVisualKind) {
self.vim_visual_anchor = Some(self.cursor_pos);
self.vim_mode = VimMode::Visual(kind);
self.vim_pending = VimPending::None;
self.clear_vim_counts();
self.clear_vim_register_selection();
}
pub(in super::super) fn clear_vim_visual_selection(&mut self) {
self.vim_visual_anchor = None;
}
pub(crate) fn vim_visual_selection_range(&self) -> Option<Range<usize>> {
let anchor = self.vim_visual_anchor?;
let VimMode::Visual(kind) = self.vim_mode else {
return None;
};
if self.text.is_empty() {
return None;
}
let range = match kind {
VimVisualKind::Characterwise => {
let start = anchor.min(self.cursor_pos);
let end = self.next_atomic_boundary(anchor.max(self.cursor_pos));
start..end
}
VimVisualKind::Linewise => {
let start = self.beginning_of_line(anchor.min(self.cursor_pos));
let line_end = self.end_of_line(anchor.max(self.cursor_pos));
let end = if line_end < self.text.len() {
line_end + 1
} else {
line_end
};
start..end
}
};
(range.start < range.end).then(|| self.expand_range_to_element_boundaries(range))
}
pub(in super::super) fn handle_vim_visual(&mut self, event: KeyEvent) {
if matches!(self.vim_pending, VimPending::None) && self.capture_vim_count_digit(event) {
return;
}
let pending = std::mem::replace(&mut self.vim_pending, VimPending::None);
match pending {
VimPending::None => {}
VimPending::Register => {
self.handle_vim_register_name(event);
return;
}
VimPending::Find {
operator: None,
kind,
} => {
self.handle_vim_find_target(/*operator*/ None, kind, event);
return;
}
VimPending::VisualTextObject { scope } => {
self.handle_vim_visual_text_object(scope, event);
return;
}
VimPending::Operator(_)
| VimPending::TextObject { .. }
| VimPending::Find {
operator: Some(_), ..
} => {
self.exit_vim_visual_mode();
return;
}
}
if self.vim_visual_keymap.cancel.is_pressed(event) {
self.exit_vim_visual_mode();
return;
}
if self.vim_normal_keymap.select_register.is_pressed(event) {
self.vim_pending = VimPending::Register;
return;
}
if self.vim_normal_keymap.enter_visual.is_pressed(event) {
if self.vim_mode == VimMode::Visual(VimVisualKind::Characterwise) {
self.exit_vim_visual_mode();
} else {
self.enter_vim_visual_mode(VimVisualKind::Characterwise);
}
return;
}
if self.vim_normal_keymap.enter_visual_line.is_pressed(event) {
if self.vim_mode == VimMode::Visual(VimVisualKind::Linewise) {
self.exit_vim_visual_mode();
} else {
self.enter_vim_visual_mode(VimVisualKind::Linewise);
}
return;
}
if self.vim_visual_keymap.delete.is_pressed(event) {
self.apply_vim_visual_operator(VimOperator::Delete);
return;
}
if self.vim_visual_keymap.yank.is_pressed(event) {
self.apply_vim_visual_operator(VimOperator::Yank);
return;
}
if self.vim_visual_keymap.change.is_pressed(event) {
self.apply_vim_visual_operator(VimOperator::Change);
return;
}
if let Some(scope) = self.vim_text_object_scope_for_event(event) {
self.vim_pending = VimPending::VisualTextObject { scope };
return;
}
if let Some(kind) = self.vim_normal_find_kind_for_event(event) {
self.vim_pending = VimPending::Find {
operator: None,
kind,
};
return;
}
if self.vim_normal_keymap.repeat_find.is_pressed(event) {
let count = self.take_vim_count();
self.repeat_vim_find(/*operator*/ None, /*reverse*/ false, count);
return;
}
if self.vim_normal_keymap.repeat_find_reverse.is_pressed(event) {
let count = self.take_vim_count();
self.repeat_vim_find(/*operator*/ None, /*reverse*/ true, count);
return;
}
if let Some(motion) = self.vim_visual_motion_for_event(event) {
let count = self.take_vim_count();
self.apply_counted_vim_normal_motion(motion, count);
return;
}
self.clear_vim_counts();
self.clear_vim_register_selection();
}
fn vim_visual_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
[
(self.vim_normal_keymap.move_left.as_slice(), VimMotion::Left),
(
self.vim_normal_keymap.move_right.as_slice(),
VimMotion::Right,
),
(self.vim_normal_keymap.move_up.as_slice(), VimMotion::Up),
(self.vim_normal_keymap.move_down.as_slice(), VimMotion::Down),
(
self.vim_normal_keymap.move_word_forward.as_slice(),
VimMotion::WordForward,
),
(
self.vim_normal_keymap.move_word_backward.as_slice(),
VimMotion::WordBackward,
),
(
self.vim_normal_keymap.move_word_end.as_slice(),
VimMotion::WordEnd,
),
(
self.vim_normal_keymap.move_line_start.as_slice(),
VimMotion::LineStart,
),
(
self.vim_normal_keymap.move_line_end.as_slice(),
VimMotion::LineEnd,
),
]
.into_iter()
.find_map(|(bindings, motion)| bindings.is_pressed(event).then_some(motion))
}
fn handle_vim_visual_text_object(&mut self, scope: VimTextObjectScope, event: KeyEvent) {
if self.vim_text_object_keymap.cancel.is_pressed(event) {
self.clear_vim_counts();
return;
}
let Some(object) = self.vim_text_object_for_event(event) else {
self.clear_vim_counts();
return;
};
let count = self.take_vim_count();
if let Some(range) = self.counted_text_object_range(object, scope, count) {
self.select_vim_visual_range(range);
}
}
fn select_vim_visual_range(&mut self, range: Range<usize>) {
let range = self.expand_range_to_element_boundaries(range);
if range.start >= range.end {
return;
}
self.vim_visual_anchor = Some(range.start);
self.set_cursor(self.prev_atomic_boundary(range.end));
}
fn apply_vim_visual_operator(&mut self, operator: VimOperator) {
let Some(mut range) = self.vim_visual_selection_range() else {
self.exit_vim_visual_mode();
return;
};
let linewise = self.vim_mode == VimMode::Visual(VimVisualKind::Linewise);
if operator == VimOperator::Change
&& linewise
&& range.end > range.start
&& self.text[..range.end].ends_with('\n')
{
range.end -= 1;
}
let kind = if linewise {
KillBufferKind::Linewise
} else {
KillBufferKind::Characterwise
};
let repeat_target = self.visual_repeat_target(
if linewise {
VimVisualKind::Linewise
} else {
VimVisualKind::Characterwise
},
range.clone(),
);
let previous_len = self.text.len();
let start = range.start;
match operator {
VimOperator::Delete => self.kill_range_with_kind(range, kind),
VimOperator::Yank => self.yank_range_with_kind(range, kind),
VimOperator::Change => self.kill_range_with_kind(range, kind),
}
self.clear_vim_visual_selection();
self.clear_vim_counts();
self.clear_vim_register_selection();
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
self.begin_vim_change_recording(repeat_target);
} else {
self.vim_mode = VimMode::Normal;
self.set_cursor(start.min(self.vim_normal_end_cursor()));
if operator == VimOperator::Delete {
self.record_vim_delete_if_changed(repeat_target, previous_len);
}
}
}
fn exit_vim_visual_mode(&mut self) {
self.vim_mode = VimMode::Normal;
self.vim_pending = VimPending::None;
self.clear_vim_visual_selection();
self.clear_vim_counts();
self.clear_vim_register_selection();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -133,15 +133,30 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "move_word_end", "Move to the end of the current or next word."),
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
action("vim_normal", "Vim normal", "find_forward", "Find the next target character on the current line."),
action("vim_normal", "Vim normal", "find_backward", "Find the previous target character on the current line."),
action("vim_normal", "Vim normal", "till_forward", "Move until before the next target character on the current line."),
action("vim_normal", "Vim normal", "till_backward", "Move until after the previous target character on the current line."),
action("vim_normal", "Vim normal", "repeat_find", "Repeat the latest find or till motion."),
action("vim_normal", "Vim normal", "repeat_find_reverse", "Repeat the latest find or till motion in reverse."),
action("vim_normal", "Vim normal", "repeat_change", "Repeat the most recent text-changing Vim command."),
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
action("vim_normal", "Vim normal", "substitute_char", "Substitute the character under the cursor and enter insert mode."),
action("vim_normal", "Vim normal", "substitute_line", "Substitute the current line and enter insert mode."),
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
action("vim_normal", "Vim normal", "select_register", "Select a named register for the next command."),
action("vim_normal", "Vim normal", "enter_visual", "Enter characterwise visual mode."),
action("vim_normal", "Vim normal", "enter_visual_line", "Enter linewise visual mode."),
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
action("vim_operator", "Vim operator", "change_line", "Repeat change operator to change the whole line."),
action("vim_operator", "Vim operator", "motion_left", "Operator motion left."),
action("vim_operator", "Vim operator", "motion_right", "Operator motion right."),
action("vim_operator", "Vim operator", "motion_up", "Operator motion up."),
@@ -151,7 +166,31 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."),
action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."),
action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."),
action("vim_operator", "Vim operator", "find_forward", "Operator motion to the next target character."),
action("vim_operator", "Vim operator", "find_backward", "Operator motion to the previous target character."),
action("vim_operator", "Vim operator", "till_forward", "Operator motion until before the next target character."),
action("vim_operator", "Vim operator", "till_backward", "Operator motion until after the previous target character."),
action("vim_operator", "Vim operator", "repeat_find", "Repeat the latest find or till motion for this operator."),
action("vim_operator", "Vim operator", "repeat_find_reverse", "Repeat the latest find or till motion in reverse for this operator."),
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."),
action("vim_text_object", "Vim text object", "word", "Target the current word."),
action("vim_text_object", "Vim text object", "big_word", "Target the current WORD."),
action("vim_text_object", "Vim text object", "parentheses", "Target enclosing parentheses."),
action("vim_text_object", "Vim text object", "brackets", "Target enclosing brackets."),
action("vim_text_object", "Vim text object", "braces", "Target enclosing braces."),
action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."),
action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."),
action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."),
action("vim_text_object", "Vim text object", "sentence", "Target the current sentence."),
action("vim_text_object", "Vim text object", "paragraph", "Target the current paragraph."),
action("vim_text_object", "Vim text object", "tag", "Target the enclosing paired tag."),
action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."),
action("vim_visual", "Vim visual", "delete", "Delete the active selection."),
action("vim_visual", "Vim visual", "yank", "Yank the active selection."),
action("vim_visual", "Vim visual", "change", "Change the active selection and enter insert mode."),
action("vim_visual", "Vim visual", "cancel", "Cancel the active selection."),
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
action("pager", "Pager", "page_up", "Scroll up by one page."),
@@ -261,15 +300,30 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "move_word_end") => Some(&mut keymap.vim_normal.move_word_end),
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
("vim_normal", "find_forward") => Some(&mut keymap.vim_normal.find_forward),
("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", "repeat_find") => Some(&mut keymap.vim_normal.repeat_find),
("vim_normal", "repeat_find_reverse") => Some(&mut keymap.vim_normal.repeat_find_reverse),
("vim_normal", "repeat_change") => Some(&mut keymap.vim_normal.repeat_change),
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
("vim_normal", "substitute_line") => Some(&mut keymap.vim_normal.substitute_line),
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after),
("vim_normal", "select_register") => Some(&mut keymap.vim_normal.select_register),
("vim_normal", "enter_visual") => Some(&mut keymap.vim_normal.enter_visual),
("vim_normal", "enter_visual_line") => Some(&mut keymap.vim_normal.enter_visual_line),
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator),
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line),
("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line),
("vim_operator", "change_line") => Some(&mut keymap.vim_operator.change_line),
("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left),
("vim_operator", "motion_right") => Some(&mut keymap.vim_operator.motion_right),
("vim_operator", "motion_up") => Some(&mut keymap.vim_operator.motion_up),
@@ -279,7 +333,31 @@ pub(super) fn binding_slot<'a>(
("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end),
("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start),
("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end),
("vim_operator", "find_forward") => Some(&mut keymap.vim_operator.find_forward),
("vim_operator", "find_backward") => Some(&mut keymap.vim_operator.find_backward),
("vim_operator", "till_forward") => Some(&mut keymap.vim_operator.till_forward),
("vim_operator", "till_backward") => Some(&mut keymap.vim_operator.till_backward),
("vim_operator", "repeat_find") => Some(&mut keymap.vim_operator.repeat_find),
("vim_operator", "repeat_find_reverse") => Some(&mut keymap.vim_operator.repeat_find_reverse),
("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),
("vim_text_object", "word") => Some(&mut keymap.vim_text_object.word),
("vim_text_object", "big_word") => Some(&mut keymap.vim_text_object.big_word),
("vim_text_object", "parentheses") => Some(&mut keymap.vim_text_object.parentheses),
("vim_text_object", "brackets") => Some(&mut keymap.vim_text_object.brackets),
("vim_text_object", "braces") => Some(&mut keymap.vim_text_object.braces),
("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote),
("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote),
("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick),
("vim_text_object", "sentence") => Some(&mut keymap.vim_text_object.sentence),
("vim_text_object", "paragraph") => Some(&mut keymap.vim_text_object.paragraph),
("vim_text_object", "tag") => Some(&mut keymap.vim_text_object.tag),
("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel),
("vim_visual", "delete") => Some(&mut keymap.vim_visual.delete),
("vim_visual", "yank") => Some(&mut keymap.vim_visual.yank),
("vim_visual", "change") => Some(&mut keymap.vim_visual.change),
("vim_visual", "cancel") => Some(&mut keymap.vim_visual.cancel),
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
("pager", "page_up") => Some(&mut keymap.pager.page_up),
@@ -371,15 +449,30 @@ pub(super) fn bindings_for_action<'a>(
("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()),
("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()),
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
("vim_normal", "find_forward") => Some(runtime_keymap.vim_normal.find_forward.as_slice()),
("vim_normal", "find_backward") => Some(runtime_keymap.vim_normal.find_backward.as_slice()),
("vim_normal", "till_forward") => Some(runtime_keymap.vim_normal.till_forward.as_slice()),
("vim_normal", "till_backward") => Some(runtime_keymap.vim_normal.till_backward.as_slice()),
("vim_normal", "repeat_find") => Some(runtime_keymap.vim_normal.repeat_find.as_slice()),
("vim_normal", "repeat_find_reverse") => Some(runtime_keymap.vim_normal.repeat_find_reverse.as_slice()),
("vim_normal", "repeat_change") => Some(runtime_keymap.vim_normal.repeat_change.as_slice()),
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()),
("vim_normal", "substitute_line") => Some(runtime_keymap.vim_normal.substitute_line.as_slice()),
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
("vim_normal", "select_register") => Some(runtime_keymap.vim_normal.select_register.as_slice()),
("vim_normal", "enter_visual") => Some(runtime_keymap.vim_normal.enter_visual.as_slice()),
("vim_normal", "enter_visual_line") => Some(runtime_keymap.vim_normal.enter_visual_line.as_slice()),
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()),
("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()),
("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()),
("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()),
("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()),
("vim_operator", "change_line") => Some(runtime_keymap.vim_operator.change_line.as_slice()),
("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()),
("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()),
("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()),
@@ -389,7 +482,31 @@ pub(super) fn bindings_for_action<'a>(
("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()),
("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()),
("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()),
("vim_operator", "find_forward") => Some(runtime_keymap.vim_operator.find_forward.as_slice()),
("vim_operator", "find_backward") => Some(runtime_keymap.vim_operator.find_backward.as_slice()),
("vim_operator", "till_forward") => Some(runtime_keymap.vim_operator.till_forward.as_slice()),
("vim_operator", "till_backward") => Some(runtime_keymap.vim_operator.till_backward.as_slice()),
("vim_operator", "repeat_find") => Some(runtime_keymap.vim_operator.repeat_find.as_slice()),
("vim_operator", "repeat_find_reverse") => Some(runtime_keymap.vim_operator.repeat_find_reverse.as_slice()),
("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()),
("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()),
("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()),
("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()),
("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()),
("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()),
("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()),
("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()),
("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()),
("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()),
("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()),
("vim_text_object", "sentence") => Some(runtime_keymap.vim_text_object.sentence.as_slice()),
("vim_text_object", "paragraph") => Some(runtime_keymap.vim_text_object.paragraph.as_slice()),
("vim_text_object", "tag") => Some(runtime_keymap.vim_text_object.tag.as_slice()),
("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()),
("vim_visual", "delete") => Some(runtime_keymap.vim_visual.delete.as_slice()),
("vim_visual", "yank") => Some(runtime_keymap.vim_visual.yank.as_slice()),
("vim_visual", "change") => Some(runtime_keymap.vim_visual.change.as_slice()),
("vim_visual", "cancel") => Some(runtime_keymap.vim_visual.cancel.as_slice()),
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),

View File

@@ -103,8 +103,13 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
KeymapContextTab {
id: "vim-shortcuts",
label: "Vim",
description: "Vim normal-mode and operator shortcuts.",
contexts: &["vim_normal", "vim_operator"],
description: "Vim normal, operator, text-object, and visual shortcuts.",
contexts: &[
"vim_normal",
"vim_operator",
"vim_text_object",
"vim_visual",
],
},
KeymapContextTab {
id: "navigation-shortcuts",

View File

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

View File

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

View File

@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (93 selectable)
tab: All (132 selectable)
tab: Common (19 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (2) (2 selectable)
tab: App (9 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (34 selectable)
tab: Vim (73 selectable)
tab: Navigation (20 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)

View File

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

View File

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