feat(tui): add vim dot repeat

This commit is contained in:
Felipe Coury
2026-05-25 19:35:59 -03:00
parent 50fbb55995
commit f3d0c003ea
16 changed files with 648 additions and 16 deletions
+2
View File
@@ -231,6 +231,8 @@ pub struct TuiVimNormalKeymap {
pub repeat_find: Option<KeybindingsSpec>,
/// Repeat the most recent find/till motion in the opposite direction (`,`).
pub repeat_find_reverse: Option<KeybindingsSpec>,
/// Repeat the most recent text-changing Vim command (`.`).
pub repeat_change: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
+10
View File
@@ -2845,6 +2845,7 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_change": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_register": null,
@@ -3553,6 +3554,7 @@
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"repeat_change": null,
"repeat_find": null,
"repeat_find_reverse": null,
"select_register": null,
@@ -4021,6 +4023,14 @@
],
"description": "Paste after cursor (`p`)."
},
"repeat_change": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the most recent text-changing Vim command (`.`)."
},
"repeat_find": {
"allOf": [
{
+181 -8
View File
@@ -40,11 +40,14 @@ use unicode_width::UnicodeWidthStr;
mod vim;
use self::vim::VimFind;
use self::vim::VimInsertRecording;
use self::vim::VimMode;
use self::vim::VimMotion;
use self::vim::VimOperator;
use self::vim::VimPending;
use self::vim::VimRegisterSelection;
use self::vim::VimRepeatChange;
use self::vim::VimRepeatTarget;
use self::vim::VimTextObjectScope;
use self::vim::VimVisualKind;
@@ -121,6 +124,9 @@ pub(crate) struct TextArea {
vim_operator_count: Option<usize>,
vim_last_find: Option<VimFind>,
vim_visual_anchor: Option<usize>,
vim_last_change: Option<VimRepeatChange>,
vim_insert_recording: Option<VimInsertRecording>,
vim_replaying_change: bool,
editor_keymap: EditorKeymap,
vim_normal_keymap: VimNormalKeymap,
vim_operator_keymap: VimOperatorKeymap,
@@ -169,6 +175,9 @@ impl TextArea {
vim_operator_count: None,
vim_last_find: None,
vim_visual_anchor: None,
vim_last_change: None,
vim_insert_recording: None,
vim_replaying_change: false,
editor_keymap: defaults.editor,
vim_normal_keymap: defaults.vim_normal,
vim_operator_keymap: defaults.vim_operator,
@@ -211,6 +220,7 @@ impl TextArea {
}
fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
self.invalidate_vim_repeat_change();
// Stage 1: replace the raw text and keep the cursor in a safe byte range.
self.text = text.to_string();
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
@@ -255,6 +265,7 @@ impl TextArea {
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.vim_enabled = enabled;
self.vim_pending = VimPending::None;
self.vim_insert_recording = None;
self.clear_vim_counts();
self.clear_vim_register_selection();
self.clear_vim_visual_selection();
@@ -671,15 +682,14 @@ impl TextArea {
fn handle_vim_insert(&mut self, event: KeyEvent) {
if matches!(event.code, KeyCode::Esc) {
let bol = self.beginning_of_current_line();
if self.cursor_pos > bol {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
}
self.enter_vim_normal_mode();
self.exit_vim_insert_after_change();
self.complete_vim_change_recording();
return;
}
let edit = self.vim_insert_edit_for_event(event);
let keymap = self.editor_keymap.clone();
self.input_with_keymap(event, &keymap);
self.record_vim_insert_edit(edit);
}
fn handle_vim_normal(&mut self, event: KeyEvent) {
@@ -836,9 +846,15 @@ impl TextArea {
self.repeat_vim_find(/*operator*/ None, /*reverse*/ true, count);
return;
}
if self.vim_normal_keymap.repeat_change.is_pressed(event) {
self.repeat_vim_change();
return;
}
if self.vim_normal_keymap.delete_char.is_pressed(event) {
let count = self.take_vim_count();
let previous_len = self.text.len();
self.delete_forward_kill(count);
self.record_vim_delete_if_changed(VimRepeatTarget::Characters(count), previous_len);
self.clear_vim_register_selection();
return;
}
@@ -857,12 +873,14 @@ impl TextArea {
self.delete_forward_kill(count);
self.clear_vim_register_selection();
self.vim_mode = VimMode::Insert;
self.begin_vim_change_recording(VimRepeatTarget::Characters(count));
return;
}
if self.vim_normal_keymap.substitute_line.is_pressed(event) {
let count = self.take_vim_count();
self.vim_change_current_lines(count);
self.clear_vim_register_selection();
self.begin_vim_change_recording(VimRepeatTarget::Lines(count));
return;
}
if self.vim_normal_keymap.yank_line.is_pressed(event) {
@@ -909,7 +927,9 @@ impl TextArea {
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) {
let count = self.take_vim_operator_count();
let previous_len = self.text.len();
self.vim_kill_current_lines(count);
self.record_vim_delete_if_changed(VimRepeatTarget::Lines(count), previous_len);
self.clear_vim_register_selection();
return true;
}
@@ -922,6 +942,7 @@ impl TextArea {
if op == VimOperator::Change && self.vim_operator_keymap.change_line.is_pressed(event) {
let count = self.take_vim_operator_count();
self.vim_change_current_lines(count);
self.begin_vim_change_recording(VimRepeatTarget::Lines(count));
self.clear_vim_register_selection();
return true;
}
@@ -987,7 +1008,12 @@ impl TextArea {
};
let count = self.take_vim_operator_count();
if let Some(range) = self.counted_text_object_range(object, scope, count) {
self.apply_vim_operator_to_range(op, range);
let target = VimRepeatTarget::TextObject {
object,
scope,
count,
};
self.apply_vim_operator_to_range(op, range, Some(target));
}
self.clear_vim_register_selection();
true
@@ -1033,6 +1059,7 @@ impl TextArea {
}
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion, count: usize) {
let previous_len = self.text.len();
match op {
VimOperator::Delete => {
if let Some(range) = self.range_for_motion(motion, count) {
@@ -1053,6 +1080,14 @@ impl TextArea {
}
}
}
let target = VimRepeatTarget::Motion { motion, count };
match op {
VimOperator::Delete => self.record_vim_delete_if_changed(target, previous_len),
VimOperator::Change if self.vim_mode == VimMode::Insert => {
self.begin_vim_change_recording(target);
}
VimOperator::Yank | VimOperator::Change => {}
}
self.clear_vim_register_selection();
}
@@ -1070,7 +1105,13 @@ impl TextArea {
self.range_for_motion(motion, count)
}
fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range<usize>) {
fn apply_vim_operator_to_range(
&mut self,
op: VimOperator,
range: Range<usize>,
repeat_target: Option<VimRepeatTarget>,
) {
let previous_len = self.text.len();
match op {
VimOperator::Delete => self.kill_range(range),
VimOperator::Yank => self.yank_range(range),
@@ -1079,6 +1120,15 @@ impl TextArea {
self.vim_mode = VimMode::Insert;
}
}
if let Some(target) = repeat_target {
match op {
VimOperator::Delete => self.record_vim_delete_if_changed(target, previous_len),
VimOperator::Change if self.vim_mode == VimMode::Insert => {
self.begin_vim_change_recording(target);
}
VimOperator::Yank | VimOperator::Change => {}
}
}
self.clear_vim_register_selection();
}
@@ -1604,6 +1654,7 @@ impl TextArea {
if start > end || end > self.text.len() {
return false;
}
self.invalidate_vim_repeat_change();
let removed_len = end - start;
let inserted_len = new.len();
@@ -1655,6 +1706,7 @@ impl TextArea {
}
pub fn insert_element(&mut self, text: &str) -> u64 {
self.invalidate_vim_repeat_change();
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
@@ -1666,6 +1718,7 @@ impl TextArea {
#[cfg(not(target_os = "linux"))]
pub fn insert_named_element(&mut self, text: &str, id: String) {
self.invalidate_vim_repeat_change();
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
@@ -1681,6 +1734,7 @@ impl TextArea {
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
self.invalidate_vim_repeat_change();
let range = self.elements[idx].range.clone();
self.replace_range_raw(range, text);
self.elements.retain(|e| e.name.as_deref() != Some(id));
@@ -1699,6 +1753,7 @@ impl TextArea {
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
self.invalidate_vim_repeat_change();
let old_range = self.elements[elem_idx].range.clone();
let start = old_range.start;
self.replace_range_raw(old_range, text);
@@ -1757,6 +1812,7 @@ impl TextArea {
return None;
}
let id = self.add_element(start..end);
self.invalidate_vim_repeat_change();
Some(id)
}
@@ -1769,7 +1825,11 @@ impl TextArea {
let len_before = self.elements.len();
self.elements
.retain(|elem| elem.range.start != start || elem.range.end != end);
len_before != self.elements.len()
let removed = len_before != self.elements.len();
if removed {
self.invalidate_vim_repeat_change();
}
removed
}
fn next_element_id(&mut self) -> u64 {
@@ -2991,6 +3051,119 @@ mod tests {
assert_eq!(t.vim_mode_label(), Some("Normal"));
}
#[test]
fn vim_dot_repeats_deletes_and_change_insert_edits() {
let mut t = ta_with("one two three");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "three");
let mut t = ta_with("one two");
t.set_cursor(/*pos*/ 0);
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('w'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
t.set_cursor(/*pos*/ "bar ".len());
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "bar bar");
assert_eq!(t.vim_mode_label(), Some("Normal"));
}
#[test]
fn vim_dot_repeats_paste_and_visual_changes() {
let mut t = ta_with("a");
t.kill_buffer = "!".to_string();
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "a!!");
let mut t = ta_with("one two");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
t.set_cursor(/*pos*/ "x ".len());
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "x x");
let mut t = ta_with("one\ntwo");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT));
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
t.set_cursor(/*pos*/ "x\n".len());
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "x\nx");
}
#[test]
fn vim_dot_repeats_substitutions_and_find_deletes() {
let mut t = ta_with("ab cd");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
t.set_cursor(/*pos*/ "Xb ".len());
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "Xb Xd");
let mut t = ta_with("one\ntwo");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
t.set_cursor(/*pos*/ "x\n".len());
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "x\nx");
let mut t = ta_with("a,b,c");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "c");
}
#[test]
fn vim_atomic_element_updates_invalidate_dot_repeat() {
let mut t = ta_with("abc");
t.set_cursor(/*pos*/ 0);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(t.text(), "bc");
t.add_element_range(/*range*/ 0..1).expect("valid element");
t.set_cursor(/*pos*/ 1);
t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
assert_eq!(t.text(), "bc");
}
#[test]
fn vim_find_till_motions_repeat_and_stay_on_current_line() {
let mut t = ta_with("ab,cd,ef\naz");
@@ -8,6 +8,7 @@ mod count;
mod find;
mod prose;
mod register;
mod repeat;
mod tag;
mod visual;
@@ -85,6 +86,69 @@ pub(super) struct VimFind {
pub(super) target: char,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimRepeatTarget {
Characters(usize),
Lines(usize),
Motion {
motion: VimMotion,
count: usize,
},
TextObject {
object: VimTextObject,
scope: VimTextObjectScope,
count: usize,
},
Find {
find: VimFind,
count: usize,
},
Visual {
kind: VimVisualKind,
atomic_units: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimInsertEdit {
Insert(String),
DeleteBackward,
DeleteForward,
DeleteBackwardWord,
DeleteForwardWord,
KillLineStart,
KillWholeLine,
KillLineEnd,
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
MoveWordLeft,
MoveWordRight,
MoveLineStart { move_up_at_bol: bool },
MoveLineEnd { move_down_at_eol: bool },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct VimInsertRecording {
pub(super) target: VimRepeatTarget,
pub(super) edits: Vec<VimInsertEdit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VimRepeatChange {
Delete(VimRepeatTarget),
Change {
target: VimRepeatTarget,
edits: Vec<VimInsertEdit>,
},
Paste {
text: String,
kind: super::KillBufferKind,
count: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VimTextObjectScope {
Inner,
@@ -133,7 +133,7 @@ impl TextArea {
self.vim_mode = super::VimMode::Insert;
}
fn current_line_range_with_count(&self, count: usize) -> Range<usize> {
pub(in super::super) fn current_line_range_with_count(&self, count: usize) -> Range<usize> {
let start = self.beginning_of_current_line();
let mut end = self.current_line_range_with_newline().end;
for _ in 1..count {
@@ -2,6 +2,7 @@ use super::super::TextArea;
use super::VimFind;
use super::VimFindKind;
use super::VimOperator;
use super::VimRepeatTarget;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -102,7 +103,11 @@ impl TextArea {
fn execute_vim_find(&mut self, operator: Option<VimOperator>, find: VimFind, count: usize) {
if let Some(operator) = operator {
if let Some(range) = self.range_for_find(find, count) {
self.apply_vim_operator_to_range(operator, range);
self.apply_vim_operator_to_range(
operator,
range,
Some(VimRepeatTarget::Find { find, count }),
);
}
} else if let Some(target) = self.target_for_find(find, count) {
self.set_cursor(target);
@@ -66,8 +66,10 @@ impl TextArea {
if source.0.is_empty() {
return;
}
let previous_len = self.text.len();
for _ in 0..count {
self.paste_text_after_cursor(&source.0, source.1);
}
self.record_vim_paste_if_changed(source.0, source.1, count, previous_len);
}
}
@@ -0,0 +1,337 @@
use super::super::KillBufferKind;
use super::super::TextArea;
use super::VimInsertEdit;
use super::VimInsertRecording;
use super::VimMode;
use super::VimOperator;
use super::VimRepeatChange;
use super::VimRepeatTarget;
use super::VimVisualKind;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::ops::Range;
impl TextArea {
pub(in super::super) fn invalidate_vim_repeat_change(&mut self) {
if !self.vim_replaying_change {
self.vim_last_change = None;
self.vim_insert_recording = None;
}
}
pub(in super::super) fn record_vim_delete_if_changed(
&mut self,
target: VimRepeatTarget,
previous_len: usize,
) {
if !self.vim_replaying_change && self.text.len() < previous_len {
self.vim_last_change = Some(VimRepeatChange::Delete(target));
self.vim_insert_recording = None;
}
}
pub(in super::super) fn begin_vim_change_recording(&mut self, target: VimRepeatTarget) {
if !self.vim_replaying_change {
self.vim_insert_recording = Some(VimInsertRecording {
target,
edits: Vec::new(),
});
}
}
pub(in super::super) fn complete_vim_change_recording(&mut self) {
if self.vim_replaying_change {
return;
}
if let Some(recording) = self.vim_insert_recording.take() {
self.vim_last_change = Some(VimRepeatChange::Change {
target: recording.target,
edits: recording.edits,
});
}
}
pub(in super::super) fn record_vim_paste_if_changed(
&mut self,
text: String,
kind: KillBufferKind,
count: usize,
previous_len: usize,
) {
if !self.vim_replaying_change && self.text.len() > previous_len {
self.vim_last_change = Some(VimRepeatChange::Paste { text, kind, count });
self.vim_insert_recording = None;
}
}
pub(in super::super) fn record_vim_insert_edit(&mut self, edit: Option<VimInsertEdit>) {
if self.vim_replaying_change {
return;
}
if let Some(recording) = self.vim_insert_recording.as_mut()
&& let Some(edit) = edit
{
recording.edits.push(edit);
}
}
pub(in super::super) fn vim_insert_edit_for_event(
&self,
event: KeyEvent,
) -> Option<VimInsertEdit> {
let keymap = &self.editor_keymap;
if keymap.insert_newline.is_pressed(event) {
return Some(VimInsertEdit::Insert("\n".to_string()));
}
if keymap.delete_backward_word.is_pressed(event) {
return Some(VimInsertEdit::DeleteBackwardWord);
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = event
&& is_altgr(modifiers)
{
return Some(VimInsertEdit::Insert(c.to_string()));
}
if keymap.delete_backward.is_pressed(event) {
return Some(VimInsertEdit::DeleteBackward);
}
if keymap.delete_forward_word.is_pressed(event) {
return Some(VimInsertEdit::DeleteForwardWord);
}
if keymap.delete_forward.is_pressed(event) {
return Some(VimInsertEdit::DeleteForward);
}
if keymap.kill_line_start.is_pressed(event) {
return Some(VimInsertEdit::KillLineStart);
}
if keymap.kill_whole_line.is_pressed(event) {
return Some(VimInsertEdit::KillWholeLine);
}
if keymap.kill_line_end.is_pressed(event) {
return Some(VimInsertEdit::KillLineEnd);
}
if keymap.yank.is_pressed(event) {
return (!self.kill_buffer.is_empty())
.then(|| VimInsertEdit::Insert(self.kill_buffer.clone()));
}
if keymap.move_word_left.is_pressed(event) {
return Some(VimInsertEdit::MoveWordLeft);
}
if keymap.move_word_right.is_pressed(event) {
return Some(VimInsertEdit::MoveWordRight);
}
if keymap.move_left.is_pressed(event) {
return Some(VimInsertEdit::MoveLeft);
}
if keymap.move_right.is_pressed(event) {
return Some(VimInsertEdit::MoveRight);
}
if keymap.move_up.is_pressed(event) {
return Some(VimInsertEdit::MoveUp);
}
if keymap.move_down.is_pressed(event) {
return Some(VimInsertEdit::MoveDown);
}
if keymap.move_line_start.is_pressed(event) {
let move_up_at_bol = matches!(
event,
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
}
);
return Some(VimInsertEdit::MoveLineStart { move_up_at_bol });
}
if keymap.move_line_end.is_pressed(event) {
let move_down_at_eol = matches!(
event,
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
}
);
return Some(VimInsertEdit::MoveLineEnd { move_down_at_eol });
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} = event
&& !c.is_ascii_control()
{
return Some(VimInsertEdit::Insert(c.to_string()));
}
None
}
pub(in super::super) fn repeat_vim_change(&mut self) {
let Some(change) = self.vim_last_change.clone() else {
return;
};
self.vim_replaying_change = true;
match change {
VimRepeatChange::Delete(target) => {
self.execute_vim_repeat_target(VimOperator::Delete, target);
}
VimRepeatChange::Change { target, edits } => {
if self.execute_vim_repeat_target(VimOperator::Change, target) {
for edit in edits {
self.apply_vim_insert_edit(edit);
}
self.exit_vim_insert_after_change();
}
}
VimRepeatChange::Paste { text, kind, count } => {
for _ in 0..count {
self.paste_text_after_cursor(&text, kind);
}
}
}
self.vim_replaying_change = false;
self.clear_vim_counts();
self.clear_vim_register_selection();
}
fn execute_vim_repeat_target(
&mut self,
operator: VimOperator,
target: VimRepeatTarget,
) -> bool {
let previous_len = self.text.len();
match target {
VimRepeatTarget::Characters(count) => {
self.delete_forward_kill(count);
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
}
}
VimRepeatTarget::Lines(count) => match operator {
VimOperator::Delete => self.vim_kill_current_lines(count),
VimOperator::Change => self.vim_change_current_lines(count),
VimOperator::Yank => unreachable!("repeat changes never yank"),
},
VimRepeatTarget::Motion { motion, count } => {
self.apply_vim_operator(operator, motion, count);
}
VimRepeatTarget::TextObject {
object,
scope,
count,
} => {
if let Some(range) = self.counted_text_object_range(object, scope, count) {
self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None);
}
}
VimRepeatTarget::Find { find, count } => {
if let Some(range) = self.range_for_find(find, count) {
self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None);
}
}
VimRepeatTarget::Visual { kind, atomic_units } => {
if let Some(mut range) = self.visual_repeat_range(kind, atomic_units) {
if operator == VimOperator::Change
&& kind == VimVisualKind::Linewise
&& range.end > range.start
&& self.text[..range.end].ends_with('\n')
{
range.end -= 1;
}
let buffer_kind = if kind == VimVisualKind::Linewise {
KillBufferKind::Linewise
} else {
KillBufferKind::Characterwise
};
self.kill_range_with_kind(range, buffer_kind);
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
}
}
}
}
self.text.len() < previous_len
|| operator == VimOperator::Change && self.vim_mode == VimMode::Insert
}
pub(in super::super) fn visual_repeat_target(
&self,
kind: VimVisualKind,
range: Range<usize>,
) -> VimRepeatTarget {
let atomic_units = match kind {
VimVisualKind::Characterwise => {
let mut pos = range.start;
let mut units = 0;
while pos < range.end {
let next = self.next_atomic_boundary(pos);
if next <= pos {
break;
}
units += 1;
pos = next;
}
units.max(1)
}
VimVisualKind::Linewise => self.text[range].lines().count().max(1),
};
VimRepeatTarget::Visual { kind, atomic_units }
}
fn visual_repeat_range(
&self,
kind: VimVisualKind,
atomic_units: usize,
) -> Option<Range<usize>> {
let range = match kind {
VimVisualKind::Characterwise => {
let mut end = self.cursor_pos;
for _ in 0..atomic_units {
end = self.next_atomic_boundary(end);
}
self.cursor_pos..end
}
VimVisualKind::Linewise => self.current_line_range_with_count(atomic_units),
};
(range.start < range.end).then(|| self.expand_range_to_element_boundaries(range))
}
fn apply_vim_insert_edit(&mut self, edit: VimInsertEdit) {
match edit {
VimInsertEdit::Insert(text) => self.insert_str(&text),
VimInsertEdit::DeleteBackward => self.delete_backward(/*n*/ 1),
VimInsertEdit::DeleteForward => self.delete_forward(/*n*/ 1),
VimInsertEdit::DeleteBackwardWord => self.delete_backward_word(),
VimInsertEdit::DeleteForwardWord => self.delete_forward_word(),
VimInsertEdit::KillLineStart => self.kill_to_beginning_of_line(),
VimInsertEdit::KillWholeLine => self.kill_current_line(),
VimInsertEdit::KillLineEnd => self.kill_to_end_of_line(),
VimInsertEdit::MoveLeft => self.move_cursor_left(),
VimInsertEdit::MoveRight => self.move_cursor_right(),
VimInsertEdit::MoveUp => self.move_cursor_up(),
VimInsertEdit::MoveDown => self.move_cursor_down(),
VimInsertEdit::MoveWordLeft => self.set_cursor(self.beginning_of_previous_word()),
VimInsertEdit::MoveWordRight => self.set_cursor(self.end_of_next_word()),
VimInsertEdit::MoveLineStart { move_up_at_bol } => {
self.move_cursor_to_beginning_of_line(move_up_at_bol);
}
VimInsertEdit::MoveLineEnd { move_down_at_eol } => {
self.move_cursor_to_end_of_line(move_down_at_eol);
}
}
}
pub(in super::super) fn exit_vim_insert_after_change(&mut self) {
let bol = self.beginning_of_current_line();
if self.cursor_pos > bol {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
}
self.enter_vim_normal_mode();
}
}
@@ -225,6 +225,15 @@ impl TextArea {
} else {
KillBufferKind::Characterwise
};
let repeat_target = self.visual_repeat_target(
if linewise {
VimVisualKind::Linewise
} else {
VimVisualKind::Characterwise
},
range.clone(),
);
let previous_len = self.text.len();
let start = range.start;
match operator {
VimOperator::Delete => self.kill_range_with_kind(range, kind),
@@ -236,9 +245,13 @@ impl TextArea {
self.clear_vim_register_selection();
if operator == VimOperator::Change {
self.vim_mode = VimMode::Insert;
self.begin_vim_change_recording(repeat_target);
} else {
self.vim_mode = VimMode::Normal;
self.set_cursor(start.min(self.vim_normal_end_cursor()));
if operator == VimOperator::Delete {
self.record_vim_delete_if_changed(repeat_target, previous_len);
}
}
}
+23
View File
@@ -162,6 +162,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) till_backward: Vec<KeyBinding>,
pub(crate) repeat_find: Vec<KeyBinding>,
pub(crate) repeat_find_reverse: Vec<KeyBinding>,
pub(crate) repeat_change: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
@@ -523,6 +524,7 @@ impl RuntimeKeymap {
till_backward: resolve_local!(keymap, defaults, vim_normal, till_backward),
repeat_find: resolve_local!(keymap, defaults, vim_normal, repeat_find),
repeat_find_reverse: resolve_local!(keymap, defaults, vim_normal, repeat_find_reverse),
repeat_change: resolve_local!(keymap, defaults, vim_normal, repeat_change),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
@@ -634,6 +636,10 @@ impl RuntimeKeymap {
keymap.vim_normal.repeat_find_reverse.as_ref(),
vim_normal.repeat_find_reverse.as_slice(),
),
(
keymap.vim_normal.repeat_change.as_ref(),
vim_normal.repeat_change.as_slice(),
),
(
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
@@ -721,6 +727,11 @@ impl RuntimeKeymap {
.repeat_find_reverse
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.repeat_change.is_none() {
vim_normal
.repeat_change
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.select_register.is_none() {
vim_normal
.select_register
@@ -1275,6 +1286,7 @@ impl RuntimeKeymap {
],
repeat_find: default_bindings![plain(KeyCode::Char(';'))],
repeat_find_reverse: default_bindings![plain(KeyCode::Char(','))],
repeat_change: default_bindings![plain(KeyCode::Char('.'))],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
@@ -1741,6 +1753,7 @@ impl RuntimeKeymap {
"repeat_find_reverse",
self.vim_normal.repeat_find_reverse.as_slice(),
),
("repeat_change", self.vim_normal.repeat_change.as_slice()),
("delete_char", self.vim_normal.delete_char.as_slice()),
(
"delete_to_line_end",
@@ -2698,6 +2711,16 @@ mod tests {
assert_eq!(runtime.vim_normal.enter_visual_line, Vec::new());
}
#[test]
fn configured_legacy_vim_binding_prunes_repeat_change_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("."));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.vim_normal.repeat_change, Vec::new());
}
#[test]
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
let mut keymap = TuiKeymap::default();
+3
View File
@@ -139,6 +139,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "till_backward", "Move until after the previous target character on the current line."),
action("vim_normal", "Vim normal", "repeat_find", "Repeat the latest find or till motion."),
action("vim_normal", "Vim normal", "repeat_find_reverse", "Repeat the latest find or till motion in reverse."),
action("vim_normal", "Vim normal", "repeat_change", "Repeat the most recent text-changing Vim command."),
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
@@ -305,6 +306,7 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "till_backward") => Some(&mut keymap.vim_normal.till_backward),
("vim_normal", "repeat_find") => Some(&mut keymap.vim_normal.repeat_find),
("vim_normal", "repeat_find_reverse") => Some(&mut keymap.vim_normal.repeat_find_reverse),
("vim_normal", "repeat_change") => Some(&mut keymap.vim_normal.repeat_change),
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
@@ -453,6 +455,7 @@ pub(super) fn bindings_for_action<'a>(
("vim_normal", "till_backward") => Some(runtime_keymap.vim_normal.till_backward.as_slice()),
("vim_normal", "repeat_find") => Some(runtime_keymap.vim_normal.repeat_find.as_slice()),
("vim_normal", "repeat_find_reverse") => Some(runtime_keymap.vim_normal.repeat_find_reverse.as_slice()),
("vim_normal", "repeat_change") => Some(runtime_keymap.vim_normal.repeat_change.as_slice()),
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
131 actions, 1 customized, 2 unbound.
132 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.
132 actions, 0 customized, 3 unbound.
133 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 (131 selectable)
tab: All (132 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 (72 selectable)
tab: Vim (73 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.
131 actions, 0 customized, 2 unbound.
132 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.
131 actions, 0 customized, 2 unbound.
132 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug