mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## What changed - Add line-local `f`, `F`, `t`, and `T` motions to Vim normal mode. - Support the motions with `c`, `d`, and `y`, including dot-repeat for changes. - Expose separate configurable normal-mode and operator-motion bindings, while preserving existing custom keymaps that use the new defaults. - Keep character targeting aligned to extended grapheme boundaries and skip embedded composer elements. ## Testing - Cover forward and backward motion and operator ranges, missing and adjacent targets, cancellation, dot-repeat, custom bindings, shifted terminal key events, grapheme clusters, and embedded elements. GitOrigin-RevId: 2610cf9a13538d9ae9b890c01417ca25316c1d60
335 lines
10 KiB
Rust
335 lines
10 KiB
Rust
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,
|
|
},
|
|
Replace,
|
|
Find {
|
|
motion: VimFindMotion,
|
|
operator: Option<VimOperator>,
|
|
},
|
|
}
|
|
|
|
/// Line-local character motions shared by navigation, operators, and dot replay.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(super) enum VimFindMotion {
|
|
Forward,
|
|
Backward,
|
|
TillForward,
|
|
TillBackward,
|
|
}
|
|
|
|
#[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) || self.cursor_is_at_range_end(range))
|
|
}
|
|
|
|
fn small_word_range_at_cursor(&self) -> Option<Range<usize>> {
|
|
for run in self.non_ws_runs() {
|
|
if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) {
|
|
continue;
|
|
}
|
|
let mut last_piece = None;
|
|
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);
|
|
}
|
|
last_piece = Some(piece);
|
|
}
|
|
if self.cursor_is_at_range_end(&run) {
|
|
return last_piece.or(Some(run));
|
|
}
|
|
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 cursor_is_at_range_end(&self, range: &Range<usize>) -> bool {
|
|
range.start < range.end && 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|