Compare commits

...

12 Commits

Author SHA1 Message Date
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
16 changed files with 2822 additions and 95 deletions

View File

@@ -219,10 +219,28 @@ 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>,
/// 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`).
@@ -231,14 +249,16 @@ pub struct TuiVimNormalKeymap {
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 +268,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 +288,57 @@ 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>,
}
/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -374,6 +443,8 @@ pub struct TuiKeymap {
#[serde(default)]
pub vim_operator: TuiVimOperatorKeymap,
#[serde(default)]
pub vim_text_object: TuiVimTextObjectKeymap,
#[serde(default)]
pub pager: TuiPagerKeymap,
#[serde(default)]
pub list: TuiListKeymap,
@@ -560,6 +631,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,12 @@
"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,
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
@@ -2840,13 +2843,23 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_find": null,
"repeat_find_reverse": 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 +2869,27 @@
"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
}
},
"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 +3523,12 @@
"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,
"find_backward": null,
"find_forward": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
@@ -3506,8 +3542,15 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_find": null,
"repeat_find_reverse": 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 +3562,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 +3575,35 @@
"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
}
}
},
"type": "object"
@@ -3755,6 +3828,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 +3860,22 @@
],
"description": "Enter insert mode at cursor (`i`)."
},
"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 +3980,30 @@
],
"description": "Paste after cursor (`p`)."
},
"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 (`,`)."
},
"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 +4020,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 +4065,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 +4075,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 +4091,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 +4179,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 +4238,109 @@
},
"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"
},
"UriBasedFileOpener": {
"oneOf": [
{

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,357 @@
use super::TextArea;
use super::split_word_pieces;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyEvent;
use std::ops::Range;
mod find;
mod prose;
mod tag;
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimOperator {
Delete,
Yank,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimPending {
None,
Operator(VimOperator),
TextObject {
operator: VimOperator,
scope: VimTextObjectScope,
},
Find {
operator: Option<VimOperator>,
kind: VimFindKind,
},
}
#[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, 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,149 @@
use super::super::TextArea;
use super::VimFind;
use super::VimFindKind;
use super::VimOperator;
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)
{
return true;
}
let KeyCode::Char(target) = event.code else {
return true;
};
let find = VimFind { kind, target };
self.vim_last_find = Some(find);
self.execute_vim_find(operator, find);
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,
) {
let Some(mut find) = self.vim_last_find else {
return;
};
if reverse {
find.kind = find.kind.reverse();
}
self.execute_vim_find(operator, find);
}
fn execute_vim_find(&mut self, operator: Option<VimOperator>, find: VimFind) {
if let Some(operator) = operator {
if let Some(range) = self.range_for_find(find) {
self.apply_vim_operator_to_range(operator, range);
}
} else if let Some(target) = self.target_for_find(find) {
self.set_cursor(target);
}
}
pub(in super::super) fn target_for_find(&self, find: VimFind) -> Option<usize> {
let matched = self.find_match(find)?;
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) -> Option<Range<usize>> {
let matched = self.find_match(find)?;
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) -> 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)
.find(|&idx| self.matches_find_target(idx, find.target))
}
VimFindKind::FindBackward | VimFindKind::TillBackward => self.text
[line_start..self.cursor_pos]
.char_indices()
.map(|(offset, _)| line_start + offset)
.rev()
.find(|&idx| self.matches_find_target(idx, find.target)),
}
}
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,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

@@ -46,6 +46,7 @@ pub(crate) struct RuntimeKeymap {
pub(crate) editor: EditorKeymap,
pub(crate) vim_normal: VimNormalKeymap,
pub(crate) vim_operator: VimOperatorKeymap,
pub(crate) vim_text_object: VimTextObjectKeymap,
pub(crate) pager: PagerKeymap,
pub(crate) list: ListKeymap,
pub(crate) approval: ApprovalKeymap,
@@ -133,7 +134,7 @@ pub(crate) struct EditorKeymap {
///
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
/// or editing key here either moves the cursor, triggers an operator-pending
/// state (via `start_delete_operator` / `start_yank_operator`), or transitions
/// state (via an operator-start action), or transitions
/// to insert mode. Default bindings include both `shift(letter)` and
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
/// handle cross-terminal shift-reporting inconsistencies.
@@ -154,16 +155,26 @@ pub(crate) struct VimNormalKeymap {
pub(crate) move_word_end: Vec<KeyBinding>,
pub(crate) move_line_start: Vec<KeyBinding>,
pub(crate) move_line_end: Vec<KeyBinding>,
pub(crate) find_forward: Vec<KeyBinding>,
pub(crate) find_backward: Vec<KeyBinding>,
pub(crate) till_forward: Vec<KeyBinding>,
pub(crate) till_backward: Vec<KeyBinding>,
pub(crate) repeat_find: Vec<KeyBinding>,
pub(crate) repeat_find_reverse: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
pub(crate) substitute_char: Vec<KeyBinding>,
pub(crate) substitute_line: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) paste_after: Vec<KeyBinding>,
pub(crate) start_delete_operator: Vec<KeyBinding>,
pub(crate) start_yank_operator: Vec<KeyBinding>,
pub(crate) start_change_operator: Vec<KeyBinding>,
pub(crate) cancel_operator: Vec<KeyBinding>,
}
/// Vim operator-pending keybindings active after `d` or `y` in normal mode.
/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode.
///
/// When an operator (`start_delete_operator` or `start_yank_operator`) is
/// pressed, the next keypress is matched against this context to determine the
@@ -173,6 +184,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) struct VimOperatorKeymap {
pub(crate) delete_line: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) change_line: Vec<KeyBinding>,
pub(crate) motion_left: Vec<KeyBinding>,
pub(crate) motion_right: Vec<KeyBinding>,
pub(crate) motion_up: Vec<KeyBinding>,
@@ -182,6 +194,31 @@ pub(crate) struct VimOperatorKeymap {
pub(crate) motion_word_end: Vec<KeyBinding>,
pub(crate) motion_line_start: Vec<KeyBinding>,
pub(crate) motion_line_end: Vec<KeyBinding>,
pub(crate) find_forward: Vec<KeyBinding>,
pub(crate) find_backward: Vec<KeyBinding>,
pub(crate) till_forward: Vec<KeyBinding>,
pub(crate) till_backward: Vec<KeyBinding>,
pub(crate) repeat_find: Vec<KeyBinding>,
pub(crate) repeat_find_reverse: Vec<KeyBinding>,
pub(crate) select_inner_text_object: Vec<KeyBinding>,
pub(crate) select_around_text_object: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
/// Vim text-object keybindings active after an operator plus inner/around prefix.
#[derive(Clone, Debug, Default)]
pub(crate) struct VimTextObjectKeymap {
pub(crate) word: Vec<KeyBinding>,
pub(crate) big_word: Vec<KeyBinding>,
pub(crate) parentheses: Vec<KeyBinding>,
pub(crate) brackets: Vec<KeyBinding>,
pub(crate) braces: Vec<KeyBinding>,
pub(crate) double_quote: Vec<KeyBinding>,
pub(crate) single_quote: Vec<KeyBinding>,
pub(crate) backtick: Vec<KeyBinding>,
pub(crate) sentence: Vec<KeyBinding>,
pub(crate) paragraph: Vec<KeyBinding>,
pub(crate) tag: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
@@ -451,7 +488,7 @@ impl RuntimeKeymap {
yank: resolve_local!(keymap, defaults, editor, yank),
};
let vim_normal = VimNormalKeymap {
let mut vim_normal = VimNormalKeymap {
enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert),
append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor),
append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end),
@@ -467,8 +504,17 @@ impl RuntimeKeymap {
move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end),
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
find_forward: resolve_local!(keymap, defaults, vim_normal, find_forward),
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),
repeat_find: resolve_local!(keymap, defaults, vim_normal, repeat_find),
repeat_find_reverse: resolve_local!(keymap, defaults, vim_normal, repeat_find_reverse),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
substitute_line: resolve_local!(keymap, defaults, vim_normal, substitute_line),
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
start_delete_operator: resolve_local!(
@@ -478,12 +524,192 @@ impl RuntimeKeymap {
start_delete_operator
),
start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator),
start_change_operator: resolve_local!(
keymap,
defaults,
vim_normal,
start_change_operator
),
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
};
let vim_operator = VimOperatorKeymap {
let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.vim_normal.enter_insert.as_ref(),
vim_normal.enter_insert.as_slice(),
),
(
keymap.vim_normal.append_after_cursor.as_ref(),
vim_normal.append_after_cursor.as_slice(),
),
(
keymap.vim_normal.append_line_end.as_ref(),
vim_normal.append_line_end.as_slice(),
),
(
keymap.vim_normal.insert_line_start.as_ref(),
vim_normal.insert_line_start.as_slice(),
),
(
keymap.vim_normal.open_line_below.as_ref(),
vim_normal.open_line_below.as_slice(),
),
(
keymap.vim_normal.open_line_above.as_ref(),
vim_normal.open_line_above.as_slice(),
),
(
keymap.vim_normal.move_left.as_ref(),
vim_normal.move_left.as_slice(),
),
(
keymap.vim_normal.move_right.as_ref(),
vim_normal.move_right.as_slice(),
),
(
keymap.vim_normal.move_up.as_ref(),
vim_normal.move_up.as_slice(),
),
(
keymap.vim_normal.move_down.as_ref(),
vim_normal.move_down.as_slice(),
),
(
keymap.vim_normal.move_word_forward.as_ref(),
vim_normal.move_word_forward.as_slice(),
),
(
keymap.vim_normal.move_word_backward.as_ref(),
vim_normal.move_word_backward.as_slice(),
),
(
keymap.vim_normal.move_word_end.as_ref(),
vim_normal.move_word_end.as_slice(),
),
(
keymap.vim_normal.move_line_start.as_ref(),
vim_normal.move_line_start.as_slice(),
),
(
keymap.vim_normal.move_line_end.as_ref(),
vim_normal.move_line_end.as_slice(),
),
(
keymap.vim_normal.find_forward.as_ref(),
vim_normal.find_forward.as_slice(),
),
(
keymap.vim_normal.find_backward.as_ref(),
vim_normal.find_backward.as_slice(),
),
(
keymap.vim_normal.till_forward.as_ref(),
vim_normal.till_forward.as_slice(),
),
(
keymap.vim_normal.till_backward.as_ref(),
vim_normal.till_backward.as_slice(),
),
(
keymap.vim_normal.repeat_find.as_ref(),
vim_normal.repeat_find.as_slice(),
),
(
keymap.vim_normal.repeat_find_reverse.as_ref(),
vim_normal.repeat_find_reverse.as_slice(),
),
(
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
),
(
keymap.vim_normal.delete_to_line_end.as_ref(),
vim_normal.delete_to_line_end.as_slice(),
),
(
keymap.vim_normal.change_to_line_end.as_ref(),
vim_normal.change_to_line_end.as_slice(),
),
(
keymap.vim_normal.substitute_char.as_ref(),
vim_normal.substitute_char.as_slice(),
),
(
keymap.vim_normal.substitute_line.as_ref(),
vim_normal.substitute_line.as_slice(),
),
(
keymap.vim_normal.yank_line.as_ref(),
vim_normal.yank_line.as_slice(),
),
(
keymap.vim_normal.paste_after.as_ref(),
vim_normal.paste_after.as_slice(),
),
(
keymap.vim_normal.start_delete_operator.as_ref(),
vim_normal.start_delete_operator.as_slice(),
),
(
keymap.vim_normal.start_yank_operator.as_ref(),
vim_normal.start_yank_operator.as_slice(),
),
(
keymap.vim_normal.cancel_operator.as_ref(),
vim_normal.cancel_operator.as_slice(),
),
]);
if keymap.vim_normal.start_change_operator.is_none() {
vim_normal
.start_change_operator
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.substitute_char.is_none() {
vim_normal
.substitute_char
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.substitute_line.is_none() {
vim_normal
.substitute_line
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.find_forward.is_none() {
vim_normal
.find_forward
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.find_backward.is_none() {
vim_normal
.find_backward
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.till_forward.is_none() {
vim_normal
.till_forward
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.till_backward.is_none() {
vim_normal
.till_backward
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.repeat_find.is_none() {
vim_normal
.repeat_find
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.repeat_find_reverse.is_none() {
vim_normal
.repeat_find_reverse
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
let mut vim_operator = VimOperatorKeymap {
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
change_line: resolve_local!(keymap, defaults, vim_operator, change_line),
motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left),
motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right),
motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up),
@@ -503,9 +729,235 @@ impl RuntimeKeymap {
motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end),
motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start),
motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end),
find_forward: resolve_local!(keymap, defaults, vim_operator, find_forward),
find_backward: resolve_local!(keymap, defaults, vim_operator, find_backward),
till_forward: resolve_local!(keymap, defaults, vim_operator, till_forward),
till_backward: resolve_local!(keymap, defaults, vim_operator, till_backward),
repeat_find: resolve_local!(keymap, defaults, vim_operator, repeat_find),
repeat_find_reverse: resolve_local!(
keymap,
defaults,
vim_operator,
repeat_find_reverse
),
select_inner_text_object: resolve_local!(
keymap,
defaults,
vim_operator,
select_inner_text_object
),
select_around_text_object: resolve_local!(
keymap,
defaults,
vim_operator,
select_around_text_object
),
cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
};
let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.vim_operator.delete_line.as_ref(),
vim_operator.delete_line.as_slice(),
),
(
keymap.vim_operator.yank_line.as_ref(),
vim_operator.yank_line.as_slice(),
),
(
keymap.vim_operator.change_line.as_ref(),
vim_operator.change_line.as_slice(),
),
(
keymap.vim_operator.motion_left.as_ref(),
vim_operator.motion_left.as_slice(),
),
(
keymap.vim_operator.motion_right.as_ref(),
vim_operator.motion_right.as_slice(),
),
(
keymap.vim_operator.motion_up.as_ref(),
vim_operator.motion_up.as_slice(),
),
(
keymap.vim_operator.motion_down.as_ref(),
vim_operator.motion_down.as_slice(),
),
(
keymap.vim_operator.motion_word_forward.as_ref(),
vim_operator.motion_word_forward.as_slice(),
),
(
keymap.vim_operator.motion_word_backward.as_ref(),
vim_operator.motion_word_backward.as_slice(),
),
(
keymap.vim_operator.motion_word_end.as_ref(),
vim_operator.motion_word_end.as_slice(),
),
(
keymap.vim_operator.motion_line_start.as_ref(),
vim_operator.motion_line_start.as_slice(),
),
(
keymap.vim_operator.motion_line_end.as_ref(),
vim_operator.motion_line_end.as_slice(),
),
(
keymap.vim_operator.find_forward.as_ref(),
vim_operator.find_forward.as_slice(),
),
(
keymap.vim_operator.find_backward.as_ref(),
vim_operator.find_backward.as_slice(),
),
(
keymap.vim_operator.till_forward.as_ref(),
vim_operator.till_forward.as_slice(),
),
(
keymap.vim_operator.till_backward.as_ref(),
vim_operator.till_backward.as_slice(),
),
(
keymap.vim_operator.repeat_find.as_ref(),
vim_operator.repeat_find.as_slice(),
),
(
keymap.vim_operator.repeat_find_reverse.as_ref(),
vim_operator.repeat_find_reverse.as_slice(),
),
(
keymap.vim_operator.cancel.as_ref(),
vim_operator.cancel.as_slice(),
),
]);
if keymap.vim_operator.select_inner_text_object.is_none() {
vim_operator
.select_inner_text_object
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.select_around_text_object.is_none() {
vim_operator
.select_around_text_object
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.change_line.is_none() {
vim_operator
.change_line
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.find_forward.is_none() {
vim_operator
.find_forward
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.find_backward.is_none() {
vim_operator
.find_backward
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.till_forward.is_none() {
vim_operator
.till_forward
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.till_backward.is_none() {
vim_operator
.till_backward
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.repeat_find.is_none() {
vim_operator
.repeat_find
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
if keymap.vim_operator.repeat_find_reverse.is_none() {
vim_operator
.repeat_find_reverse
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
let mut vim_text_object = VimTextObjectKeymap {
word: resolve_local!(keymap, defaults, vim_text_object, word),
big_word: resolve_local!(keymap, defaults, vim_text_object, big_word),
parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses),
brackets: resolve_local!(keymap, defaults, vim_text_object, brackets),
braces: resolve_local!(keymap, defaults, vim_text_object, braces),
double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote),
single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote),
backtick: resolve_local!(keymap, defaults, vim_text_object, backtick),
sentence: resolve_local!(keymap, defaults, vim_text_object, sentence),
paragraph: resolve_local!(keymap, defaults, vim_text_object, paragraph),
tag: resolve_local!(keymap, defaults, vim_text_object, tag),
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
};
let configured_vim_text_object_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.vim_text_object.word.as_ref(),
vim_text_object.word.as_slice(),
),
(
keymap.vim_text_object.big_word.as_ref(),
vim_text_object.big_word.as_slice(),
),
(
keymap.vim_text_object.parentheses.as_ref(),
vim_text_object.parentheses.as_slice(),
),
(
keymap.vim_text_object.brackets.as_ref(),
vim_text_object.brackets.as_slice(),
),
(
keymap.vim_text_object.braces.as_ref(),
vim_text_object.braces.as_slice(),
),
(
keymap.vim_text_object.double_quote.as_ref(),
vim_text_object.double_quote.as_slice(),
),
(
keymap.vim_text_object.single_quote.as_ref(),
vim_text_object.single_quote.as_slice(),
),
(
keymap.vim_text_object.backtick.as_ref(),
vim_text_object.backtick.as_slice(),
),
(
keymap.vim_text_object.sentence.as_ref(),
vim_text_object.sentence.as_slice(),
),
(
keymap.vim_text_object.paragraph.as_ref(),
vim_text_object.paragraph.as_slice(),
),
(
keymap.vim_text_object.cancel.as_ref(),
vim_text_object.cancel.as_slice(),
),
]);
if keymap.vim_text_object.sentence.is_none() {
vim_text_object.sentence.retain(|binding| {
!configured_vim_text_object_bindings_to_preserve.contains(binding)
});
}
if keymap.vim_text_object.paragraph.is_none() {
vim_text_object.paragraph.retain(|binding| {
!configured_vim_text_object_bindings_to_preserve.contains(binding)
});
}
if keymap.vim_text_object.tag.is_none() {
vim_text_object.tag.retain(|binding| {
!configured_vim_text_object_bindings_to_preserve.contains(binding)
});
}
let pager = PagerKeymap {
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
@@ -534,8 +986,7 @@ impl RuntimeKeymap {
let list_move_down = resolve_local!(keymap, defaults, list, move_down);
let list_accept = resolve_local!(keymap, defaults, list, accept);
let list_cancel = resolve_local!(keymap, defaults, list, cancel);
let mut configured_bindings_to_preserve = Vec::new();
for (configured, resolved) in [
let configured_bindings_to_preserve = configured_bindings_to_preserve([
(
keymap.global.open_transcript.as_ref(),
app.open_transcript.as_slice(),
@@ -591,51 +1042,42 @@ impl RuntimeKeymap {
approval.decline.as_slice(),
),
(keymap.approval.cancel.as_ref(), approval.cancel.as_slice()),
] {
if configured.is_none() {
continue;
}
for binding in resolved {
if !configured_bindings_to_preserve.contains(binding) {
configured_bindings_to_preserve.push(*binding);
}
}
}
]);
let list = ListKeymap {
move_up: list_move_up,
move_down: list_move_down,
move_left: resolve_new_list_bindings(
move_left: resolve_new_default_bindings(
keymap.list.move_left.as_ref(),
&defaults.list.move_left,
&configured_bindings_to_preserve,
"tui.keymap.list.move_left",
)?,
move_right: resolve_new_list_bindings(
move_right: resolve_new_default_bindings(
keymap.list.move_right.as_ref(),
&defaults.list.move_right,
&configured_bindings_to_preserve,
"tui.keymap.list.move_right",
)?,
page_up: resolve_new_list_bindings(
page_up: resolve_new_default_bindings(
keymap.list.page_up.as_ref(),
&defaults.list.page_up,
&configured_bindings_to_preserve,
"tui.keymap.list.page_up",
)?,
page_down: resolve_new_list_bindings(
page_down: resolve_new_default_bindings(
keymap.list.page_down.as_ref(),
&defaults.list.page_down,
&configured_bindings_to_preserve,
"tui.keymap.list.page_down",
)?,
jump_top: resolve_new_list_bindings(
jump_top: resolve_new_default_bindings(
keymap.list.jump_top.as_ref(),
&defaults.list.jump_top,
&configured_bindings_to_preserve,
"tui.keymap.list.jump_top",
)?,
jump_bottom: resolve_new_list_bindings(
jump_bottom: resolve_new_default_bindings(
keymap.list.jump_bottom.as_ref(),
&defaults.list.jump_bottom,
&configured_bindings_to_preserve,
@@ -652,6 +1094,7 @@ impl RuntimeKeymap {
editor,
vim_normal,
vim_operator,
vim_text_object,
pager,
list,
approval,
@@ -781,20 +1224,43 @@ impl RuntimeKeymap {
plain(KeyCode::Char('$')),
shift(KeyCode::Char('$'))
],
find_forward: default_bindings![plain(KeyCode::Char('f'))],
find_backward: default_bindings![
shift(KeyCode::Char('f')),
plain(KeyCode::Char('F'))
],
till_forward: default_bindings![plain(KeyCode::Char('t'))],
till_backward: default_bindings![
shift(KeyCode::Char('t')),
plain(KeyCode::Char('T'))
],
repeat_find: default_bindings![plain(KeyCode::Char(';'))],
repeat_find_reverse: default_bindings![plain(KeyCode::Char(','))],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
plain(KeyCode::Char('D'))
],
change_to_line_end: default_bindings![
shift(KeyCode::Char('c')),
plain(KeyCode::Char('C'))
],
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
substitute_line: default_bindings![
shift(KeyCode::Char('s')),
plain(KeyCode::Char('S'))
],
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
paste_after: default_bindings![plain(KeyCode::Char('p'))],
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
cancel_operator: default_bindings![plain(KeyCode::Esc)],
},
vim_operator: VimOperatorKeymap {
delete_line: default_bindings![plain(KeyCode::Char('d'))],
yank_line: default_bindings![plain(KeyCode::Char('y'))],
change_line: default_bindings![plain(KeyCode::Char('c'))],
motion_left: default_bindings![plain(KeyCode::Char('h'))],
motion_right: default_bindings![plain(KeyCode::Char('l'))],
motion_up: default_bindings![plain(KeyCode::Char('k'))],
@@ -807,6 +1273,50 @@ impl RuntimeKeymap {
plain(KeyCode::Char('$')),
shift(KeyCode::Char('$'))
],
find_forward: default_bindings![plain(KeyCode::Char('f'))],
find_backward: default_bindings![
shift(KeyCode::Char('f')),
plain(KeyCode::Char('F'))
],
till_forward: default_bindings![plain(KeyCode::Char('t'))],
till_backward: default_bindings![
shift(KeyCode::Char('t')),
plain(KeyCode::Char('T'))
],
repeat_find: default_bindings![plain(KeyCode::Char(';'))],
repeat_find_reverse: default_bindings![plain(KeyCode::Char(','))],
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)],
},
vim_text_object: VimTextObjectKeymap {
word: default_bindings![plain(KeyCode::Char('w'))],
big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))],
parentheses: default_bindings![
plain(KeyCode::Char('(')),
shift(KeyCode::Char('(')),
plain(KeyCode::Char(')')),
shift(KeyCode::Char(')')),
plain(KeyCode::Char('b'))
],
brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))],
braces: default_bindings![
plain(KeyCode::Char('{')),
shift(KeyCode::Char('{')),
plain(KeyCode::Char('}')),
shift(KeyCode::Char('}')),
shift(KeyCode::Char('b')),
plain(KeyCode::Char('B'))
],
double_quote: default_bindings![
plain(KeyCode::Char('"')),
shift(KeyCode::Char('"'))
],
single_quote: default_bindings![plain(KeyCode::Char('\''))],
backtick: default_bindings![plain(KeyCode::Char('`'))],
sentence: default_bindings![plain(KeyCode::Char('s'))],
paragraph: default_bindings![plain(KeyCode::Char('p'))],
tag: default_bindings![plain(KeyCode::Char('t'))],
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
@@ -1171,11 +1681,32 @@ impl RuntimeKeymap {
self.vim_normal.move_line_start.as_slice(),
),
("move_line_end", self.vim_normal.move_line_end.as_slice()),
("find_forward", self.vim_normal.find_forward.as_slice()),
("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()),
("repeat_find", self.vim_normal.repeat_find.as_slice()),
(
"repeat_find_reverse",
self.vim_normal.repeat_find_reverse.as_slice(),
),
("delete_char", self.vim_normal.delete_char.as_slice()),
(
"delete_to_line_end",
self.vim_normal.delete_to_line_end.as_slice(),
),
(
"change_to_line_end",
self.vim_normal.change_to_line_end.as_slice(),
),
(
"substitute_char",
self.vim_normal.substitute_char.as_slice(),
),
(
"substitute_line",
self.vim_normal.substitute_line.as_slice(),
),
("yank_line", self.vim_normal.yank_line.as_slice()),
("paste_after", self.vim_normal.paste_after.as_slice()),
(
@@ -1186,6 +1717,10 @@ impl RuntimeKeymap {
"start_yank_operator",
self.vim_normal.start_yank_operator.as_slice(),
),
(
"start_change_operator",
self.vim_normal.start_change_operator.as_slice(),
),
(
"cancel_operator",
self.vim_normal.cancel_operator.as_slice(),
@@ -1198,6 +1733,7 @@ impl RuntimeKeymap {
[
("delete_line", self.vim_operator.delete_line.as_slice()),
("yank_line", self.vim_operator.yank_line.as_slice()),
("change_line", self.vim_operator.change_line.as_slice()),
("motion_left", self.vim_operator.motion_left.as_slice()),
("motion_right", self.vim_operator.motion_right.as_slice()),
("motion_up", self.vim_operator.motion_up.as_slice()),
@@ -1222,10 +1758,45 @@ impl RuntimeKeymap {
"motion_line_end",
self.vim_operator.motion_line_end.as_slice(),
),
("find_forward", self.vim_operator.find_forward.as_slice()),
("find_backward", self.vim_operator.find_backward.as_slice()),
("till_forward", self.vim_operator.till_forward.as_slice()),
("till_backward", self.vim_operator.till_backward.as_slice()),
("repeat_find", self.vim_operator.repeat_find.as_slice()),
(
"repeat_find_reverse",
self.vim_operator.repeat_find_reverse.as_slice(),
),
(
"select_inner_text_object",
self.vim_operator.select_inner_text_object.as_slice(),
),
(
"select_around_text_object",
self.vim_operator.select_around_text_object.as_slice(),
),
("cancel", self.vim_operator.cancel.as_slice()),
],
)?;
validate_unique(
"vim_text_object",
[
("word", self.vim_text_object.word.as_slice()),
("big_word", self.vim_text_object.big_word.as_slice()),
("parentheses", self.vim_text_object.parentheses.as_slice()),
("brackets", self.vim_text_object.brackets.as_slice()),
("braces", self.vim_text_object.braces.as_slice()),
("double_quote", self.vim_text_object.double_quote.as_slice()),
("single_quote", self.vim_text_object.single_quote.as_slice()),
("backtick", self.vim_text_object.backtick.as_slice()),
("sentence", self.vim_text_object.sentence.as_slice()),
("paragraph", self.vim_text_object.paragraph.as_slice()),
("tag", self.vim_text_object.tag.as_slice()),
("cancel", self.vim_text_object.cancel.as_slice()),
],
)?;
validate_unique(
"pager",
[
@@ -1514,7 +2085,24 @@ fn resolve_bindings(
parse_bindings(spec, path)
}
fn resolve_new_list_bindings(
fn configured_bindings_to_preserve<const N: usize>(
pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N],
) -> Vec<KeyBinding> {
let mut configured_bindings = Vec::new();
for (configured, resolved) in pairs {
if configured.is_none() {
continue;
}
for binding in resolved {
if !configured_bindings.contains(binding) {
configured_bindings.push(*binding);
}
}
}
configured_bindings
}
fn resolve_new_default_bindings(
configured: Option<&KeybindingsSpec>,
fallback: &[KeyBinding],
configured_bindings_to_preserve: &[KeyBinding],
@@ -1963,6 +2551,109 @@ mod tests {
expect_conflict(&keymap, "list.jump_top", "approval.approve");
}
#[test]
fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("c"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.move_left,
vec![key_hint::plain(KeyCode::Char('c'))]
);
assert_eq!(runtime.vim_normal.start_change_operator, Vec::new());
}
#[test]
fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("c"));
keymap.vim_normal.start_change_operator = Some(one("c"));
expect_conflict(&keymap, "move_left", "start_change_operator");
}
#[test]
fn configured_legacy_vim_bindings_prune_new_substitute_and_change_line_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("s"));
keymap.vim_normal.move_right = Some(one("shift-s"));
keymap.vim_operator.motion_left = Some(one("c"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
assert_eq!(
runtime.vim_normal.substitute_line,
vec![key_hint::plain(KeyCode::Char('S'))]
);
assert_eq!(runtime.vim_operator.change_line, Vec::new());
}
#[test]
fn configured_legacy_vim_bindings_prune_new_find_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("f"));
keymap.vim_operator.motion_left = Some(one("t"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.vim_normal.find_forward, Vec::new());
assert_eq!(runtime.vim_operator.till_forward, Vec::new());
}
#[test]
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_operator.motion_left = Some(one("i"));
keymap.vim_operator.motion_right = Some(one("a"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_operator.motion_left,
vec![key_hint::plain(KeyCode::Char('i'))]
);
assert_eq!(
runtime.vim_operator.motion_right,
vec![key_hint::plain(KeyCode::Char('a'))]
);
assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new());
assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new());
}
#[test]
fn configured_legacy_vim_text_objects_prune_new_prose_defaults() {
let mut keymap = TuiKeymap::default();
keymap.vim_text_object.word = Some(one("s"));
keymap.vim_text_object.big_word = Some(one("p"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.vim_text_object.sentence, Vec::new());
assert_eq!(runtime.vim_text_object.paragraph, Vec::new());
}
#[test]
fn configured_existing_vim_text_objects_prune_new_tag_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_text_object.paragraph = Some(one("t"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.vim_text_object.tag, Vec::new());
}
#[test]
fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_operator.motion_left = Some(one("i"));
keymap.vim_operator.select_inner_text_object = Some(one("i"));
expect_conflict(&keymap, "motion_left", "select_inner_text_object");
}
#[test]
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
let runtime = RuntimeKeymap::defaults();

View File

@@ -133,15 +133,26 @@ 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", "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", "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 +162,27 @@ 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("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 +292,26 @@ 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", "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", "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 +321,27 @@ 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),
("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 +433,26 @@ 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", "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", "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 +462,27 @@ 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()),
("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

@@ -104,7 +104,7 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
id: "vim-shortcuts",
label: "Vim",
description: "Vim normal-mode and operator shortcuts.",
contexts: &["vim_normal", "vim_operator"],
contexts: &["vim_normal", "vim_operator", "vim_text_object"],
},
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.
124 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.
125 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 (124 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 (65 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.
124 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.
124 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug