feat(tui): add vim tag text objects

This commit is contained in:
Felipe Coury
2026-05-25 17:58:21 -03:00
parent cbdae45c3f
commit 9d9e89ca29
12 changed files with 262 additions and 6 deletions
+2
View File
@@ -333,6 +333,8 @@ pub struct TuiVimTextObjectKeymap {
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>,
}
+10
View File
@@ -2888,6 +2888,7 @@
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
}
},
@@ -3600,6 +3601,7 @@
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
}
}
@@ -4320,6 +4322,14 @@
],
"description": "Text object: single quotes."
},
"tag": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: enclosing paired tag."
},
"word": {
"allOf": [
{
+60
View File
@@ -2909,6 +2909,66 @@ mod tests {
assert_eq!(t.kill_buffer, "Use @file now? ");
}
#[test]
fn vim_tag_text_objects_cover_nested_tags_attributes_and_change() {
let mut t = ta_with(r#"<div><span data-x=">">hello</span></div>"#);
t.set_cursor(/*pos*/ r#"<div><span data-x=">">he"#.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('t'), KeyModifiers::NONE));
assert_eq!(t.text(), "<div></div>");
assert_eq!(t.kill_buffer, r#"<span data-x=">">hello</span>"#);
assert_eq!(t.vim_mode_label(), Some("Insert"));
let mut t = ta_with("<div><span>hello</span></div>");
t.set_cursor(/*pos*/ "<div><span>he".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('t'), KeyModifiers::NONE));
assert_eq!(t.text(), "<div><span></span></div>");
assert_eq!(t.kill_buffer, "hello");
}
#[test]
fn vim_tag_text_objects_ignore_nonpaired_forms_and_preserve_elements() {
let text = "<!doctype html><root><!-- <fake>bad</fake> --><img/><leaf>@file</leaf></root>";
let mut t = ta_with(text);
let mention_start = text.find("@file").expect("test mention exists");
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('t'), KeyModifiers::NONE));
assert_eq!(
t.text(),
"<!doctype html><root><!-- <fake>bad</fake> --><img/></root>"
);
assert_eq!(t.kill_buffer, "<leaf>@file</leaf>");
let mut t = ta_with(text);
t.set_cursor(/*pos*/ text.find("bad").expect("test comment exists"));
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('t'), KeyModifiers::NONE));
assert_eq!(t.text(), "<!doctype html><root></root>");
assert_eq!(
t.kill_buffer,
"<!-- <fake>bad</fake> --><img/><leaf>@file</leaf>"
);
}
#[test]
fn vim_text_object_cancellation_does_not_edit() {
let mut t = ta_with("hello world");
@@ -6,6 +6,7 @@ use std::ops::Range;
mod find;
mod prose;
mod tag;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimMode {
@@ -81,6 +82,7 @@ pub(super) enum VimTextObject {
Backtick,
Sentence,
Paragraph,
Tag,
}
impl TextArea {
@@ -136,6 +138,9 @@ impl TextArea {
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
}
@@ -155,6 +160,7 @@ impl TextArea {
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),
}
}
@@ -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, '-' | '_' | ':')
}
+27
View File
@@ -218,6 +218,7 @@ pub(crate) struct VimTextObjectKeymap {
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>,
}
@@ -890,6 +891,7 @@ impl RuntimeKeymap {
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),
};
@@ -926,6 +928,14 @@ impl RuntimeKeymap {
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(),
@@ -942,6 +952,11 @@ impl RuntimeKeymap {
!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),
@@ -1301,6 +1316,7 @@ impl RuntimeKeymap {
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 {
@@ -1776,6 +1792,7 @@ impl RuntimeKeymap {
("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()),
],
)?;
@@ -2618,6 +2635,16 @@ mod tests {
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();
+3
View File
@@ -181,6 +181,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
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."),
@@ -339,6 +340,7 @@ pub(super) fn binding_slot<'a>(
("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),
@@ -479,6 +481,7 @@ pub(super) fn bindings_for_action<'a>(
("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()),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
123 actions, 1 customized, 2 unbound.
124 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.
124 actions, 0 customized, 3 unbound.
125 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 (123 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 (64 selectable)
tab: Vim (65 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.
123 actions, 0 customized, 2 unbound.
124 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.
123 actions, 0 customized, 2 unbound.
124 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug