diff --git a/.typos.toml b/.typos.toml index 805fe638..bc61d59f 100644 --- a/.typos.toml +++ b/.typos.toml @@ -21,3 +21,5 @@ l3ine = "l3ine" 4should = "4should" wr5ap = "wr5ap" ine = "ine" +# For testing multibyte cursor clamping +caf = "caf" diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba..e8bf5840 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -200,7 +200,9 @@ impl Editor { EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), + EditCommand::ClampCursorToNormalMode => self.clamp_cursor(), } + if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.clear_selection(); } @@ -251,6 +253,17 @@ impl Editor { pub fn set_edit_mode(&mut self, mode: PromptEditMode) { self.edit_mode = mode; } + + pub(crate) fn clamp_cursor(&mut self) { + if matches!(self.edit_mode, PromptEditMode::Vi(PromptViMode::Normal)) { + let len = self.get_buffer().len(); + if !self.get_buffer().is_empty() && self.line_buffer.insertion_point() >= len { + let last = self.line_buffer.grapheme_left_index_from_pos(len); + self.line_buffer.set_insertion_point(last); + } + } + } + fn move_to_position(&mut self, position: usize, select: bool) { self.update_selection_anchor(select); self.line_buffer.set_insertion_point(position) @@ -287,6 +300,7 @@ impl Editor { pub(crate) fn set_buffer(&mut self, buffer: String, undo_behavior: UndoBehavior) { self.line_buffer.set_buffer(buffer); self.update_undo_state(undo_behavior); + self.clamp_cursor(); } pub(crate) fn insertion_point(&self) -> usize { @@ -306,7 +320,13 @@ impl Editor { } pub(crate) fn is_cursor_at_buffer_end(&self) -> bool { - self.line_buffer.insertion_point() == self.get_buffer().len() + let pos = self.line_buffer.insertion_point(); + let len = self.get_buffer().len(); + if pos == len { + return true; + } + matches!(self.edit_mode, PromptEditMode::Vi(PromptViMode::Normal)) + && self.line_buffer.grapheme_right_index() == len } pub(crate) fn reset_undo_stack(&mut self) { @@ -321,6 +341,7 @@ impl Editor { pub(crate) fn move_to_end(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_to_end(); + self.clamp_cursor(); } pub(crate) fn move_to_line_start(&mut self, select: bool) { @@ -336,6 +357,7 @@ impl Editor { pub(crate) fn move_to_line_end(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_to_line_end(); + self.clamp_cursor(); } fn undo(&mut self) { @@ -635,6 +657,7 @@ impl Editor { fn move_right(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_right(); + self.clamp_cursor(); } fn select_all(&mut self) { @@ -745,22 +768,27 @@ impl Editor { fn move_word_right(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_index(), select); + self.clamp_cursor(); } fn move_word_right_start(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_start_index(), select); + self.clamp_cursor(); } fn move_big_word_right_start(&mut self, select: bool) { self.move_to_position(self.line_buffer.big_word_right_start_index(), select); + self.clamp_cursor(); } fn move_word_right_end(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_end_index(), select); + self.clamp_cursor(); } fn move_big_word_right_end(&mut self, select: bool) { self.move_to_position(self.line_buffer.big_word_right_end_index(), select); + self.clamp_cursor(); } fn insert_char(&mut self, c: char) { @@ -2168,4 +2196,107 @@ mod test { assert_eq!(bracket_result, expected_bracket); assert_eq!(quote_result, expected_quote); } + + #[test] + fn test_clamp_cursor_vi_normal_mode() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(5); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_clamp_cursor_does_not_affect_insert_mode() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + editor.line_buffer.set_insertion_point(5); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 5); + } + + #[test] + fn test_clamp_cursor_empty_buffer() { + let mut editor = editor_with(""); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 0); + } + + #[test] + fn test_clamp_cursor_already_in_bounds() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(2); + editor.clamp_cursor(); + assert_eq!(editor.insertion_point(), 2); + } + + #[test] + fn test_is_cursor_at_buffer_end_vi_normal() { + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + // Cursor on last char 's' (position 1) — at end in normal mode + editor.line_buffer.set_insertion_point(1); + assert!(editor.is_cursor_at_buffer_end()); + + // Cursor on first char 'l' (position 0) — not at end + editor.line_buffer.set_insertion_point(0); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char (position 2) — still at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } + + #[test] + fn test_is_cursor_at_buffer_end_insert_mode() { + let mut editor = editor_with("ls"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + + // Cursor on last char 's' — NOT at end in insert mode + editor.line_buffer.set_insertion_point(1); + assert!(!editor.is_cursor_at_buffer_end()); + + // Cursor after last char — at end + editor.line_buffer.set_insertion_point(2); + assert!(editor.is_cursor_at_buffer_end()); + } + + #[test] + fn test_move_to_end_vi_normal_clamps() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(0); + editor.move_to_end(false); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_move_to_line_end_vi_normal_clamps() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point(0); + editor.move_to_line_end(false); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_set_buffer_vi_normal_clamps() { + let mut editor = editor_with(""); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.set_buffer("hello".to_string(), UndoBehavior::CreateUndoPoint); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_clamp_cursor_multibyte() { + let mut editor = editor_with("café"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.line_buffer.set_insertion_point("café".len()); + editor.clamp_cursor(); + // 'é' is 2 bytes, so last grapheme starts at byte index 3 + assert_eq!(editor.insertion_point(), "caf".len()); + } } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 18999e5c..7bdddc5b 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -230,11 +230,23 @@ impl Command { })], Self::NewlineAbove => vec![ReedlineOption::Edit(EditCommand::InsertNewlineAbove)], Self::NewlineBelow => vec![ReedlineOption::Edit(EditCommand::InsertNewlineBelow)], - Self::PasteAfter => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferAfter)], - Self::PasteBefore => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferBefore)], - Self::Undo => vec![ReedlineOption::Edit(EditCommand::Undo)], + Self::PasteAfter => vec![ + ReedlineOption::Edit(EditCommand::PasteCutBufferAfter), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::PasteBefore => vec![ + ReedlineOption::Edit(EditCommand::PasteCutBufferBefore), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::Undo => vec![ + ReedlineOption::Edit(EditCommand::Undo), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], Self::ChangeToLineEnd => vec![ReedlineOption::Edit(EditCommand::ClearToLineEnd)], - Self::DeleteToEnd => vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)], + Self::DeleteToEnd => vec![ + ReedlineOption::Edit(EditCommand::CutToLineEnd), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], Self::AppendToEnd => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd { select: false, })], @@ -244,13 +256,22 @@ impl Command { Self::RewriteCurrentLine => vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)], Self::DeleteChar => { if vi_state.mode == ViMode::Visual { - vec![ReedlineOption::Edit(EditCommand::CutSelection)] + vec![ + ReedlineOption::Edit(EditCommand::CutSelection), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } else { - vec![ReedlineOption::Edit(EditCommand::CutChar)] + vec![ + ReedlineOption::Edit(EditCommand::CutChar), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } } Self::ReplaceChar(c) => { - vec![ReedlineOption::Edit(EditCommand::ReplaceChar(*c))] + vec![ + ReedlineOption::Edit(EditCommand::ReplaceChar(*c)), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::SubstituteCharWithInsert => { if vi_state.mode == ViMode::Visual { @@ -260,9 +281,16 @@ impl Command { } } Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], - Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], + Self::Switchcase => vec![ + ReedlineOption::Edit(EditCommand::SwitchcaseChar), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], // Whenever a motion is required to finish the command we must be in visual mode - Self::Delete | Self::Change => vec![ReedlineOption::Edit(EditCommand::CutSelection)], + Self::Delete => vec![ + ReedlineOption::Edit(EditCommand::CutSelection), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ], + Self::Change => vec![ReedlineOption::Edit(EditCommand::CutSelection)], Self::Yank => vec![ReedlineOption::Edit(EditCommand::CopySelection)], Self::Incomplete => vec![ReedlineOption::Incomplete], Self::RepeatLastAction => match &vi_state.previous { @@ -276,28 +304,40 @@ impl Command { })] } Self::DeleteInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInsidePair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutInsidePair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::YankInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CopyInsidePair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyInsidePair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::DeleteAroundPair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutAroundPair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutAroundPair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::YankAroundPair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CopyAroundPair { - left: *left, - right: *right, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyAroundPair { + left: *left, + right: *right, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::ChangeTextObject { text_object } => { vec![ReedlineOption::Edit(EditCommand::CutTextObject { @@ -305,17 +345,26 @@ impl Command { })] } Self::YankTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CopyTextObject { - text_object: *text_object, - })] + vec![ + ReedlineOption::Edit(EditCommand::CopyTextObject { + text_object: *text_object, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::DeleteTextObject { text_object } => { - vec![ReedlineOption::Edit(EditCommand::CutTextObject { - text_object: *text_object, - })] + vec![ + ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + }), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } Self::SwapCursorAndAnchor => { - vec![ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor)] + vec![ + ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor), + ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode), + ] } } } @@ -326,66 +375,79 @@ impl Command { vi_state: &mut Vi, ) -> Option> { match self { - Self::Delete => match motion { - Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), - Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), - Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) - } - Motion::NextBigWord => Some(vec![ReedlineOption::Edit( - EditCommand::CutBigWordRightToNext, - )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) - } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]), - Motion::PreviousBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) - } - Motion::RightUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) - } - Motion::RightBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) - } - Motion::LeftUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) - } - Motion::LeftBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) - } - Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]), - Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( - EditCommand::CutFromLineNonBlankStart, - )]), - Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), - Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), - Motion::Up => None, - Motion::Down => None, - Motion::FirstLine => Some(vec![ReedlineOption::Edit( - EditCommand::CutFromStartLinewise { - leave_blank_line: false, - }, - )]), - Motion::LastLine => { - Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { - leave_blank_line: false, - })]) - } - Motion::ReplayCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), - Motion::ReverseCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.reverse().to_cut())]), - }, + Self::Delete => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) + } + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]) + } + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + } + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + } + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + } + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]) + } + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromLineNonBlankStart, + )]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromStartLinewise { + leave_blank_line: false, + }, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { + leave_blank_line: false, + })]) + } + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), + Motion::ReverseCharSearch => { + vi_state.last_char_search.as_ref().map(|char_search| { + vec![ReedlineOption::Edit(char_search.reverse().to_cut())] + }) + } + }; + op.map(|mut vec| { + vec.push(ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode)); + vec + }) + } Self::Change => { let op = match motion { Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), @@ -461,62 +523,75 @@ impl Command { vec }) } - Self::Yank => match motion { - Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CopyToLineEnd)]), - Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CopyCurrentLine)]), - Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightToNext)]) - } - Motion::NextBigWord => Some(vec![ReedlineOption::Edit( - EditCommand::CopyBigWordRightToNext, - )]), - Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRight)]), - Motion::NextBigWordEnd => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRight)]) - } - Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CopyWordLeft)]), - Motion::PreviousBigWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordLeft)]) - } - Motion::RightUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyRightUntil(*c))]) - } - Motion::RightBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyRightBefore(*c))]) - } - Motion::LeftUntil(c) => { - vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftUntil(*c))]) - } - Motion::LeftBefore(c) => { - vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); - Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftBefore(*c))]) - } - Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CopyFromLineStart)]), - Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( - EditCommand::CopyFromLineNonBlankStart, - )]), - Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), - Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), - Motion::Up => None, - Motion::Down => None, - Motion::FirstLine => Some(vec![ReedlineOption::Edit( - EditCommand::CopyFromStartLinewise, - )]), - Motion::LastLine => { - Some(vec![ReedlineOption::Edit(EditCommand::CopyToEndLinewise)]) - } - Motion::ReplayCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.to_copy())]), - Motion::ReverseCharSearch => vi_state - .last_char_search - .as_ref() - .map(|char_search| vec![ReedlineOption::Edit(char_search.reverse().to_copy())]), - }, + Self::Yank => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CopyToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CopyCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRightToNext)]) + } + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CopyBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordRight)]) + } + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordRight)]) + } + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyWordLeft)]) + } + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyBigWordLeft)]) + } + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyRightUntil(*c))]) + } + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyRightBefore(*c))]) + } + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftUntil(*c))]) + } + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftBefore(*c))]) + } + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyFromLineStart)]) + } + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromLineNonBlankStart, + )]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), + Motion::Up => None, + Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromStartLinewise, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyToEndLinewise)]) + } + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_copy())]), + Motion::ReverseCharSearch => { + vi_state.last_char_search.as_ref().map(|char_search| { + vec![ReedlineOption::Edit(char_search.reverse().to_copy())] + }) + } + }; + op.map(|mut vec| { + vec.push(ReedlineOption::Edit(EditCommand::ClampCursorToNormalMode)); + vec + }) + } _ => None, } } diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 721e45d9..8e742fb2 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -156,8 +156,20 @@ impl EditMode for Vi { } (_, KeyModifiers::NONE, KeyCode::Esc) => { self.cache.clear(); + let was_insert = self.mode == ViMode::Insert; self.mode = ViMode::Normal; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + // In Vi, exiting insert mode moves the cursor one position + // left because insert mode places the cursor between + // characters while normal mode places it on a character. + if was_insert { + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) + } else { + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + } } (ViMode::Normal | ViMode::Visual, _, _) => self .normal_keybindings @@ -230,6 +242,28 @@ mod test { use super::*; use pretty_assertions::assert_eq; + #[test] + fn normal_mode_delete_char_emits_clamp() { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let xkey = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(); + let event = vi.parse_event(xkey); + match &event { + ReedlineEvent::Multiple(events) => { + assert!(events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode + ]))); + } + _ => panic!("Expected Multiple event, got {:?}", event), + } + } + #[test] fn esc_leads_to_normal_mode_test() { let mut vi = Vi::default(); @@ -238,6 +272,28 @@ mod test { .unwrap(); let result = vi.parse_event(esc); + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint + ]) + ); + assert!(matches!(vi.mode, ViMode::Normal)); + } + + #[test] + fn esc_from_normal_mode_does_not_move_cursor() { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let esc = + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + let result = vi.parse_event(esc); + assert_eq!( result, ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) @@ -342,4 +398,153 @@ mod test { assert_eq!(result, ReedlineEvent::None); } + + /// Helper: parse a sequence of chars in Vi normal mode and return the event. + fn parse_normal_keys(keys: &[char]) -> ReedlineEvent { + let mut vi = Vi { + mode: ViMode::Normal, + ..Default::default() + }; + let mut event = ReedlineEvent::None; + for &c in keys { + event = vi.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(c), + if c.is_ascii_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }, + ))) + .unwrap(), + ); + } + event + } + + /// Assert that parsing the given keys in normal mode produces an event + /// containing ClampCursorToNormalMode. + fn assert_emits_clamp(keys: &[char]) { + let event = parse_normal_keys(keys); + match &event { + ReedlineEvent::Multiple(events) => { + assert!( + events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode + ])), + "Expected ClampCursorToNormalMode in {:?} for keys {:?}", + events, + keys + ); + } + _ => panic!( + "Expected Multiple event for keys {:?}, got {:?}", + keys, event + ), + } + } + + /// Assert that parsing the given keys in normal mode produces an event + /// that does NOT contain ClampCursorToNormalMode. + fn assert_does_not_emit_clamp(keys: &[char]) { + let event = parse_normal_keys(keys); + let contains_clamp = match &event { + ReedlineEvent::Multiple(events) => events.contains(&ReedlineEvent::Edit(vec![ + EditCommand::ClampCursorToNormalMode, + ])), + ReedlineEvent::Edit(cmds) => cmds.contains(&EditCommand::ClampCursorToNormalMode), + _ => false, + }; + assert!( + !contains_clamp, + "Expected NO ClampCursorToNormalMode for keys {:?}, got {:?}", + keys, event + ); + } + + // --- Commands that SHOULD emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_paste_after_emits_clamp() { + assert_emits_clamp(&['p']); + } + + #[test] + fn normal_mode_paste_before_emits_clamp() { + assert_emits_clamp(&['P']); + } + + #[test] + fn normal_mode_undo_emits_clamp() { + assert_emits_clamp(&['u']); + } + + #[test] + fn normal_mode_delete_line_emits_clamp() { + assert_emits_clamp(&['d', 'd']); + } + + #[test] + fn normal_mode_delete_to_end_emits_clamp() { + assert_emits_clamp(&['D']); + } + + #[test] + fn normal_mode_switchcase_emits_clamp() { + assert_emits_clamp(&['~']); + } + + #[test] + fn normal_mode_replace_char_emits_clamp() { + assert_emits_clamp(&['r', 'a']); + } + + // --- Commands with motion that SHOULD emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_delete_word_emits_clamp() { + assert_emits_clamp(&['d', 'w']); + } + + #[test] + fn normal_mode_delete_back_word_emits_clamp() { + assert_emits_clamp(&['d', 'b']); + } + + #[test] + fn normal_mode_delete_to_line_end_emits_clamp() { + assert_emits_clamp(&['d', '$']); + } + + #[test] + fn normal_mode_yank_word_emits_clamp() { + assert_emits_clamp(&['y', 'w']); + } + + // --- Commands that should NOT emit ClampCursorToNormalMode --- + + #[test] + fn normal_mode_insert_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['i']); + } + + #[test] + fn normal_mode_append_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['a']); + } + + #[test] + fn normal_mode_change_line_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['c', 'c']); + } + + #[test] + fn normal_mode_change_word_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['c', 'w']); + } + + #[test] + fn normal_mode_substitute_does_not_emit_clamp() { + assert_does_not_emit_clamp(&['s']); + } } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index da0839cd..2c837e65 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -512,23 +512,29 @@ mod tests { #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart{select:false}])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd{select:false}])]))] #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] - #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), - ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] - #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::Undo]), - ReedlineEvent::Edit(vec![EditCommand::Undo]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] #[case(&['d', 'd'], ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] - #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext])]))] - #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext])]))] - #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight])]))] - #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])]))] - #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] + #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['c', 'c'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]), ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] #[case(&['c', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Repaint]))] @@ -536,21 +542,26 @@ mod tests { #[case(&['c', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), ReedlineEvent::Repaint]))] #[case(&['c', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]), ReedlineEvent::Repaint]))] #[case(&['c', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), ReedlineEvent::Repaint]))] - #[case(&['d', 'h'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Backspace])]))] - #[case(&['d', 'l'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Delete])]))] - #[case(&['2', 'd', 'd'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] + #[case(&['d', 'h'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Backspace]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'l'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Delete]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['2', 'd', 'd'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] // #[case(&['d', 'j'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] // #[case(&['d', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::Up, ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] - #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight])]))] - #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart])]))] - #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart])]))] - #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd])]))] - #[case(&['d', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['d', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')])]))] - #[case(&['d', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] - #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')])]))] - #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }])]))] - #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }])]))] + #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Repaint]))] #[case(&['c', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Repaint]))] #[case(&['c', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Repaint]))] @@ -567,9 +578,9 @@ mod tests { ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: true }]), ReedlineEvent::Repaint, ]))] - #[case(&['y', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromLineNonBlankStart])]))] - #[case(&['y', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromStartLinewise])]))] - #[case(&['y', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyToEndLinewise])]))] + #[case(&['y', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromLineNonBlankStart]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['y', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromStartLinewise]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['y', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyToEndLinewise]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi::default(); let res = vi_parse(input); @@ -583,10 +594,10 @@ mod tests { #[case(&['f', 'a'], &[','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil{c: 'a', select: false}])]))] #[case(&['F', 'a'], &[','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveRightUntil{c: 'a', select: false}])]))] #[case(&['F', 'a'], &[';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil{c: 'a', select: false}])]))] - #[case(&['f', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['f', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] - #[case(&['F', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] - #[case(&['F', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] + #[case(&['f', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['f', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['F', 'a'], &['d', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] + #[case(&['F', 'a'], &['d', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['f', 'a'], &['c', ';'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['f', 'a'], &['c', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['F', 'a'], &['c', ','], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Repaint]))] @@ -643,18 +654,24 @@ mod tests { #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart{select:true}])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd{select:true}])]))] #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] - #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), - ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] - #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo]), ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode])]))] #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::Undo]), - ReedlineEvent::Edit(vec![EditCommand::Undo]) + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), ]))] #[case(&['d'], ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![EditCommand::CutSelection])]))] + ReedlineEvent::Edit(vec![EditCommand::CutSelection]), + ReedlineEvent::Edit(vec![EditCommand::ClampCursorToNormalMode]), + ]))] fn test_reedline_move_in_visual_mode(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi { mode: ViMode::Visual, diff --git a/src/engine.rs b/src/engine.rs index ac7ccf84..28121f1e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1229,6 +1229,8 @@ impl Reedline { ReedlineEvent::Esc => { self.deactivate_menus(); self.editor.clear_selection(); + self.editor.set_edit_mode(self.edit_mode.edit_mode()); + self.editor.clamp_cursor(); Ok(EventStatus::Handled) } ReedlineEvent::CtrlD => { diff --git a/src/enums.rs b/src/enums.rs index 46228e0b..13883901 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -506,6 +506,9 @@ pub enum EditCommand { /// The text object to operate on text_object: TextObject, }, + /// Clamp the cursor to the last grapheme if in Vi normal mode. + /// Emitted by the Vi mode to enforce its cursor-on-character invariant. + ClampCursorToNormalMode, } // FIXME: This implementation makes no sense to be here, and should be removed in a future version @@ -647,6 +650,7 @@ impl Display for EditCommand { EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), + EditCommand::ClampCursorToNormalMode => write!(f, "ClampCursorToNormalMode"), } } } @@ -680,6 +684,7 @@ impl EditCommand { | EditCommand::MoveLeftBefore { select, .. } => { EditType::MoveCursor { select: *select } } + EditCommand::ClampCursorToNormalMode => EditType::MoveCursor { select: false }, EditCommand::SwapCursorAndAnchor => EditType::MoveCursor { select: true }, EditCommand::SelectAll => EditType::MoveCursor { select: true },