feat(tui): add vim text object bindings

This commit is contained in:
Felipe Coury
2026-05-24 20:17:11 -03:00
parent 9f42c89c01
commit 4e3f68330a
12 changed files with 826 additions and 60 deletions
+32
View File
@@ -231,6 +231,8 @@ 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 text object.
pub start_change_operator: Option<KeybindingsSpec>,
/// Cancel a pending operator and return to normal mode.
pub cancel_operator: Option<KeybindingsSpec>,
}
@@ -266,10 +268,38 @@ pub struct TuiVimOperatorKeymap {
pub motion_line_start: Option<KeybindingsSpec>,
/// Motion: to end of line (`$`).
pub motion_line_end: 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)]
#[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>,
/// 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 +404,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,
+138
View File
@@ -2840,6 +2840,7 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
@@ -2856,7 +2857,20 @@
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"select_around_text_object": null,
"select_inner_text_object": null,
"yank_line": null
},
"vim_text_object": {
"backtick": null,
"big_word": null,
"braces": null,
"brackets": null,
"cancel": null,
"double_quote": null,
"parentheses": null,
"single_quote": 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."
@@ -3506,6 +3520,7 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
@@ -3529,8 +3544,28 @@
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"select_around_text_object": null,
"select_inner_text_object": 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,
"parentheses": null,
"single_quote": null,
"word": null
}
}
},
"type": "object"
@@ -3883,6 +3918,14 @@
],
"description": "Paste after cursor (`p`)."
},
"start_change_operator": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Begin change operator; next keys select a text object."
},
"start_delete_operator": {
"allOf": [
{
@@ -4002,6 +4045,22 @@
],
"description": "Motion: to start of next word (`w`)."
},
"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."
},
"yank_line": {
"allOf": [
{
@@ -4013,6 +4072,85 @@
},
"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."
},
"parentheses": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: parentheses."
},
"single_quote": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: single quotes."
},
"word": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: word."
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{
+199 -53
View File
@@ -16,6 +16,7 @@ use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
use crate::keymap::VimTextObjectKeymap;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -35,6 +36,13 @@ use textwrap::Options;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
mod vim;
use self::vim::VimMode;
use self::vim::VimMotion;
use self::vim::VimOperator;
use self::vim::VimPending;
use self::vim::VimTextObjectScope;
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
fn is_word_separator(ch: char) -> bool {
@@ -101,10 +109,11 @@ pub(crate) struct TextArea {
kill_buffer_kind: KillBufferKind,
vim_enabled: bool,
vim_mode: VimMode,
vim_operator: Option<VimOperator>,
vim_pending: VimPending,
editor_keymap: EditorKeymap,
vim_normal_keymap: VimNormalKeymap,
vim_operator_keymap: VimOperatorKeymap,
vim_text_object_keymap: VimTextObjectKeymap,
}
#[derive(Debug, Clone)]
@@ -119,14 +128,6 @@ pub(crate) struct TextAreaState {
scroll: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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)]
enum KillBufferKind {
/// Characterwise kills and yanks paste at the cursor.
@@ -135,36 +136,6 @@ enum KillBufferKind {
Linewise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimOperator {
/// Delete the range selected by the next motion or repeated operator key.
Delete,
/// Copy the range selected by the next motion or repeated operator key.
Yank,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMotion {
/// Move one atomic boundary to the left.
Left,
/// Move one atomic boundary to the right.
Right,
/// Move one visual row up, preserving preferred display column.
Up,
/// Move one visual row down, preserving preferred display column.
Down,
/// Move to the start of the next word-like run.
WordForward,
/// Move to the start of the previous word-like run.
WordBackward,
/// Move to the end of the current or next word-like run.
WordEnd,
/// Move to the start of the current line.
LineStart,
/// Move to the end of the current line.
LineEnd,
}
impl TextArea {
pub fn new() -> Self {
let defaults = RuntimeKeymap::defaults();
@@ -179,10 +150,11 @@ impl TextArea {
kill_buffer_kind: KillBufferKind::Characterwise,
vim_enabled: false,
vim_mode: VimMode::Insert,
vim_operator: None,
vim_pending: VimPending::None,
editor_keymap: defaults.editor,
vim_normal_keymap: defaults.vim_normal,
vim_operator_keymap: defaults.vim_operator,
vim_text_object_keymap: defaults.vim_text_object,
}
}
@@ -196,6 +168,7 @@ impl TextArea {
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.vim_operator_keymap = keymap.vim_operator.clone();
self.vim_text_object_keymap = keymap.vim_text_object.clone();
}
/// Replace the visible textarea text and clear any existing text elements.
@@ -257,7 +230,7 @@ impl TextArea {
/// an old `d` or `y` command.
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.vim_enabled = enabled;
self.vim_operator = None;
self.vim_pending = VimPending::None;
self.vim_mode = if enabled {
VimMode::Normal
} else {
@@ -293,7 +266,7 @@ impl TextArea {
/// This is observable so the composer can avoid stealing the second key of
/// `d{motion}` or `y{motion}` for higher-level shortcuts.
pub(crate) fn is_vim_operator_pending(&self) -> bool {
self.vim_operator.is_some()
!matches!(self.vim_pending, VimPending::None)
}
/// Enter Vim insert mode if modal editing is enabled.
@@ -304,7 +277,7 @@ impl TextArea {
pub(crate) fn enter_vim_insert_mode(&mut self) {
if self.vim_enabled {
self.vim_mode = VimMode::Insert;
self.vim_operator = None;
self.vim_pending = VimPending::None;
}
}
@@ -317,7 +290,7 @@ impl TextArea {
pub(crate) fn enter_vim_normal_mode(&mut self) {
if self.vim_enabled {
self.vim_mode = VimMode::Normal;
self.vim_operator = None;
self.vim_pending = VimPending::None;
self.preferred_col = None;
}
}
@@ -669,9 +642,17 @@ impl TextArea {
}
fn handle_vim_normal(&mut self, event: KeyEvent) {
if let Some(op) = self.vim_operator.take() {
self.handle_vim_operator(op, event);
return;
let pending = std::mem::replace(&mut self.vim_pending, VimPending::None);
match pending {
VimPending::None => {}
VimPending::Operator(op) => {
self.handle_vim_operator(op, event);
return;
}
VimPending::TextObject { operator, scope } => {
self.handle_vim_text_object(operator, scope, event);
return;
}
}
if self.vim_normal_keymap.enter_insert.is_pressed(event) {
@@ -771,15 +752,23 @@ impl TextArea {
.start_delete_operator
.is_pressed(event)
{
self.vim_operator = Some(VimOperator::Delete);
self.vim_pending = VimPending::Operator(VimOperator::Delete);
return;
}
if self.vim_normal_keymap.start_yank_operator.is_pressed(event) {
self.vim_operator = Some(VimOperator::Yank);
self.vim_pending = VimPending::Operator(VimOperator::Yank);
return;
}
if self
.vim_normal_keymap
.start_change_operator
.is_pressed(event)
{
self.vim_pending = VimPending::Operator(VimOperator::Change);
return;
}
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
self.vim_operator = None;
self.vim_pending = VimPending::None;
}
}
@@ -795,14 +784,41 @@ impl TextArea {
if self.vim_operator_keymap.cancel.is_pressed(event) {
return true;
}
if let Some(scope) = self.vim_text_object_scope_for_event(event) {
self.vim_pending = VimPending::TextObject {
operator: op,
scope,
};
return true;
}
if let Some(motion) = self.vim_motion_for_event(event) {
if op != VimOperator::Change
&& let Some(motion) = self.vim_motion_for_event(event)
{
self.apply_vim_operator(op, motion);
return true;
}
false
}
fn handle_vim_text_object(
&mut self,
op: VimOperator,
scope: VimTextObjectScope,
event: KeyEvent,
) -> bool {
if self.vim_text_object_keymap.cancel.is_pressed(event) {
return true;
}
let Some(object) = self.vim_text_object_for_event(event) else {
return false;
};
if let Some(range) = self.text_object_range(object, scope) {
self.apply_vim_operator_to_range(op, range);
}
true
}
fn vim_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
if self.vim_operator_keymap.motion_left.is_pressed(event) {
return Some(VimMotion::Left);
@@ -849,6 +865,18 @@ impl TextArea {
match op {
VimOperator::Delete => self.kill_range(range),
VimOperator::Yank => self.yank_range(range),
VimOperator::Change => {}
}
}
fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range<usize>) {
match op {
VimOperator::Delete => self.kill_range(range),
VimOperator::Yank => self.yank_range(range),
VimOperator::Change => {
self.kill_range(range);
self.vim_mode = VimMode::Insert;
}
}
}
@@ -2202,7 +2230,7 @@ mod tests {
let mut t = TextArea::new();
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(t.vim_mode_label(), Some("Normal"));
@@ -2315,6 +2343,124 @@ mod tests {
assert_eq!(t.kill_buffer, "hello ");
}
#[test]
fn vim_change_inner_word_deletes_word_and_enters_insert() {
let mut t = ta_with("hello world");
t.set_cursor(/*pos*/ "hello ".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello ");
assert_eq!(t.kill_buffer, "world");
assert_eq!(t.cursor(), "hello ".len());
assert_eq!(t.vim_mode_label(), Some("Insert"));
}
#[test]
fn vim_word_text_objects_cover_delete_yank_and_big_word() {
let mut t = ta_with("hello world");
t.set_cursor(/*pos*/ 1);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello world");
assert_eq!(t.kill_buffer, "hello ");
assert_eq!(t.vim_mode_label(), Some("Normal"));
let mut t = ta_with("foo.bar/baz qux");
t.set_cursor(/*pos*/ "foo.".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE));
assert_eq!(t.text(), " qux");
assert_eq!(t.kill_buffer, "foo.bar/baz");
}
#[test]
fn vim_delimiter_text_objects_select_innermost_pair_and_aliases() {
let mut t = ta_with("a(b(c)d)e");
t.set_cursor(/*pos*/ "a(b(".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert_eq!(t.text(), "a(b()d)e");
assert_eq!(t.kill_buffer, "c");
assert_eq!(t.vim_mode_label(), Some("Insert"));
let mut t = ta_with("a [b] c");
t.set_cursor(/*pos*/ "a [".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE));
assert_eq!(t.text(), "a c");
assert_eq!(t.kill_buffer, "[b]");
}
#[test]
fn vim_quote_text_objects_are_line_local_and_handle_escapes() {
let mut t = ta_with(r#"say "a \"b\" c" now"#);
t.set_cursor(/*pos*/ r#"say "a \"#.len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
assert_eq!(t.text(), r#"say "" now"#);
assert_eq!(t.kill_buffer, r#"a \"b\" c"#);
assert_eq!(t.vim_mode_label(), Some("Insert"));
let mut t = ta_with("one \"two\nthree\" four");
t.set_cursor(/*pos*/ "one \"two\n".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
assert_eq!(t.text(), "one \"two\nthree\" four");
assert_eq!(t.kill_buffer, "");
}
#[test]
fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() {
let mut t = ta_with("hello world");
t.set_cursor(/*pos*/ 1);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello world");
assert_eq!(t.kill_buffer, "");
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert!(!t.is_vim_operator_pending());
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(t.is_vim_operator_pending());
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(t.text(), "hello world");
assert_eq!(t.kill_buffer, "");
assert!(!t.is_vim_operator_pending());
}
#[test]
fn vim_operator_invalid_motion_is_consumed() {
let mut t = ta_with("hello");
@@ -2324,7 +2470,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(t.is_vim_operator_pending());
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello");
assert_eq!(t.vim_mode_label(), Some("Normal"));
@@ -0,0 +1,311 @@
use super::TextArea;
use super::split_word_pieces;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyEvent;
use std::ops::Range;
#[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,
},
}
#[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 VimTextObjectScope {
Inner,
Around,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimTextObject {
Word,
BigWord,
Parentheses,
Brackets,
Braces,
DoubleQuote,
SingleQuote,
Backtick,
}
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);
}
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, '`'),
}
}
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))
}
fn small_word_range_at_cursor(&self) -> Option<Range<usize>> {
for run in self.non_ws_runs() {
if !self.cursor_overlaps_range(&run) {
continue;
}
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);
}
}
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 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.filter(|range| range.start < range.end)
}
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.filter(|range| range.start < range.end)
}
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()
}
+103
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,
@@ -160,6 +161,7 @@ pub(crate) struct VimNormalKeymap {
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>,
}
@@ -182,6 +184,22 @@ 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) 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) cancel: Vec<KeyBinding>,
}
@@ -478,6 +496,12 @@ 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),
};
@@ -503,9 +527,33 @@ 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),
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 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),
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
};
let pager = PagerKeymap {
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
@@ -652,6 +700,7 @@ impl RuntimeKeymap {
editor,
vim_normal,
vim_operator,
vim_text_object,
pager,
list,
approval,
@@ -790,6 +839,7 @@ impl RuntimeKeymap {
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 {
@@ -807,6 +857,32 @@ impl RuntimeKeymap {
plain(KeyCode::Char('$')),
shift(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('"'))],
single_quote: default_bindings![plain(KeyCode::Char('\''))],
backtick: default_bindings![plain(KeyCode::Char('`'))],
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
@@ -1186,6 +1262,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(),
@@ -1222,10 +1302,33 @@ impl RuntimeKeymap {
"motion_line_end",
self.vim_operator.motion_line_end.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()),
("cancel", self.vim_text_object.cancel.as_slice()),
],
)?;
validate_unique(
"pager",
[
+36
View File
@@ -139,6 +139,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
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 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."),
@@ -151,7 +152,18 @@ 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", "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", "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."),
@@ -267,6 +279,7 @@ pub(super) fn binding_slot<'a>(
("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),
@@ -279,7 +292,18 @@ 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", "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", "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),
@@ -377,6 +401,7 @@ pub(super) fn bindings_for_action<'a>(
("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()),
@@ -389,7 +414,18 @@ 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", "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", "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()),
+1 -1
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",
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
93 actions, 1 customized, 2 unbound.
105 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
94 actions, 0 customized, 3 unbound.
106 actions, 0 customized, 3 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (93 selectable)
tab: All (105 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 (46 selectable)
tab: Navigation (20 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
93 actions, 0 customized, 2 unbound.
105 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
93 actions, 0 customized, 2 unbound.
105 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug