Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2888,6 +2888,7 @@
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
}
},
Expand Down Expand Up @@ -3600,6 +3601,7 @@
"parentheses": null,
"sentence": null,
"single_quote": null,
"tag": null,
"word": null
}
}
Expand Down Expand Up @@ -4320,6 +4322,14 @@
],
"description": "Text object: single quotes."
},
"tag": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Text object: enclosing paired tag."
},
"word": {
"allOf": [
{
Expand Down
60 changes: 60 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea/vim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::ops::Range;

mod find;
mod prose;
mod tag;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimMode {
Expand Down Expand Up @@ -81,6 +82,7 @@ pub(super) enum VimTextObject {
Backtick,
Sentence,
Paragraph,
Tag,
}

impl TextArea {
Expand Down Expand Up @@ -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
}

Expand All @@ -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),
}
}

Expand Down
148 changes: 148 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea/vim/tag.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions codex-rs/tui/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}

Expand Down Expand Up @@ -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),
};

Expand Down Expand Up @@ -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(),
Expand All @@ -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),
Expand Down Expand 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 {
Expand Down Expand Up @@ -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()),
],
)?;
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/tui/src/keymap_setup/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading