diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 227606eb..8d7b724a 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -120,12 +120,63 @@ impl Editor { EditCommand::CutSelection => self.cut_selection_to_cut_buffer(), EditCommand::CopySelection => self.copy_selection_to_cut_buffer(), EditCommand::Paste => self.paste_cut_buffer(), + EditCommand::CopyFromStart => self.copy_from_start(), + EditCommand::CopyFromLineStart => self.copy_from_line_start(), + EditCommand::CopyToEnd => self.copy_from_end(), + EditCommand::CopyToLineEnd => self.copy_to_line_end(), + EditCommand::CopyWordLeft => self.copy_word_left(), + EditCommand::CopyBigWordLeft => self.copy_big_word_left(), + EditCommand::CopyWordRight => self.copy_word_right(), + EditCommand::CopyBigWordRight => self.copy_big_word_right(), + EditCommand::CopyWordRightToNext => self.copy_word_right_to_next(), + EditCommand::CopyBigWordRightToNext => self.copy_big_word_right_to_next(), + EditCommand::CopyRightUntil(c) => self.copy_right_until_char(*c, false, true), + EditCommand::CopyRightBefore(c) => self.copy_right_until_char(*c, true, true), + EditCommand::CopyLeftUntil(c) => self.copy_left_until_char(*c, false, true), + EditCommand::CopyLeftBefore(c) => self.copy_left_until_char(*c, true, true), + EditCommand::CopyCurrentLine => { + let range = self.line_buffer.current_line_range(); + let copy_slice = &self.line_buffer.get_buffer()[range]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Lines); + } + } + EditCommand::CopyLeft => { + let insertion_offset = self.line_buffer.insertion_point(); + if insertion_offset > 0 { + let left_index = self.line_buffer.grapheme_left_index(); + let copy_range = left_index..insertion_offset; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + EditCommand::CopyRight => { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.grapheme_right_index(); + if right_index > insertion_offset { + let copy_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } #[cfg(feature = "system_clipboard")] EditCommand::CutSelectionSystem => self.cut_selection_to_system(), #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => self.copy_selection_to_system(), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => self.paste_from_system(), + EditCommand::CutInside { + left_char, + right_char, + } => self.cut_inside(*left_char, *right_char), + EditCommand::YankInside { + left_char, + right_char, + } => self.yank_inside(*left_char, *right_char), } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.selection_anchor = None; @@ -564,10 +615,19 @@ impl Editor { /// The range is guaranteed to be ascending. pub fn get_selection(&self) -> Option<(usize, usize)> { self.selection_anchor.map(|selection_anchor| { + let buffer_len = self.line_buffer.len(); if self.insertion_point() > selection_anchor { - (selection_anchor, self.insertion_point()) + ( + selection_anchor, + self.line_buffer.grapheme_right_index().min(buffer_len), + ) } else { - (self.insertion_point(), selection_anchor) + ( + self.insertion_point(), + self.line_buffer + .grapheme_right_index_from_pos(selection_anchor) + .min(buffer_len), + ) } }) } @@ -648,6 +708,198 @@ impl Editor { self.delete_selection(); insert_clipboard_content_before(&mut self.line_buffer, self.cut_buffer.deref_mut()); } + + pub(crate) fn reset_selection(&mut self) { + self.selection_anchor = None; + } + + /// Delete text strictly between matching `left_char` and `right_char`. + /// Places deleted text into the cut buffer. + /// Leaves the parentheses/quotes/etc. themselves. + /// On success, move the cursor just after the `left_char`. + /// If matching chars can't be found, restore the original cursor. + pub(crate) fn cut_inside(&mut self, left_char: char, right_char: char) { + let old_pos = self.insertion_point(); + let buffer_len = self.line_buffer.len(); + + if let Some((lp, rp)) = + self.line_buffer + .find_matching_pair(left_char, right_char, self.insertion_point()) + { + let inside_start = lp + left_char.len_utf8(); + if inside_start < rp && rp <= buffer_len { + let inside_slice = &self.line_buffer.get_buffer()[inside_start..rp]; + if !inside_slice.is_empty() { + self.cut_buffer.set(inside_slice, ClipboardMode::Normal); + self.line_buffer.clear_range_safe(inside_start, rp); + } + self.line_buffer + .set_insertion_point(lp + left_char.len_utf8()); + return; + } + } + // If no valid pair was found, restore original cursor + self.line_buffer.set_insertion_point(old_pos); + } + + pub(crate) fn copy_from_start(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + if insertion_offset > 0 { + self.cut_buffer.set( + &self.line_buffer.get_buffer()[..insertion_offset], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_from_line_start(&mut self) { + let previous_offset = self.line_buffer.insertion_point(); + let start_offset = { + let temp_pos = self.line_buffer.insertion_point(); + self.line_buffer.move_to_line_start(); + let start = self.line_buffer.insertion_point(); + self.line_buffer.set_insertion_point(temp_pos); + start + }; + let copy_range = start_offset..previous_offset; + let copy_slice = &self.line_buffer.get_buffer()[copy_range]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Normal); + } + } + + pub(crate) fn copy_from_end(&mut self) { + let copy_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Normal); + } + } + + pub(crate) fn copy_to_line_end(&mut self) { + let copy_slice = &self.line_buffer.get_buffer() + [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Normal); + } + } + + pub(crate) fn copy_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let left_index = self.line_buffer.word_left_index(); + if left_index < insertion_offset { + let copy_range = left_index..insertion_offset; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_big_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let left_index = self.line_buffer.big_word_left_index(); + if left_index < insertion_offset { + let copy_range = left_index..insertion_offset; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_word_right(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.word_right_index(); + if right_index > insertion_offset { + let copy_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_big_word_right(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.next_whitespace(); + if right_index > insertion_offset { + let copy_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_word_right_to_next(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.word_right_start_index(); + if right_index > insertion_offset { + let copy_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_big_word_right_to_next(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.big_word_right_start_index(); + if right_index > insertion_offset { + let copy_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[copy_range], + ClipboardMode::Normal, + ); + } + } + + pub(crate) fn copy_right_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if let Some(index) = self.line_buffer.find_char_right(c, current_line) { + let extra = if before_char { 0 } else { c.len_utf8() }; + let copy_slice = + &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..index + extra]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Normal); + } + } + } + + pub(crate) fn copy_left_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if let Some(index) = self.line_buffer.find_char_left(c, current_line) { + let extra = if before_char { c.len_utf8() } else { 0 }; + let copy_slice = + &self.line_buffer.get_buffer()[index + extra..self.line_buffer.insertion_point()]; + if !copy_slice.is_empty() { + self.cut_buffer.set(copy_slice, ClipboardMode::Normal); + } + } + } + + /// Yank text strictly between matching `left_char` and `right_char`. + /// Copies it into the cut buffer without removing anything. + /// Leaves the buffer unchanged and restores the original cursor. + pub(crate) fn yank_inside(&mut self, left_char: char, right_char: char) { + let old_pos = self.insertion_point(); + let buffer_len = self.line_buffer.len(); + + if let Some((lp, rp)) = + self.line_buffer + .find_matching_pair(left_char, right_char, self.insertion_point()) + { + let inside_start = lp + left_char.len_utf8(); + if inside_start < rp && rp <= buffer_len { + let inside_slice = &self.line_buffer.get_buffer()[inside_start..rp]; + if !inside_slice.is_empty() { + self.cut_buffer.set(inside_slice, ClipboardMode::Normal); + } + } + } + + // Always restore the cursor position + self.line_buffer.set_insertion_point(old_pos); + } } fn insert_clipboard_content_before(line_buffer: &mut LineBuffer, clipboard: &mut dyn Clipboard) { @@ -898,4 +1150,129 @@ mod test { pretty_assertions::assert_eq!(editor.line_buffer.len(), s.len() * 2); } } + + #[test] + fn test_cut_inside_brackets() { + let mut editor = editor_with("foo(bar)baz"); + editor.move_to_position(5, false); // Move inside brackets + editor.cut_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo()baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); + + // Test with cursor outside brackets + let mut editor = editor_with("foo(bar)baz"); + editor.move_to_position(0, false); + editor.cut_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar)baz"); + assert_eq!(editor.insertion_point(), 0); + assert_eq!(editor.cut_buffer.get().0, ""); + + // Test with no matching brackets + let mut editor = editor_with("foo bar baz"); + editor.move_to_position(4, false); + editor.cut_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo bar baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, ""); + } + + #[test] + fn test_cut_inside_quotes() { + let mut editor = editor_with("foo\"bar\"baz"); + editor.move_to_position(5, false); // Move inside quotes + editor.cut_inside('"', '"'); + assert_eq!(editor.get_buffer(), "foo\"\"baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); + + // Test with cursor outside quotes + let mut editor = editor_with("foo\"bar\"baz"); + editor.move_to_position(0, false); + editor.cut_inside('"', '"'); + assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); + assert_eq!(editor.insertion_point(), 0); + assert_eq!(editor.cut_buffer.get().0, ""); + + // Test with no matching quotes + let mut editor = editor_with("foo bar baz"); + editor.move_to_position(4, false); + editor.cut_inside('"', '"'); + assert_eq!(editor.get_buffer(), "foo bar baz"); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn test_cut_inside_nested() { + let mut editor = editor_with("foo(bar(baz)qux)quux"); + editor.move_to_position(8, false); // Move inside inner brackets + editor.cut_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar()qux)quux"); + assert_eq!(editor.insertion_point(), 8); + assert_eq!(editor.cut_buffer.get().0, "baz"); + + editor.move_to_position(4, false); // Move inside outer brackets + editor.cut_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo()quux"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar()qux"); + } + + #[test] + fn test_yank_inside_brackets() { + let mut editor = editor_with("foo(bar)baz"); + editor.move_to_position(5, false); // Move inside brackets + editor.yank_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar)baz"); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position + + // Test yanked content by pasting + editor.paste_cut_buffer(); + assert_eq!(editor.get_buffer(), "foo(bbarar)baz"); + + // Test with cursor outside brackets + let mut editor = editor_with("foo(bar)baz"); + editor.move_to_position(0, false); + editor.yank_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar)baz"); + assert_eq!(editor.insertion_point(), 0); + } + + #[test] + fn test_yank_inside_quotes() { + let mut editor = editor_with("foo\"bar\"baz"); + editor.move_to_position(5, false); // Move inside quotes + editor.yank_inside('"', '"'); + assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position + assert_eq!(editor.cut_buffer.get().0, "bar"); + + // Test with no matching quotes + let mut editor = editor_with("foo bar baz"); + editor.move_to_position(4, false); + editor.yank_inside('"', '"'); + assert_eq!(editor.get_buffer(), "foo bar baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, ""); + } + + #[test] + fn test_yank_inside_nested() { + let mut editor = editor_with("foo(bar(baz)qux)quux"); + editor.move_to_position(8, false); // Move inside inner brackets + editor.yank_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar(baz)qux)quux"); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), 8); + assert_eq!(editor.cut_buffer.get().0, "baz"); + + // Test yanked content by pasting + editor.paste_cut_buffer(); + assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); + + editor.move_to_position(4, false); // Move inside outer brackets + editor.yank_inside('(', ')'); + assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar(bazbaz)qux"); + } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index ce5dc889..0b3ae15f 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -168,6 +168,15 @@ impl LineBuffer { .unwrap_or(0) } + /// Cursor position *behind* the next unicode grapheme to the right from the given position + pub fn grapheme_right_index_from_pos(&self, pos: usize) -> usize { + self.lines[pos..] + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| pos + i) + .unwrap_or_else(|| self.lines.len()) + } + /// Cursor position *behind* the next word to the right pub fn word_right_index(&self) -> usize { self.lines[self.insertion_point..] @@ -767,6 +776,119 @@ impl LineBuffer { self.insertion_point = index + c.len_utf8(); } } + + /// Attempts to find the matching `(left_char, right_char)` pair *enclosing* + /// the cursor position, respecting nested pairs. + /// + /// Algorithm: + /// 1. Walk left from `cursor` until we find the "outermost" `left_char`, + /// ignoring any extra `right_char` we see (i.e., we keep a depth counter). + /// 2. Then from that left bracket, walk right to find the matching `right_char`, + /// also respecting nesting. + /// + /// Returns `Some((left_index, right_index))` if found, or `None` otherwise. + pub fn find_matching_pair( + &self, + left_char: char, + right_char: char, + cursor: usize, + ) -> Option<(usize, usize)> { + // Special case: quotes or the same char for left & right + // (Vi doesn't do nested quotes, so no depth counting). + if left_char == right_char { + // 1) Walk left to find the first matching quote + let mut scan_pos = cursor; + while scan_pos > 0 { + // Move left by one grapheme + let mut tmp = LineBuffer { + lines: self.lines.clone(), + insertion_point: scan_pos, + }; + tmp.move_left(); + scan_pos = tmp.insertion_point; + + if scan_pos >= self.lines.len() { + break; + } + let ch = self.lines[scan_pos..].chars().next().unwrap_or('\0'); + if ch == left_char { + // Found the "left quote" + let left_index = scan_pos; + // 2) Now walk right to find the next matching quote + let mut scan_pos_r = left_index + left_char.len_utf8(); + while scan_pos_r < self.lines.len() { + let next_ch = self.lines[scan_pos_r..].chars().next().unwrap(); + if next_ch == right_char { + return Some((left_index, scan_pos_r)); + } + scan_pos_r += next_ch.len_utf8(); + } + return None; // no right quote found + } + } + return None; // no left quote found + } + + // Step 1: search left + let mut scan_pos = cursor; + let mut depth = 0; + + while scan_pos > 0 { + // Move left by one grapheme + scan_pos = { + // a small helper to move left from an arbitrary position + let mut tmp = LineBuffer { + lines: self.lines.clone(), + insertion_point: scan_pos, + }; + tmp.move_left(); + tmp.insertion_point + }; + if scan_pos >= self.lines.len() { + break; + } + + let ch = self.lines[scan_pos..].chars().next().unwrap_or('\0'); + + if ch == left_char && depth == 0 { + // Found the "outermost" left bracket + let left_index = scan_pos; + // Step 2: search right from `left_index + left_char.len_utf8()` to find matching + let mut scan_pos_r = left_index + left_char.len_utf8(); + let mut depth_r = 0; + + while scan_pos_r < self.lines.len() { + let next_ch = self.lines[scan_pos_r..].chars().next().unwrap(); + if next_ch == left_char { + depth_r += 1; + } else if next_ch == right_char { + if depth_r == 0 { + // Found the matching close + let right_index = scan_pos_r; + return Some((left_index, right_index)); + } else { + depth_r -= 1; + } + } + scan_pos_r += next_ch.len_utf8(); + } + // Matching right bracket not found + return None; + } else if ch == right_char { + // This means we are "inside" nested parentheses, so increment nesting + depth += 1; + } else if ch == left_char { + // If we see another left_char while depth>0, it just closes one nesting level + if depth > 0 { + depth -= 1; + } else { + // That would be the outer bracket if depth==0, + // but we handle that in the `if ch == left_char && depth == 0` above + } + } + } + None + } } /// Match any sequence of characters that are considered a word boundary @@ -1597,4 +1719,26 @@ mod test { assert_eq!(index, expected); } + + #[rstest] + #[case("abc", 0, 1)] // Basic ASCII + #[case("abc", 1, 2)] // From middle position + #[case("abc", 2, 3)] // From last char + #[case("abc", 3, 3)] // From end of string + #[case("🦀rust", 0, 4)] // Unicode emoji + #[case("🦀rust", 4, 5)] // After emoji + #[case("é́", 0, 4)] // Combining characters + fn test_grapheme_right_index_from_pos( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line = LineBuffer::new(); + line.insert_str(input); + assert_eq!( + line.grapheme_right_index_from_pos(position), + expected, + "input: {input:?}, pos: {position}" + ); + } } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index f00b549b..faef45d9 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -1,4 +1,4 @@ -use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption}; +use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption, ViMode}; use crate::{EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; @@ -9,13 +9,16 @@ where match input.peek() { Some('d') => { let _ = input.next(); + // Checking for "di(" or "di)" etc. if let Some('i') = input.peek() { let _ = input.next(); match input.next() { - Some(c) - if is_valid_change_inside_left(c) || is_valid_change_inside_right(c) => - { - Some(Command::DeleteInside(*c)) + Some(&c) => { + if let Some((l, r)) = bracket_pair_for(c) { + Some(Command::DeleteInsidePair { left: l, right: r }) + } else { + None + } } _ => None, } @@ -23,6 +26,25 @@ where Some(Command::Delete) } } + // Checking for "yi(" or "yi)" etc. + Some('y') => { + let _ = input.next(); + if let Some('i') = input.peek() { + let _ = input.next(); + match input.next() { + Some(&c) => { + if let Some((l, r)) = bracket_pair_for(c) { + Some(Command::YankInsidePair { left: l, right: r }) + } else { + None + } + } + _ => None, + } + } else { + Some(Command::Yank) + } + } Some('p') => { let _ = input.next(); Some(Command::PasteAfter) @@ -43,15 +65,18 @@ where let _ = input.next(); Some(Command::Undo) } + // Checking for "ci(" or "ci)" etc. Some('c') => { let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); match input.next() { - Some(c) - if is_valid_change_inside_left(c) || is_valid_change_inside_right(c) => - { - Some(Command::ChangeInside(*c)) + Some(&c) => { + if let Some((l, r)) = bracket_pair_for(c) { + Some(Command::ChangeInsidePair { left: l, right: r }) + } else { + None + } } _ => None, } @@ -131,8 +156,11 @@ pub enum Command { HistorySearch, Switchcase, RepeatLastAction, - ChangeInside(char), - DeleteInside(char), + Yank, + // These DoSthInsidePair commands are agnostic to whether user pressed the left char or right char + ChangeInsidePair { left: char, right: char }, + DeleteInsidePair { left: char, right: char }, + YankInsidePair { left: char, right: char }, } impl Command { @@ -140,12 +168,13 @@ impl Command { match self { Command::Delete => Some('d'), Command::Change => Some('c'), + Command::Yank => Some('y'), _ => None, } } pub fn requires_motion(&self) -> bool { - matches!(self, Command::Delete | Command::Change) + matches!(self, Command::Delete | Command::Change | Command::Yank) } pub fn to_reedline(&self, vi_state: &mut Vi) -> Vec { @@ -166,53 +195,50 @@ impl Command { select: false, })], Self::RewriteCurrentLine => vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)], - Self::DeleteChar => vec![ReedlineOption::Edit(EditCommand::CutChar)], + Self::DeleteChar => { + if vi_state.mode == ViMode::Visual { + vec![ReedlineOption::Edit(EditCommand::CutSelection)] + } else { + vec![ReedlineOption::Edit(EditCommand::CutChar)] + } + } Self::ReplaceChar(c) => { vec![ReedlineOption::Edit(EditCommand::ReplaceChar(*c))] } - Self::SubstituteCharWithInsert => vec![ReedlineOption::Edit(EditCommand::CutChar)], + Self::SubstituteCharWithInsert => { + if vi_state.mode == ViMode::Visual { + vec![ReedlineOption::Edit(EditCommand::CutSelection)] + } else { + vec![ReedlineOption::Edit(EditCommand::CutChar)] + } + } Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], // 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::Yank => vec![ReedlineOption::Edit(EditCommand::CopySelection)], Self::Incomplete => vec![ReedlineOption::Incomplete], Self::RepeatLastAction => match &vi_state.previous { Some(event) => vec![ReedlineOption::Event(event.clone())], None => vec![], }, - Self::ChangeInside(left) if is_valid_change_inside_left(left) => { - let right = bracket_for(left); - vec![ - ReedlineOption::Edit(EditCommand::CutLeftBefore(*left)), - ReedlineOption::Edit(EditCommand::CutRightBefore(right)), - ] - } - Self::ChangeInside(right) if is_valid_change_inside_right(right) => { - let left = bracket_for(right); - vec![ - ReedlineOption::Edit(EditCommand::CutLeftBefore(left)), - ReedlineOption::Edit(EditCommand::CutRightBefore(*right)), - ] - } - Self::ChangeInside(_) => { - vec![] - } - Self::DeleteInside(left) if is_valid_change_inside_left(left) => { - let right = bracket_for(left); - vec![ - ReedlineOption::Edit(EditCommand::CutLeftBefore(*left)), - ReedlineOption::Edit(EditCommand::CutRightBefore(right)), - ] + Self::ChangeInsidePair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CutInside { + left_char: *left, + right_char: *right, + })] } - Self::DeleteInside(right) if is_valid_change_inside_right(right) => { - let left = bracket_for(right); - vec![ - ReedlineOption::Edit(EditCommand::CutLeftBefore(left)), - ReedlineOption::Edit(EditCommand::CutRightBefore(*right)), - ] + Self::DeleteInsidePair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CutInside { + left_char: *left, + right_char: *right, + })] } - Self::DeleteInside(_) => { - vec![] + Self::YankInsidePair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::YankInside { + left_char: *left, + right_char: *right, + })] } } } @@ -332,29 +358,71 @@ 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::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), + Motion::Up => None, + Motion::Down => None, + 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())]), + }, _ => None, } } } -fn bracket_for(c: &char) -> char { - match *c { - '(' => ')', - '[' => ']', - '{' => '}', - '<' => '>', - ')' => '(', - ']' => '[', - '}' => '{', - '>' => '<', - _ => *c, +fn bracket_pair_for(c: char) -> Option<(char, char)> { + match c { + '(' => Some(('(', ')')), + '[' => Some(('[', ']')), + '{' => Some(('{', '}')), + '<' => Some(('<', '>')), + ')' => Some(('(', ')')), + ']' => Some(('[', ']')), + '}' => Some(('{', '}')), + '>' => Some(('<', '>')), + '"' => Some(('"', '"')), + '\'' => Some(('\'', '\'')), + '`' => Some(('`', '`')), + _ => None, } } - -pub(crate) fn is_valid_change_inside_left(c: &char) -> bool { - matches!(c, '(' | '[' | '{' | '"' | '\'' | '`' | '<') -} - -pub(crate) fn is_valid_change_inside_right(c: &char) -> bool { - matches!(c, ')' | ']' | '}' | '"' | '\'' | '`' | '>') -} diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 81fa5b04..cc19cbd6 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -89,11 +89,10 @@ impl EditMode for Vi { self.cache.clear(); ReedlineEvent::None } else if res.is_complete(self.mode) { - if let Some(mode) = res.changes_mode() { + let event = res.to_reedline_event(self); + if let Some(mode) = res.changes_mode(self.mode) { self.mode = mode; } - - let event = res.to_reedline_event(self); self.cache.clear(); event } else { @@ -143,7 +142,11 @@ impl EditMode for Vi { (_, KeyModifiers::NONE, KeyCode::Esc) => { self.cache.clear(); self.mode = ViMode::Normal; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ReedlineEvent::Multiple(vec![ + ReedlineEvent::ResetSelection, + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) } (_, KeyModifiers::NONE, KeyCode::Enter) => { self.mode = ViMode::Insert; @@ -192,7 +195,11 @@ mod test { assert_eq!( result, - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ReedlineEvent::Multiple(vec![ + ReedlineEvent::ResetSelection, + ReedlineEvent::Esc, + ReedlineEvent::Repaint + ]) ); assert!(matches!(vi.mode, ViMode::Normal)); } diff --git a/src/edit_mode/vi/motion.rs b/src/edit_mode/vi/motion.rs index a0e1ad3c..a59edf3e 100644 --- a/src/edit_mode/vi/motion.rs +++ b/src/edit_mode/vi/motion.rs @@ -295,4 +295,13 @@ impl ViCharSearch { ViCharSearch::TillLeft(c) => EditCommand::CutLeftBefore(*c), } } + + pub fn to_copy(&self) -> EditCommand { + match self { + ViCharSearch::ToRight(c) => EditCommand::CopyRightUntil(*c), + ViCharSearch::TillRight(c) => EditCommand::CopyRightBefore(*c), + ViCharSearch::ToLeft(c) => EditCommand::CopyLeftUntil(*c), + ViCharSearch::TillLeft(c) => EditCommand::CopyLeftBefore(*c), + } + } } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index 0699dbc1..3c39a3ea 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -1,6 +1,4 @@ -use super::command::{ - is_valid_change_inside_left, is_valid_change_inside_right, parse_command, Command, -}; +use super::command::{parse_command, Command}; use super::motion::{parse_motion, Motion}; use crate::{edit_mode::vi::ViMode, EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; @@ -98,7 +96,7 @@ impl ParsedViSequence { } } - pub fn changes_mode(&self) -> Option { + pub fn changes_mode(&self, mode: ViMode) -> Option { match (&self.command, &self.motion) { (Some(Command::EnterViInsert), ParseResult::Incomplete) | (Some(Command::EnterViAppend), ParseResult::Incomplete) @@ -109,12 +107,23 @@ impl ParsedViSequence { | (Some(Command::SubstituteCharWithInsert), ParseResult::Incomplete) | (Some(Command::HistorySearch), ParseResult::Incomplete) | (Some(Command::Change), ParseResult::Valid(_)) => Some(ViMode::Insert), - (Some(Command::ChangeInside(char)), ParseResult::Incomplete) - if is_valid_change_inside_left(char) || is_valid_change_inside_right(char) => - { + (Some(Command::Change), ParseResult::Incomplete) if mode == ViMode::Visual => { Some(ViMode::Insert) } - (Some(Command::Delete), ParseResult::Incomplete) => Some(ViMode::Normal), + (Some(Command::Delete), ParseResult::Incomplete) if mode == ViMode::Visual => { + Some(ViMode::Normal) + } + (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), + (Some(Command::Delete), ParseResult::Incomplete) + | (Some(Command::DeleteChar), ParseResult::Incomplete) + | (Some(Command::DeleteToEnd), ParseResult::Incomplete) + | (Some(Command::Delete), ParseResult::Valid(_)) + | (Some(Command::DeleteChar), ParseResult::Valid(_)) + | (Some(Command::DeleteToEnd), ParseResult::Valid(_)) + | (Some(Command::Yank), ParseResult::Valid(_)) + | (Some(Command::Yank), ParseResult::Incomplete) + | (Some(Command::DeleteInsidePair { .. }), _) + | (Some(Command::YankInsidePair { .. }), _) => Some(ViMode::Normal), _ => None, } } diff --git a/src/engine.rs b/src/engine.rs index dd9b02f8..c08a27e5 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -909,6 +909,10 @@ impl Reedline { self.input_mode = InputMode::Regular; Ok(EventStatus::Handled) } + ReedlineEvent::ResetSelection => { + self.editor.reset_selection(); + Ok(EventStatus::Handled) + } // TODO: Check if events should be handled ReedlineEvent::Right | ReedlineEvent::Left @@ -1197,6 +1201,10 @@ impl Reedline { Ok(EventStatus::Handled) } ReedlineEvent::OpenEditor => self.open_editor().map(|_| EventStatus::Handled), + ReedlineEvent::ResetSelection => { + self.editor.reset_selection(); + Ok(EventStatus::Handled) + } ReedlineEvent::Resize(width, height) => { self.painter.handle_resize(width, height); Ok(EventStatus::Handled) diff --git a/src/enums.rs b/src/enums.rs index cde42b1a..3b87b099 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -267,6 +267,57 @@ pub enum EditCommand { /// Paste content from local buffer at the current cursor position Paste, + /// Copy from the start of the buffer to the insertion point + CopyFromStart, + + /// Copy from the start of the current line to the insertion point + CopyFromLineStart, + + /// Copy from the insertion point to the end of the buffer + CopyToEnd, + + /// Copy from the insertion point to the end of the current line + CopyToLineEnd, + + /// Copy the current line + CopyCurrentLine, + + /// Copy the word left of the insertion point + CopyWordLeft, + + /// Copy the WORD left of the insertion point + CopyBigWordLeft, + + /// Copy the word right of the insertion point + CopyWordRight, + + /// Copy the WORD right of the insertion point + CopyBigWordRight, + + /// Copy the word right of the insertion point and any following space + CopyWordRightToNext, + + /// Copy the WORD right of the insertion point and any following space + CopyBigWordRightToNext, + + /// Copy one character to the left + CopyLeft, + + /// Copy one character to the right + CopyRight, + + /// Copy until right until char + CopyRightUntil(char), + + /// Copy right before char + CopyRightBefore(char), + + /// Copy left until char + CopyLeftUntil(char), + + /// Copy left before char + CopyLeftBefore(char), + /// Cut selection to system clipboard #[cfg(feature = "system_clipboard")] CutSelectionSystem, @@ -278,6 +329,21 @@ pub enum EditCommand { /// Paste content from system clipboard at the current cursor position #[cfg(feature = "system_clipboard")] PasteSystem, + + /// Delete text between matching characters atomically + CutInside { + /// Left character of the pair + left_char: char, + /// Right character of the pair (usually matching bracket) + right_char: char, + }, + /// Yank text between matching characters atomically + YankInside { + /// Left character of the pair + left_char: char, + /// Right character of the pair (usually matching bracket) + right_char: char, + }, } impl Display for EditCommand { @@ -365,12 +431,31 @@ impl Display for EditCommand { EditCommand::CutSelection => write!(f, "CutSelection"), EditCommand::CopySelection => write!(f, "CopySelection"), EditCommand::Paste => write!(f, "Paste"), + EditCommand::CopyFromStart => write!(f, "CopyFromStart"), + EditCommand::CopyFromLineStart => write!(f, "CopyFromLineStart"), + EditCommand::CopyToEnd => write!(f, "CopyToEnd"), + EditCommand::CopyToLineEnd => write!(f, "CopyToLineEnd"), + EditCommand::CopyCurrentLine => write!(f, "CopyCurrentLine"), + EditCommand::CopyWordLeft => write!(f, "CopyWordLeft"), + EditCommand::CopyBigWordLeft => write!(f, "CopyBigWordLeft"), + EditCommand::CopyWordRight => write!(f, "CopyWordRight"), + EditCommand::CopyBigWordRight => write!(f, "CopyBigWordRight"), + EditCommand::CopyWordRightToNext => write!(f, "CopyWordRightToNext"), + EditCommand::CopyBigWordRightToNext => write!(f, "CopyBigWordRightToNext"), + EditCommand::CopyLeft => write!(f, "CopyLeft"), + EditCommand::CopyRight => write!(f, "CopyRight"), + EditCommand::CopyRightUntil(_) => write!(f, "CopyRightUntil Value: "), + EditCommand::CopyRightBefore(_) => write!(f, "CopyRightBefore Value: "), + EditCommand::CopyLeftUntil(_) => write!(f, "CopyLeftUntil Value: "), + EditCommand::CopyLeftBefore(_) => write!(f, "CopyLeftBefore Value: "), #[cfg(feature = "system_clipboard")] EditCommand::CutSelectionSystem => write!(f, "CutSelectionSystem"), #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => write!(f, "CopySelectionSystem"), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), + EditCommand::CutInside { .. } => write!(f, "CutInside Value: "), + EditCommand::YankInside { .. } => write!(f, "YankInside Value: "), } } } @@ -451,6 +536,25 @@ impl EditCommand { EditCommand::CopySelection => EditType::NoOp, #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, + EditCommand::CutInside { .. } => EditType::EditText, + EditCommand::YankInside { .. } => EditType::EditText, + EditCommand::CopyFromStart + | EditCommand::CopyFromLineStart + | EditCommand::CopyToEnd + | EditCommand::CopyToLineEnd + | EditCommand::CopyCurrentLine + | EditCommand::CopyWordLeft + | EditCommand::CopyBigWordLeft + | EditCommand::CopyWordRight + | EditCommand::CopyBigWordRight + | EditCommand::CopyWordRightToNext + | EditCommand::CopyBigWordRightToNext + | EditCommand::CopyLeft + | EditCommand::CopyRight + | EditCommand::CopyRightUntil(_) + | EditCommand::CopyRightBefore(_) + | EditCommand::CopyLeftUntil(_) + | EditCommand::CopyLeftBefore(_) => EditType::NoOp, } } } @@ -644,6 +748,9 @@ pub enum ReedlineEvent { /// Open text editor OpenEditor, + + /// Reset the current text selection + ResetSelection, } impl Display for ReedlineEvent { @@ -687,6 +794,7 @@ impl Display for ReedlineEvent { ReedlineEvent::MenuPagePrevious => write!(f, "MenuPagePrevious"), ReedlineEvent::ExecuteHostCommand(_) => write!(f, "ExecuteHostCommand"), ReedlineEvent::OpenEditor => write!(f, "OpenEditor"), + ReedlineEvent::ResetSelection => write!(f, "ResetSelection"), } } }