mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
feat(tui): add vim tag text objects
This commit is contained in:
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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, '-' | '_' | ':')
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user