feat(tui): add vim prose text objects

This commit is contained in:
Felipe Coury
2026-05-25 17:34:45 -03:00
parent ff077a6a07
commit cbdae45c3f
12 changed files with 325 additions and 7 deletions
+4
View File
@@ -329,6 +329,10 @@ pub struct TuiVimTextObjectKeymap {
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>,
/// Cancel the pending text-object command.
pub cancel: Option<KeybindingsSpec>,
}
+20
View File
@@ -2884,7 +2884,9 @@
"brackets": null,
"cancel": null,
"double_quote": null,
"paragraph": null,
"parentheses": null,
"sentence": null,
"single_quote": null,
"word": null
}
@@ -3594,7 +3596,9 @@
"brackets": null,
"cancel": null,
"double_quote": null,
"paragraph": null,
"parentheses": null,
"sentence": null,
"single_quote": null,
"word": null
}
@@ -4284,6 +4288,14 @@
],
"description": "Text object: double quotes."
},
"paragraph": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: paragraph."
},
"parentheses": {
"allOf": [
{
@@ -4292,6 +4304,14 @@
],
"description": "Text object: parentheses."
},
"sentence": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: sentence."
},
"single_quote": {
"allOf": [
{
+69
View File
@@ -2840,6 +2840,75 @@ mod tests {
assert_eq!(t.kill_buffer, "");
}
#[test]
fn vim_sentence_text_objects_cover_change_and_around_delete() {
let mut t = ta_with("One. Hello (world)! Next?");
t.set_cursor(/*pos*/ "One. 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('s'), KeyModifiers::NONE));
assert_eq!(t.text(), "One. Next?");
assert_eq!(t.kill_buffer, "Hello (world)!");
assert_eq!(t.vim_mode_label(), Some("Insert"));
let mut t = ta_with("One. Is this ok?) Next.");
t.set_cursor(/*pos*/ "One. Is ".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('s'), KeyModifiers::NONE));
assert_eq!(t.text(), "One. Next.");
assert_eq!(t.kill_buffer, "Is this ok?) ");
}
#[test]
fn vim_paragraph_text_objects_cover_multiline_delete_and_change() {
let mut t = ta_with("one\nline\n\nsecond\nline\n\nthird");
t.set_cursor(/*pos*/ "one\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('p'), KeyModifiers::NONE));
assert_eq!(t.text(), "\n\nsecond\nline\n\nthird");
assert_eq!(t.kill_buffer, "one\nline");
let mut t = ta_with("one\nline\n\nsecond\nline\n\nthird");
t.set_cursor(/*pos*/ "one\nline\n\nsecond".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert_eq!(t.text(), "one\nline\n\nthird");
assert_eq!(t.kill_buffer, "second\nline\n\n");
assert_eq!(t.vim_mode_label(), Some("Insert"));
}
#[test]
fn vim_sentence_text_objects_preserve_unicode_and_atomic_elements() {
let mut t = ta_with("Hi 👍! Use @file now? Done.");
let mention_start = "Hi 👍! Use ".len();
t.add_element_range(mention_start..mention_start + "@file".len())
.expect("valid element");
t.set_cursor(/*pos*/ mention_start);
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('s'), KeyModifiers::NONE));
assert_eq!(t.text(), "Hi 👍! Done.");
assert_eq!(t.kill_buffer, "Use @file now? ");
}
#[test]
fn vim_text_object_cancellation_does_not_edit() {
let mut t = ta_with("hello world");
@@ -5,6 +5,7 @@ use crossterm::event::KeyEvent;
use std::ops::Range;
mod find;
mod prose;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimMode {
@@ -78,6 +79,8 @@ pub(super) enum VimTextObject {
DoubleQuote,
SingleQuote,
Backtick,
Sentence,
Paragraph,
}
impl TextArea {
@@ -127,6 +130,12 @@ impl TextArea {
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);
}
None
}
@@ -144,6 +153,8 @@ impl TextArea {
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),
}
}
@@ -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
}
}
+71 -1
View File
@@ -216,6 +216,8 @@ pub(crate) struct VimTextObjectKeymap {
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) cancel: Vec<KeyBinding>,
}
@@ -877,7 +879,7 @@ impl RuntimeKeymap {
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
}
let vim_text_object = VimTextObjectKeymap {
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),
@@ -886,9 +888,61 @@ impl RuntimeKeymap {
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),
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.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)
});
}
let pager = PagerKeymap {
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
@@ -1245,6 +1299,8 @@ impl RuntimeKeymap {
],
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'))],
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
@@ -1718,6 +1774,8 @@ impl RuntimeKeymap {
("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()),
("cancel", self.vim_text_object.cancel.as_slice()),
],
)?;
@@ -2548,6 +2606,18 @@ mod tests {
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 explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
+6
View File
@@ -179,6 +179,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
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", "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."),
@@ -335,6 +337,8 @@ pub(super) fn binding_slot<'a>(
("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", "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),
@@ -473,6 +477,8 @@ pub(super) fn bindings_for_action<'a>(
("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", "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()),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
121 actions, 1 customized, 2 unbound.
123 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.
122 actions, 0 customized, 3 unbound.
124 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 (121 selectable)
tab: All (123 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 (62 selectable)
tab: Vim (64 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.
121 actions, 0 customized, 2 unbound.
123 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.
121 actions, 0 customized, 2 unbound.
123 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug