mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add Vim replace mode to the TUI composer (#42194)
## What changed - Add Vim Replace mode, entered with `R`, which overwrites graphemes and appends at the end of a line. - Restore overwritten text with Backspace and record replace edits for undo and dot-repeat. - Preserve composer behavior for attachments, completions, paste bursts, history previews, and external editor imports while replacing text. - Expose `vim_normal.enter_replace_mode` in the configurable TUI keymap. ## Testing - Add coverage for grapheme replacement, Backspace recovery, dot-repeat, custom bindings, atomic attachments, completions, paste handling, and history interactions. GitOrigin-RevId: cb04e3adcce013c1eb7b82c994b00e944310c481
This commit is contained in:
committed by
copyberry
parent
02f47d3fb3
commit
5a0419edb5
@@ -217,6 +217,8 @@ pub struct TuiVimNormalKeymap {
|
||||
pub open_line_below: Option<KeybindingsSpec>,
|
||||
/// Open a new line above and enter insert mode (`O`).
|
||||
pub open_line_above: Option<KeybindingsSpec>,
|
||||
/// Enter replace mode and overwrite characters under the cursor (`R`).
|
||||
pub enter_replace_mode: Option<KeybindingsSpec>,
|
||||
/// Move cursor left (`h`).
|
||||
pub move_left: Option<KeybindingsSpec>,
|
||||
/// Move cursor right (`l`).
|
||||
|
||||
@@ -3927,6 +3927,7 @@
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
"enter_replace_mode": null,
|
||||
"find_backward": null,
|
||||
"find_forward": null,
|
||||
"insert_line_start": null,
|
||||
@@ -4755,6 +4756,7 @@
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
"enter_replace_mode": null,
|
||||
"find_backward": null,
|
||||
"find_forward": null,
|
||||
"insert_line_start": null,
|
||||
@@ -5100,6 +5102,14 @@
|
||||
],
|
||||
"description": "Enter insert mode at cursor (`i`)."
|
||||
},
|
||||
"enter_replace_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter replace mode and overwrite characters under the cursor (`R`)."
|
||||
},
|
||||
"find_backward": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
//!
|
||||
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
|
||||
//! running, `Tab` submits just like Enter so input is never dropped.
|
||||
//! Vim Replace shares Insert's composer actions; only textarea editing differs.
|
||||
//! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew.
|
||||
//! Token markers added during Replace are removed when restoring overwritten characters.
|
||||
//! `Tab` does not submit when entering a `!` shell command.
|
||||
//!
|
||||
//! On submit/queue paths, the composer:
|
||||
@@ -1233,7 +1236,8 @@ impl ChatComposer {
|
||||
&& self.image_paste_enabled()
|
||||
&& self.handle_paste_image_path(&pasted)
|
||||
{
|
||||
self.draft.textarea.insert_str(" ");
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
} else {
|
||||
self.insert_str(&pasted);
|
||||
}
|
||||
@@ -1336,7 +1340,7 @@ impl ChatComposer {
|
||||
}
|
||||
self.attachments.local_images = kept_images;
|
||||
|
||||
// Rebuild textarea so placeholders become elements again.
|
||||
// Import literally so placeholders remain atomic and Replace recovery starts empty.
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
let mut remaining: HashMap<&str, usize> = HashMap::new();
|
||||
for img in &self.attachments.local_images {
|
||||
@@ -1360,14 +1364,14 @@ impl ChatComposer {
|
||||
continue;
|
||||
}
|
||||
if pos > idx {
|
||||
self.draft.textarea.insert_str(&text[idx..pos]);
|
||||
self.draft.textarea.insert_str_at(idx, &text[idx..pos]);
|
||||
}
|
||||
self.draft.textarea.insert_element(ph);
|
||||
*count -= 1;
|
||||
idx = pos + ph.len();
|
||||
}
|
||||
if idx < text.len() {
|
||||
self.draft.textarea.insert_str(&text[idx..]);
|
||||
self.draft.textarea.insert_str_at(idx, &text[idx..]);
|
||||
}
|
||||
|
||||
// Keep local image placeholders normalized in attachment order after the
|
||||
@@ -2113,14 +2117,13 @@ impl ChatComposer {
|
||||
before,
|
||||
retro_chars as usize,
|
||||
) {
|
||||
if !grab.grabbed.is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.replace_range(grab.start_byte..safe_cur, "");
|
||||
if grab.grabbed.is_empty()
|
||||
|| self.draft.textarea.retract_paste_burst(grab.start_byte)
|
||||
{
|
||||
self.draft.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// seed the paste burst buffer with everything (grabbed + new)
|
||||
self.draft.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
self.draft.paste_burst.clear_after_explicit_paste();
|
||||
}
|
||||
// If decide_begin_buffer opted not to start buffering,
|
||||
// fall through to normal insertion below.
|
||||
@@ -2468,12 +2471,12 @@ impl ChatComposer {
|
||||
.next()
|
||||
.is_some_and(|c| !c.is_whitespace());
|
||||
if separator_precedes_suffix {
|
||||
self.draft.textarea.insert_str(" ");
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
} else {
|
||||
self.draft.textarea.set_cursor(after_separator);
|
||||
}
|
||||
} else {
|
||||
self.draft.textarea.insert_str(" ");
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3735,13 +3738,13 @@ impl ChatComposer {
|
||||
before,
|
||||
retro_chars as usize,
|
||||
) {
|
||||
if !grab.grabbed.is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.replace_range(grab.start_byte..safe_cur, "");
|
||||
if grab.grabbed.is_empty()
|
||||
|| self.draft.textarea.retract_paste_burst(grab.start_byte)
|
||||
{
|
||||
self.draft.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
self.draft.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
self.draft.paste_burst.clear_after_explicit_paste();
|
||||
}
|
||||
// If decide_begin_buffer opted not to start buffering,
|
||||
// fall through to normal insertion below.
|
||||
@@ -6117,6 +6120,31 @@ mod tests {
|
||||
);
|
||||
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
|
||||
assert!(!composer.footer.esc_backtrack_hint);
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
composer.vim_mode_indicator_span(),
|
||||
Some("Vim: Replace".cyan())
|
||||
);
|
||||
snapshot_composer_state(
|
||||
"vim_replace_mode",
|
||||
/*enhanced_keys_supported*/ true,
|
||||
|composer| {
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
|
||||
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
|
||||
},
|
||||
);
|
||||
composer.insert_str("ok");
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||||
assert_eq!(composer.current_text(), "");
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
composer.vim_mode_indicator_span(),
|
||||
Some("Vim: Normal".magenta())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6620,6 +6648,9 @@ mod tests {
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(style_output(composer.cursor_style(area)), default,);
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
|
||||
assert_eq!(style_output(composer.cursor_style(area)), default,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,8 +14,11 @@ use super::super::super::chat_composer_history::HistoryEntry;
|
||||
use super::super::ChatComposer;
|
||||
use super::super::InputResult;
|
||||
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use super::super::PasteBurst;
|
||||
use super::super::tests::new_test_composer;
|
||||
use super::super::tests::snapshot_composer_state_with_width;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
fn vim_composer(text: &str) -> ChatComposer {
|
||||
let (mut composer, _receiver) = new_test_composer();
|
||||
@@ -336,7 +339,11 @@ fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() {
|
||||
#[test]
|
||||
fn canceled_history_preview_preserves_active_vim_commands() {
|
||||
for query in ["", "archive", "missing"] {
|
||||
for (command, edited) in [("iX", "XYalpha beta"), ("cwX", "XY beta")] {
|
||||
for (command, edited) in [
|
||||
("iX", "XYalpha beta"),
|
||||
("cwX", "XY beta"),
|
||||
("RX", "XYpha beta"),
|
||||
] {
|
||||
let mut composer = vim_composer("alpha beta");
|
||||
composer
|
||||
.history
|
||||
@@ -345,6 +352,11 @@ fn canceled_history_preview_preserves_active_vim_commands() {
|
||||
ctrl_r(&mut composer);
|
||||
keys(&mut composer, query);
|
||||
escape(&mut composer);
|
||||
if command == "RX" {
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(composer.current_text(), "alpha beta");
|
||||
keys(&mut composer, "X");
|
||||
}
|
||||
keys(&mut composer, "Y");
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), edited);
|
||||
@@ -464,6 +476,13 @@ fn undo_and_redo_restore_deleted_image_attachment() {
|
||||
composer.attach_image(PathBuf::from("example.png"));
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
let before = composer.draft_snapshot();
|
||||
keys(&mut composer, "RX");
|
||||
ctrl_r(&mut composer);
|
||||
escape(&mut composer);
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
escape(&mut composer);
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
keys(&mut composer, "x");
|
||||
assert_eq!(composer.current_text(), "alpha ");
|
||||
assert!(composer.local_image_paths().is_empty());
|
||||
@@ -520,3 +539,127 @@ fn find_jump_and_search_navigation_never_snapshot_the_draft() {
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), "alpha beta\nalpha gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_replace_preserves_slash_completion_and_dispatch() {
|
||||
let mut composer = vim_composer("");
|
||||
keys(&mut composer, "R/dif");
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
assert_eq!(composer.current_text(), "/diff ");
|
||||
let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_replace_backspace_restores_characters_in_new_slash_element() {
|
||||
snapshot_composer_state_with_width(
|
||||
"vim_replace_slash_recovery",
|
||||
/*width*/ 100,
|
||||
/*enhanced_keys_supported*/ true,
|
||||
|composer| {
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
composer.set_text_content("abcde tail".into(), Vec::new(), Vec::new());
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
keys(composer, "R/diff");
|
||||
assert_eq!(composer.draft.textarea.element_payloads(), vec!["/diff"]);
|
||||
for expected in [("/dife tail", 4), ("/dide tail", 3)] {
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.current_cursor()),
|
||||
(expected.0.to_string(), expected.1)
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_editing_modes_use_the_same_large_and_image_paste_handling() {
|
||||
let temporary = tempfile::tempdir().expect("create image directory");
|
||||
let path = temporary.path().join("image.png");
|
||||
image::RgbaImage::new(/*width*/ 1, /*height*/ 1)
|
||||
.save(&path)
|
||||
.expect("write image");
|
||||
for pasted in [
|
||||
"x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1),
|
||||
path.to_string_lossy().into_owned(),
|
||||
] {
|
||||
let mut insert = vim_composer("next");
|
||||
keys(&mut insert, "i");
|
||||
insert.handle_paste(pasted.clone());
|
||||
let mut replace = vim_composer("next");
|
||||
keys(&mut replace, "R");
|
||||
replace.handle_paste(pasted);
|
||||
assert_eq!(replace.draft_snapshot(), insert.draft_snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_replace_paste_bursts_preserve_overwritten_text_and_repeat() {
|
||||
for pasted in ["XYZ", "日\u{3000}語"] {
|
||||
let mut composer = vim_composer("abcd");
|
||||
keys(&mut composer, "R");
|
||||
composer.set_disable_paste_burst(/*disabled*/ false);
|
||||
let now = std::time::Instant::now();
|
||||
for ch in pasted.chars() {
|
||||
let _ = composer.handle_input_basic_with_time(KeyEvent::from(KeyCode::Char(ch)), now);
|
||||
}
|
||||
assert!(composer.is_in_paste_burst());
|
||||
composer.handle_paste_burst_flush(now + PasteBurst::recommended_active_flush_delay());
|
||||
assert_eq!(composer.current_text(), format!("{pasted}d"));
|
||||
escape(&mut composer);
|
||||
keys(&mut composer, "u.");
|
||||
assert_eq!(composer.current_text(), format!("{pasted}d"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_replace_completion_preserves_suffix() {
|
||||
snapshot_composer_state_with_width(
|
||||
"vim_replace_completion_suffix",
|
||||
/*width*/ 100,
|
||||
/*enhanced_keys_supported*/ true,
|
||||
|composer| {
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
for (text, cursor, command, path) in [
|
||||
("@ma next", 3, "R", "src/main.rs"),
|
||||
("abc next", 0, "R@ma", "ma"),
|
||||
] {
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
composer.set_text_content(text.into(), Vec::new(), Vec::new());
|
||||
composer.draft.textarea.set_cursor(cursor);
|
||||
keys(composer, command);
|
||||
composer.insert_selected_file_path(0.."@ma".len(), path);
|
||||
composer.sync_popups();
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.current_cursor()),
|
||||
(format!("{path} next"), path.len() + 1)
|
||||
);
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.current_cursor()),
|
||||
(format!("{path} next"), path.len())
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_import_preserves_replace_backspace_recovery() {
|
||||
let mut composer = vim_composer("old");
|
||||
keys(&mut composer, "RX");
|
||||
composer.apply_external_edit("abc".into());
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 1);
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(composer.current_text(), "bc");
|
||||
for code in [KeyCode::Char('X'), KeyCode::Backspace] {
|
||||
let _ = composer.handle_key_event(KeyEvent::from(code));
|
||||
}
|
||||
let textarea = &composer.draft.textarea;
|
||||
assert_eq!(
|
||||
(textarea.text(), textarea.vim_mode_label()),
|
||||
("bc", Some("Replace"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ impl BottomPaneView for CustomPromptView {
|
||||
}
|
||||
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
self.textarea.uses_vim_insert_cursor() || self.textarea.is_vim_operator_pending()
|
||||
self.textarea
|
||||
.should_handle_vim_insert_escape(KeyEvent::from(KeyCode::Esc))
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
@@ -353,7 +354,7 @@ impl Renderable for CustomPromptView {
|
||||
|
||||
let hint_y = hint_blank_y.saturating_add(1);
|
||||
if hint_y < area.y.saturating_add(area.height) {
|
||||
let mut hint_line = if self.textarea.uses_vim_insert_cursor() {
|
||||
let mut hint_line = if self.prefer_esc_to_handle_key_event() {
|
||||
accept_cancel_hint_line(
|
||||
Some(key_hint::plain(KeyCode::Enter).into()),
|
||||
"to confirm",
|
||||
|
||||
@@ -123,6 +123,8 @@ fn vim_prompt_hint_tracks_escape_behavior() {
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal");
|
||||
assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta));
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Char('R')));
|
||||
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Replace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -195,6 +197,14 @@ fn vim_insert_cursor_tracks_mode_and_normal_mode_commands() {
|
||||
assert_eq!(view.textarea.text(), "rename");
|
||||
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Char('R')));
|
||||
assert!(view.prefer_esc_to_handle_key_event());
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
|
||||
assert_eq!(view.textarea.text(), "renaxe");
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(!view.is_complete());
|
||||
assert!(!view.prefer_esc_to_handle_key_event());
|
||||
assert_eq!(view.textarea.text(), "renaxe");
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(view.is_complete());
|
||||
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled));
|
||||
@@ -287,6 +297,25 @@ fn background_prefill_survives_cursor_movement() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_prefill_preserves_replace_backspace_recovery() {
|
||||
let request_id = Uuid::new_v4();
|
||||
let (view, _submitted_rx) = custom_prompt_view();
|
||||
let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
|
||||
view.enable_vim_in_insert_mode();
|
||||
for code in [KeyCode::Esc, KeyCode::Char('R')] {
|
||||
view.handle_key_event(KeyEvent::from(code));
|
||||
}
|
||||
assert!(view.apply_text_suggestion(request_id, Some("abc")));
|
||||
for code in [KeyCode::Left, KeyCode::Char('X'), KeyCode::Backspace] {
|
||||
view.handle_key_event(KeyEvent::from(code));
|
||||
}
|
||||
assert_eq!(
|
||||
(view.textarea.text(), view.textarea.vim_mode_label()),
|
||||
("abc", Some("Replace"))
|
||||
);
|
||||
}
|
||||
|
||||
fn custom_prompt_view() -> (CustomPromptView, Receiver<String>) {
|
||||
let (submitted, submitted_rx) = std::sync::mpsc::channel();
|
||||
let view = CustomPromptView::new(
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› ma next "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left | Vim: Replace "
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Pasted Content 1001 chars] "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left | Vim: Replace "
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /dide tail "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" /diff show git diff (including untracked files) "
|
||||
@@ -318,7 +318,10 @@ impl TextArea {
|
||||
|
||||
/// Return the keymap context that owns the next editing key.
|
||||
pub(crate) fn keymap_context(&self) -> KeymapContext {
|
||||
if !self.vim_enabled || self.vim_mode == VimMode::Insert || self.vim_query().is_some() {
|
||||
if !self.vim_enabled
|
||||
|| matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
|| self.vim_query().is_some()
|
||||
{
|
||||
return KeymapContext::Editor;
|
||||
}
|
||||
match self.vim_pending {
|
||||
@@ -338,6 +341,7 @@ impl TextArea {
|
||||
if self.vim_enabled {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
self.vim_pending = VimPending::None;
|
||||
self.clear_vim_replace_recovery();
|
||||
self.cancel_vim_search();
|
||||
if self.vim_commands.pending_change.is_empty() && !self.vim_commands.replaying {
|
||||
self.start_vim_edit(VimAction::Insert(VimInsertPosition::Cursor));
|
||||
@@ -357,6 +361,7 @@ impl TextArea {
|
||||
self.vim_pending = VimPending::None;
|
||||
self.cancel_vim_search();
|
||||
self.preferred_col = None;
|
||||
self.clear_vim_replace_recovery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +371,7 @@ impl TextArea {
|
||||
/// like `dd` or `yw` remains command input instead of being converted into
|
||||
/// literal text.
|
||||
pub(crate) fn allows_paste_burst(&self) -> bool {
|
||||
!self.vim_enabled || self.vim_mode == VimMode::Insert
|
||||
!self.vim_enabled || matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
}
|
||||
|
||||
/// Return whether rendering should use the insert-mode cursor style.
|
||||
@@ -380,7 +385,8 @@ impl TextArea {
|
||||
/// transition rather than a popup cancel/backtrack or turn-interrupt shortcut.
|
||||
pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
|
||||
self.vim_enabled
|
||||
&& (self.vim_mode == VimMode::Insert || self.is_vim_operator_pending())
|
||||
&& (matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
|| self.is_vim_operator_pending())
|
||||
&& event.code == KeyCode::Esc
|
||||
&& event.modifiers == KeyModifiers::NONE
|
||||
&& matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
@@ -399,6 +405,7 @@ impl TextArea {
|
||||
Some(match self.vim_mode {
|
||||
VimMode::Normal => "Normal",
|
||||
VimMode::Insert => "Insert",
|
||||
VimMode::Replace => "Replace",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,6 +417,7 @@ impl TextArea {
|
||||
Some(match self.vim_mode {
|
||||
VimMode::Normal => "Vim: Normal".magenta(),
|
||||
VimMode::Insert => "Vim: Insert".green(),
|
||||
VimMode::Replace => "Vim: Replace".cyan(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,10 +427,15 @@ impl TextArea {
|
||||
|
||||
pub fn insert_str(&mut self, text: &str) {
|
||||
self.record_vim_inserted_text(text);
|
||||
self.insert_str_at(self.cursor_pos, text);
|
||||
if self.is_vim_replace_mode() {
|
||||
self.replace_vim_text(text);
|
||||
} else {
|
||||
self.insert_str_at(self.cursor_pos, text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
||||
self.clear_vim_replace_recovery();
|
||||
let pos = self.clamp_pos_for_insertion(pos);
|
||||
self.text.insert_str(pos, text);
|
||||
self.wrap_cache.replace(None);
|
||||
@@ -434,6 +447,12 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||
self.clear_vim_replace_recovery();
|
||||
self.replace_range_preserving_recovery(range, text);
|
||||
}
|
||||
|
||||
// Replace typing and Backspace keep contiguous recovery, but still respect atomic elements.
|
||||
fn replace_range_preserving_recovery(&mut self, range: Range<usize>, text: &str) {
|
||||
let range = self.expand_range_to_element_boundaries(range);
|
||||
self.replace_range_raw(range, text);
|
||||
}
|
||||
@@ -701,10 +720,12 @@ impl TextArea {
|
||||
}
|
||||
let prior_mode = self.vim_mode;
|
||||
match self.vim_mode {
|
||||
VimMode::Insert => self.handle_vim_insert(event),
|
||||
VimMode::Insert | VimMode::Replace => self.handle_vim_insert(event),
|
||||
VimMode::Normal => self.handle_vim_normal(event),
|
||||
}
|
||||
if prior_mode == VimMode::Insert && self.vim_mode == VimMode::Normal {
|
||||
if matches!(prior_mode, VimMode::Insert | VimMode::Replace)
|
||||
&& self.vim_mode == VimMode::Normal
|
||||
{
|
||||
self.finish_pending_vim_change();
|
||||
}
|
||||
}
|
||||
@@ -714,6 +735,12 @@ impl TextArea {
|
||||
self.leave_vim_insert_mode();
|
||||
return;
|
||||
}
|
||||
if self.is_vim_replace_mode()
|
||||
&& self.editor_keymap.delete_backward.is_pressed(event)
|
||||
&& self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ pub(super) enum VimMode {
|
||||
Normal,
|
||||
/// Insert mode routes input through the regular editor keymap until Escape is pressed.
|
||||
Insert,
|
||||
/// Replace mode overwrites existing graphemes through the regular editor keymap.
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -69,6 +69,8 @@ pub(super) enum VimEditTarget {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum VimAction {
|
||||
Insert(VimInsertPosition),
|
||||
EnterReplaceMode,
|
||||
RestoreReplacedCharacter,
|
||||
Delete(VimEditTarget),
|
||||
Change(VimEditTarget),
|
||||
Replace(char),
|
||||
@@ -96,9 +98,101 @@ pub(crate) struct VimCommandState {
|
||||
pub(crate) last_change: Vec<VimEdit>,
|
||||
changed: bool,
|
||||
pub(super) replaying: bool,
|
||||
replace_steps: Vec<VimReplaceStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct VimReplaceStep {
|
||||
// Backspace also retraces attachments skipped before the replacement.
|
||||
cursor_before: usize,
|
||||
start: usize,
|
||||
inserted_len: usize,
|
||||
original: String,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub(crate) fn is_vim_replace_mode(&self) -> bool {
|
||||
self.vim_enabled && self.vim_mode == VimMode::Replace
|
||||
}
|
||||
|
||||
pub(super) fn clear_vim_replace_recovery(&mut self) {
|
||||
self.vim_commands.replace_steps.clear();
|
||||
}
|
||||
|
||||
pub(super) fn replace_vim_text(&mut self, text: &str) {
|
||||
for grapheme in text.graphemes(/*is_extended*/ true) {
|
||||
let cursor_before = self.cursor_pos;
|
||||
while let Some(element) = self
|
||||
.elements
|
||||
.iter()
|
||||
.find(|element| element.range.start == self.cursor_pos)
|
||||
{
|
||||
self.set_cursor(element.range.end);
|
||||
}
|
||||
let start = self.cursor_pos;
|
||||
let end = if grapheme == "\n" || start >= self.end_of_current_line() {
|
||||
start
|
||||
} else {
|
||||
self.next_atomic_boundary(start)
|
||||
};
|
||||
let original = self.text[start..end].to_string();
|
||||
self.replace_range_preserving_recovery(start..end, grapheme);
|
||||
self.vim_commands.replace_steps.push(VimReplaceStep {
|
||||
cursor_before,
|
||||
start,
|
||||
inserted_len: grapheme.len(),
|
||||
original,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn restore_vim_replaced_character(&mut self) -> bool {
|
||||
let steps = &mut self.vim_commands.replace_steps;
|
||||
let Some(step) = steps
|
||||
.pop()
|
||||
.filter(|step| self.cursor_pos == step.start + step.inserted_len)
|
||||
else {
|
||||
steps.clear();
|
||||
return false;
|
||||
};
|
||||
// Replace skips existing elements, so an overlapping marker was added after typing.
|
||||
// Unmark it before restoring one character; never expand recovery to the whole token.
|
||||
self.elements.retain(|element| {
|
||||
element.range.end <= step.start || element.range.start >= step.start + step.inserted_len
|
||||
});
|
||||
self.replace_range_preserving_recovery(
|
||||
step.start..step.start + step.inserted_len,
|
||||
&step.original,
|
||||
);
|
||||
self.set_cursor(step.cursor_before);
|
||||
true
|
||||
}
|
||||
|
||||
/// Retract a detected paste prefix without losing overwritten text or crossing attachments.
|
||||
pub(crate) fn retract_paste_burst(&mut self, start: usize) -> bool {
|
||||
if self.is_vim_replace_mode() {
|
||||
let restored_start = self
|
||||
.vim_commands
|
||||
.replace_steps
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|step| step.start >= start)
|
||||
.try_fold(self.cursor_pos, |cursor, step| {
|
||||
(step.start + step.inserted_len == cursor && step.cursor_before == step.start)
|
||||
.then_some(step.start)
|
||||
});
|
||||
if restored_start != Some(start) {
|
||||
return false;
|
||||
}
|
||||
while self.cursor_pos > start {
|
||||
self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter);
|
||||
}
|
||||
} else {
|
||||
self.replace_range(start..self.cursor_pos, "");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn swap_vim_persistent_state(&mut self, state: &mut VimPersistentState) {
|
||||
std::mem::swap(&mut self.vim_commands, &mut state.commands);
|
||||
std::mem::swap(&mut self.vim_search.last, &mut state.search);
|
||||
@@ -110,14 +204,16 @@ impl TextArea {
|
||||
|
||||
pub(super) fn record_vim_inserted_text(&mut self, text: &str) {
|
||||
if !self.vim_enabled
|
||||
|| self.vim_mode != VimMode::Insert
|
||||
|| !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
|| self.vim_commands.replaying
|
||||
|| self.vim_commands.pending_change.is_empty()
|
||||
|| text.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(VimEdit::Text(pending)) = self.vim_commands.pending_change.last_mut() {
|
||||
if self.vim_mode == VimMode::Insert
|
||||
&& let Some(VimEdit::Text(pending)) = self.vim_commands.pending_change.last_mut()
|
||||
{
|
||||
pending.push_str(text);
|
||||
} else {
|
||||
self.vim_commands
|
||||
@@ -127,14 +223,17 @@ impl TextArea {
|
||||
self.vim_commands.changed = true;
|
||||
}
|
||||
|
||||
pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) {
|
||||
pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) -> bool {
|
||||
let recording = self.vim_enabled
|
||||
&& self.vim_mode == VimMode::Insert
|
||||
&& matches!(self.vim_mode, VimMode::Insert | VimMode::Replace)
|
||||
&& !self.vim_commands.replaying
|
||||
&& !self.vim_commands.pending_change.is_empty();
|
||||
let prior_len = self.text.len();
|
||||
self.apply_vim_editor_action(action.clone());
|
||||
let changed = self.text.len() != prior_len;
|
||||
if !self.apply_vim_editor_action(action.clone()) {
|
||||
return false;
|
||||
}
|
||||
let changed =
|
||||
self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter);
|
||||
let deletion = matches!(
|
||||
action,
|
||||
VimAction::DeleteBackward
|
||||
@@ -151,6 +250,7 @@ impl TextArea {
|
||||
.push(VimEdit::Editor(VimEditorEdit(action)));
|
||||
}
|
||||
self.vim_commands.changed |= recording && changed;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn start_vim_edit(&mut self, action: VimAction) -> bool {
|
||||
@@ -185,7 +285,7 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(crate) fn finish_vim_repeat(&mut self) {
|
||||
if self.vim_mode == VimMode::Insert {
|
||||
if matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) {
|
||||
self.leave_vim_insert_mode();
|
||||
}
|
||||
self.vim_pending = VimPending::None;
|
||||
@@ -196,7 +296,7 @@ impl TextArea {
|
||||
match edit {
|
||||
VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(action.clone()),
|
||||
VimEdit::Text(text) => {
|
||||
if self.vim_mode != VimMode::Insert {
|
||||
if !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) {
|
||||
return false;
|
||||
}
|
||||
self.insert_str(text);
|
||||
@@ -206,9 +306,19 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn apply_vim_editor_action(&mut self, action: VimAction) -> bool {
|
||||
// Editor actions invalidate contiguous Replace offsets, including during replay.
|
||||
if !matches!(action, VimAction::RestoreReplacedCharacter) {
|
||||
self.clear_vim_replace_recovery();
|
||||
}
|
||||
let prior_len = self.text.len();
|
||||
let is_change = matches!(action, VimAction::Change(_));
|
||||
match action {
|
||||
VimAction::EnterReplaceMode => {
|
||||
self.vim_mode = VimMode::Replace;
|
||||
}
|
||||
VimAction::RestoreReplacedCharacter => {
|
||||
return self.restore_vim_replaced_character();
|
||||
}
|
||||
VimAction::Insert(position) => {
|
||||
match position {
|
||||
VimInsertPosition::Cursor => {}
|
||||
@@ -331,6 +441,11 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(super) fn handle_vim_extra_command(&mut self, event: KeyEvent) -> bool {
|
||||
if self.vim_normal_keymap.enter_replace_mode.is_pressed(event) {
|
||||
self.start_vim_edit(VimAction::EnterReplaceMode);
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.vim_normal_keymap.replace_char.is_pressed(event)
|
||||
&& self.cursor_pos < self.end_of_current_line()
|
||||
{
|
||||
|
||||
@@ -76,6 +76,157 @@ fn replace_character_preserves_normal_mode_and_grapheme_boundaries() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_overwrites_graphemes_and_appends_at_line_end() {
|
||||
let mut textarea = vim_textarea("a👩💻c\nnext", /*cursor*/ 1);
|
||||
keys(&mut textarea, "RXY!");
|
||||
assert_eq!(textarea.text(), "aXY!\nnext");
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Replace"));
|
||||
assert_eq!(
|
||||
textarea.keymap_context(),
|
||||
crate::keymap::KeymapContext::Editor
|
||||
);
|
||||
|
||||
escape(&mut textarea);
|
||||
assert_eq!(
|
||||
(textarea.vim_mode_label(), textarea.cursor()),
|
||||
(Some("Normal"), 3)
|
||||
);
|
||||
|
||||
let mut textarea = vim_textarea("abc", /*cursor*/ 0);
|
||||
keys(&mut textarea, "R\nX");
|
||||
assert_eq!(textarea.text(), "\nXbc");
|
||||
|
||||
let mut textarea = vim_textarea("abc def", /*cursor*/ 0);
|
||||
keys(&mut textarea, "R/?nN");
|
||||
assert_eq!(textarea.text(), "/?nNdef");
|
||||
escape(&mut textarea);
|
||||
keys(&mut textarea, "/def\n");
|
||||
assert_eq!(textarea.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_uses_configured_binding_and_respects_unbinding() {
|
||||
let mut textarea = vim_textarea("abc", /*cursor*/ 0);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.vim_normal.enter_replace_mode = vec![crate::key_hint::plain(KeyCode::F(2))];
|
||||
textarea.set_keymap_bindings(&keymap);
|
||||
|
||||
keys(&mut textarea, "R");
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Normal"));
|
||||
|
||||
textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
keys(&mut textarea, "X");
|
||||
assert_eq!(textarea.text(), "Xbc");
|
||||
|
||||
escape(&mut textarea);
|
||||
keymap.vim_normal.enter_replace_mode.clear();
|
||||
textarea.set_keymap_bindings(&keymap);
|
||||
textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_backspace_restores_original_text() {
|
||||
let mut textarea = vim_textarea("a👩💻", /*cursor*/ 0);
|
||||
keys(&mut textarea, "RXYZ");
|
||||
assert_eq!(textarea.text(), "XYZ");
|
||||
|
||||
let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
||||
for expected in ["XY", "X👩💻", "a👩💻", "a👩💻"] {
|
||||
textarea.input(backspace);
|
||||
assert_eq!(textarea.text(), expected);
|
||||
}
|
||||
assert_eq!(textarea.cursor(), 0);
|
||||
|
||||
let mut textarea = vim_textarea("abcd", /*cursor*/ 0);
|
||||
keys(&mut textarea, "RXY");
|
||||
textarea.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
textarea.input(backspace);
|
||||
assert_eq!(textarea.text(), "Ycd");
|
||||
|
||||
let mut textarea = vim_textarea("abé", /*cursor*/ 0);
|
||||
keys(&mut textarea, "RXY");
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||
textarea.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||
textarea.input(backspace);
|
||||
assert_eq!(textarea.text(), "");
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Replace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_preserves_atomic_attachments() {
|
||||
for (cursor, command, edited) in [
|
||||
(0, "RXY", "X<image><paste>Y"),
|
||||
(1, "RX", "a<image><paste>X"),
|
||||
] {
|
||||
let mut textarea = vim_textarea("ab", /*cursor*/ 1);
|
||||
textarea.insert_element("<image>");
|
||||
textarea.insert_element("<paste>");
|
||||
textarea.set_cursor(cursor);
|
||||
let elements = textarea.text_element_snapshots();
|
||||
keys(&mut textarea, command);
|
||||
assert_eq!(textarea.text(), edited);
|
||||
assert_eq!(textarea.text_element_snapshots(), elements);
|
||||
assert!(!textarea.retract_paste_burst(/*start*/ 0));
|
||||
for _ in command.chars().skip(/*n*/ 1) {
|
||||
textarea.input(KeyEvent::from(KeyCode::Backspace));
|
||||
}
|
||||
assert_eq!(
|
||||
(
|
||||
textarea.text(),
|
||||
textarea.cursor(),
|
||||
textarea.text_element_snapshots(),
|
||||
),
|
||||
("a<image><paste>b", cursor, elements)
|
||||
);
|
||||
textarea.input(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(
|
||||
textarea.text(),
|
||||
if cursor == 0 {
|
||||
"a<image><paste>b"
|
||||
} else {
|
||||
"<image><paste>b"
|
||||
}
|
||||
);
|
||||
assert_eq!(textarea.element_payloads(), vec!["<image>", "<paste>"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_replays_with_dot() {
|
||||
let mut textarea = vim_textarea("abc def", /*cursor*/ 0);
|
||||
keys(&mut textarea, "Rx\u{301}");
|
||||
escape(&mut textarea);
|
||||
assert_eq!(textarea.text(), "x\u{301}c def");
|
||||
|
||||
keys(&mut textarea, "w.");
|
||||
assert_eq!(textarea.text(), "x\u{301}c x\u{301}f");
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Normal"));
|
||||
|
||||
let mut textarea = vim_textarea("abc def", /*cursor*/ 0);
|
||||
keys(&mut textarea, "RXY");
|
||||
textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
escape(&mut textarea);
|
||||
assert_eq!(textarea.text(), "Xbc def");
|
||||
|
||||
keys(&mut textarea, "w.");
|
||||
assert_eq!(textarea.text(), "Xbc Xef");
|
||||
assert_eq!(textarea.vim_mode_label(), Some("Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mode_overwrites_explicit_paste() {
|
||||
let mut textarea = vim_textarea("abcdef", /*cursor*/ 1);
|
||||
keys(&mut textarea, "R");
|
||||
textarea.insert_str("XYZ");
|
||||
assert_eq!(textarea.text(), "aXYZef");
|
||||
escape(&mut textarea);
|
||||
|
||||
keys(&mut textarea, "0.");
|
||||
assert_eq!(textarea.text(), "XYZZef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_replays_delete_replace_and_complete_change() {
|
||||
let mut textarea = vim_textarea("alpha beta gamma", /*cursor*/ 0);
|
||||
|
||||
+38
-10
@@ -184,6 +184,7 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) insert_line_start: Vec<KeyBinding>,
|
||||
pub(crate) open_line_below: Vec<KeyBinding>,
|
||||
pub(crate) open_line_above: Vec<KeyBinding>,
|
||||
pub(crate) enter_replace_mode: Vec<KeyBinding>,
|
||||
pub(crate) move_left: Vec<KeyBinding>,
|
||||
pub(crate) move_right: Vec<KeyBinding>,
|
||||
pub(crate) move_up: Vec<KeyBinding>,
|
||||
@@ -723,6 +724,7 @@ impl RuntimeKeymap {
|
||||
insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start),
|
||||
open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below),
|
||||
open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above),
|
||||
enter_replace_mode: resolve_local!(keymap, defaults, vim_normal, enter_replace_mode),
|
||||
move_left: resolve_local!(keymap, defaults, vim_normal, move_left),
|
||||
move_right: resolve_local!(keymap, defaults, vim_normal, move_right),
|
||||
move_up: resolve_local!(keymap, defaults, vim_normal, move_up),
|
||||
@@ -789,6 +791,10 @@ impl RuntimeKeymap {
|
||||
keymap.vim_normal.open_line_above.as_ref(),
|
||||
vim_normal.open_line_above.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.enter_replace_mode.as_ref(),
|
||||
vim_normal.enter_replace_mode.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_left.as_ref(),
|
||||
vim_normal.move_left.as_slice(),
|
||||
@@ -1385,13 +1391,19 @@ impl RuntimeKeymap {
|
||||
keymap.vim_normal.redo.as_ref(),
|
||||
&mut resolved.vim_normal.redo,
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.enter_replace_mode.as_ref(),
|
||||
&mut resolved.vim_normal.enter_replace_mode,
|
||||
),
|
||||
] {
|
||||
if setting.is_none() {
|
||||
bindings.retain(|binding| {
|
||||
!configured.contains(binding)
|
||||
let (code, modifiers) = binding.parts();
|
||||
let event = KeyEvent::new(code, modifiers);
|
||||
!configured.is_pressed(event)
|
||||
&& !resolved.chords.bindings.iter().any(|chord| {
|
||||
chord.action.context.overlaps(KeymapContext::VimNormal)
|
||||
&& chord.chord.prefix.parts() == binding.parts()
|
||||
&& chord.chord.prefix.is_press(event)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1534,6 +1546,10 @@ impl RuntimeKeymap {
|
||||
shift(KeyCode::Char('o')),
|
||||
plain(KeyCode::Char('O'))
|
||||
],
|
||||
enter_replace_mode: default_bindings![
|
||||
shift(KeyCode::Char('r')),
|
||||
plain(KeyCode::Char('R'))
|
||||
],
|
||||
move_left: default_bindings![plain(KeyCode::Char('h')), plain(KeyCode::Left)],
|
||||
move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)],
|
||||
move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)],
|
||||
@@ -2842,6 +2858,15 @@ mod tests {
|
||||
expect_conflict(&keymap, "move_left", "start_change_operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_replace_mode_binding_conflicts_with_legacy_binding() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("shift-r"));
|
||||
keymap.vim_normal.enter_replace_mode = Some(one("shift-r"));
|
||||
|
||||
expect_conflict(&keymap, "move_left", "enter_replace_mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
@@ -2996,8 +3021,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_bindings_prune_new_history_defaults() {
|
||||
for key in ["u", "ctrl-r"] {
|
||||
fn configured_legacy_bindings_prune_new_vim_defaults() {
|
||||
for (key, new_action) in [
|
||||
("u", "undo"),
|
||||
("ctrl-r", "redo"),
|
||||
("shift-r", "enter_replace_mode"),
|
||||
] {
|
||||
for (context, action, suffix) in [
|
||||
("vim_normal", "move_left", ""),
|
||||
("vim_normal", "move_left", " g"),
|
||||
@@ -3019,12 +3048,11 @@ mod tests {
|
||||
keymap.composer.history_search_previous = Some(KeybindingsSpec::Many(vec![]));
|
||||
}
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
let bindings = if key == "u" {
|
||||
runtime.vim_normal.undo
|
||||
} else {
|
||||
runtime.vim_normal.redo
|
||||
};
|
||||
assert_eq!(bindings, Vec::new());
|
||||
assert_eq!(
|
||||
bindings_for_action(&runtime, "vim_normal", new_action),
|
||||
Some([].as_slice()),
|
||||
"{context}.{action} = {key}{suffix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ define_runtime_action_bindings! {
|
||||
insert_line_start,
|
||||
open_line_below,
|
||||
open_line_above,
|
||||
enter_replace_mode,
|
||||
move_left,
|
||||
move_right,
|
||||
move_up,
|
||||
|
||||
@@ -127,6 +127,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_normal", "Vim normal", "insert_line_start", "Enter insert mode at the first non-blank character."),
|
||||
action("vim_normal", "Vim normal", "open_line_below", "Open a new line below and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "open_line_above", "Open a new line above and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "enter_replace_mode", "Enter replace mode and overwrite characters."),
|
||||
action("vim_normal", "Vim normal", "move_left", "Move left in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_right", "Move right in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_up", "Move up or recall older history in Vim normal mode."),
|
||||
@@ -299,6 +300,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_normal", "insert_line_start") => Some(&mut keymap.vim_normal.insert_line_start),
|
||||
("vim_normal", "open_line_below") => Some(&mut keymap.vim_normal.open_line_below),
|
||||
("vim_normal", "open_line_above") => Some(&mut keymap.vim_normal.open_line_above),
|
||||
("vim_normal", "enter_replace_mode") => Some(&mut keymap.vim_normal.enter_replace_mode),
|
||||
("vim_normal", "move_left") => Some(&mut keymap.vim_normal.move_left),
|
||||
("vim_normal", "move_right") => Some(&mut keymap.vim_normal.move_right),
|
||||
("vim_normal", "move_up") => Some(&mut keymap.vim_normal.move_up),
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
137 actions, 1 customized, 5 unbound.
|
||||
138 actions, 1 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
138 actions, 0 customized, 6 unbound.
|
||||
139 actions, 0 customized, 6 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (137 selectable)
|
||||
tab: All (138 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
tab: App (14 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (68 selectable)
|
||||
tab: Vim (69 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Agents (5 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
137 actions, 1 customized, 5 unbound.
|
||||
138 actions, 1 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
137 actions, 0 customized, 5 unbound.
|
||||
138 actions, 0 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (5) App Composer Editor Vim
|
||||
Navigation Agents Approval Debug
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
137 actions, 0 customized, 5 unbound.
|
||||
138 actions, 0 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
Reference in New Issue
Block a user