diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 3cabe1ae..3541639d 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,7 +1,25 @@ +use std::slice::SliceIndex; + use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; use crate::enums::{EditType, UndoBehavior}; use crate::{core_editor::get_default_clipboard, EditCommand}; +// Determines how we update the cut buffer when we next cut +enum LastCutCommand { + // We are currently running a command that includes a cut command. + This, + // We have just run a command that includes a cut command. + Last, + // We have not recently run a cut command. Replace the cut buffer if another cut happens. + BeforeLast, +} + +// Which direction are we cutting in? +enum CutDirection { + Left, + Right, +} + /// Stateful editor executing changes to the underlying [`LineBuffer`] /// /// In comparison to the state-less [`LineBuffer`] the [`Editor`] keeps track of @@ -9,6 +27,7 @@ use crate::{core_editor::get_default_clipboard, EditCommand}; pub struct Editor { line_buffer: LineBuffer, cut_buffer: Box, + last_cut_command: LastCutCommand, edit_stack: EditStack, last_undo_behavior: UndoBehavior, @@ -19,6 +38,7 @@ impl Default for Editor { Editor { line_buffer: LineBuffer::new(), cut_buffer: Box::new(get_default_clipboard()), + last_cut_command: LastCutCommand::BeforeLast, edit_stack: EditStack::new(), last_undo_behavior: UndoBehavior::CreateUndoPoint, } @@ -96,8 +116,14 @@ impl Editor { EditCommand::CutLeftBefore(c) => self.cut_left_until_char(*c, true, true), EditCommand::MoveLeftUntil(c) => self.move_left_until_char(*c, false, true), EditCommand::MoveLeftBefore(c) => self.move_left_until_char(*c, true, true), + EditCommand::StopCutting => self.last_cut_command = LastCutCommand::BeforeLast, } + self.last_cut_command = match self.last_cut_command { + LastCutCommand::This => LastCutCommand::Last, + LastCutCommand::Last | LastCutCommand::BeforeLast => LastCutCommand::BeforeLast, + }; + let new_undo_behavior = match (command, command.edit_type()) { (_, EditType::MoveCursor) => UndoBehavior::MoveCursor, (EditCommand::InsertChar(c), EditType::EditText) => UndoBehavior::InsertCharacter(*c), @@ -213,147 +239,155 @@ impl Editor { self.last_undo_behavior = undo_behavior; } + // Only updates the cut buffer if the range is not empty. + fn maybe_cut(&mut self, cut_range: T, mode: ClipboardMode, direction: CutDirection) + where + T: SliceIndex + std::ops::RangeBounds + Clone, + { + let cut_slice = &self.line_buffer.get_buffer()[cut_range.clone()]; + if !cut_slice.is_empty() { + // If the last command was also a cut then we want to keep accumulating in the cut buffer. + // Otherwise, replace what's in the cut buffer. + let buf; + let cut_slice = match (&self.last_cut_command, direction) { + (LastCutCommand::BeforeLast, _) => cut_slice, + (_, CutDirection::Left) => { + let existing = self.cut_buffer.get().0; + buf = format!("{cut_slice}{existing}"); + &buf + } + (_, CutDirection::Right) => { + let existing = self.cut_buffer.get().0; + buf = format!("{existing}{cut_slice}"); + &buf + } + }; + + self.cut_buffer.set(cut_slice, mode); + + let start = match cut_range.start_bound() { + std::ops::Bound::Included(start) => *start, + std::ops::Bound::Excluded(start) => *start + 1, + std::ops::Bound::Unbounded => 0, + }; + self.line_buffer.set_insertion_point(start); + self.line_buffer.clear_range(cut_range); + } + self.last_cut_command = LastCutCommand::This; + } + fn cut_current_line(&mut self) { let deletion_range = self.line_buffer.current_line_range(); - let cut_slice = &self.line_buffer.get_buffer()[deletion_range.clone()]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Lines); - self.line_buffer.set_insertion_point(deletion_range.start); - self.line_buffer.clear_range(deletion_range); - } + self.maybe_cut( + deletion_range.clone(), + ClipboardMode::Lines, + // FIXME: what do other shells do here? + CutDirection::Right, + ); } fn cut_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, - ); - self.line_buffer.clear_to_insertion_point(); - } + self.maybe_cut( + 0..insertion_offset, + ClipboardMode::Normal, + CutDirection::Left, + ); } fn cut_from_line_start(&mut self) { let previous_offset = self.line_buffer.insertion_point(); self.line_buffer.move_to_line_start(); let deletion_range = self.line_buffer.insertion_point()..previous_offset; - let cut_slice = &self.line_buffer.get_buffer()[deletion_range.clone()]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range(deletion_range); - } + self.maybe_cut( + deletion_range.clone(), + ClipboardMode::Normal, + CutDirection::Left, + ); } fn cut_from_end(&mut self) { - let cut_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_to_end(); - } + self.maybe_cut( + self.line_buffer.insertion_point().., + ClipboardMode::Normal, + CutDirection::Right, + ); } fn cut_to_line_end(&mut self) { - let cut_slice = &self.line_buffer.get_buffer() - [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_to_line_end(); - } + let cut_range = + self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); + self.maybe_cut(cut_range, ClipboardMode::Normal, CutDirection::Right); } fn cut_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 cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let cut_range = left_index..insertion_offset; + self.maybe_cut(cut_range.clone(), ClipboardMode::Normal, CutDirection::Left); } fn cut_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 cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let cut_range = left_index..insertion_offset; + self.maybe_cut(cut_range.clone(), ClipboardMode::Normal, CutDirection::Left); } fn cut_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 cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let cut_range = insertion_offset..right_index; + self.maybe_cut( + cut_range.clone(), + ClipboardMode::Normal, + CutDirection::Right, + ); } fn cut_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 cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let cut_range = insertion_offset..right_index; + self.maybe_cut( + cut_range.clone(), + ClipboardMode::Normal, + CutDirection::Right, + ); } fn cut_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 cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let cut_range = insertion_offset..right_index; + self.maybe_cut( + cut_range.clone(), + ClipboardMode::Normal, + CutDirection::Right, + ); } fn cut_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 cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let cut_range = insertion_offset..right_index; + self.maybe_cut( + cut_range.clone(), + ClipboardMode::Normal, + CutDirection::Right, + ); } fn cut_char(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); let right_index = self.line_buffer.grapheme_right_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let cut_range = insertion_offset..right_index; + self.maybe_cut( + cut_range.clone(), + ClipboardMode::Normal, + CutDirection::Right, + ); } fn insert_cut_buffer_before(&mut self) { @@ -414,18 +448,9 @@ impl Editor { // Saving the section of the string that will be deleted to be // stored into the buffer let extra = if before_char { 0 } else { c.len_utf8() }; - let cut_slice = - &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..index + extra]; - - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + let cut_range = self.line_buffer.insertion_point()..index + extra; - if before_char { - self.line_buffer.delete_right_before_char(c, current_line); - } else { - self.line_buffer.delete_right_until_char(c, current_line); - } - } + self.maybe_cut(cut_range, ClipboardMode::Normal, CutDirection::Right); } } @@ -434,18 +459,9 @@ impl Editor { // Saving the section of the string that will be deleted to be // stored into the buffer let extra = if before_char { c.len_utf8() } else { 0 }; - let cut_slice = - &self.line_buffer.get_buffer()[index + extra..self.line_buffer.insertion_point()]; - - if !cut_slice.is_empty() { - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + let cut_range = index + extra..self.line_buffer.insertion_point(); - if before_char { - self.line_buffer.delete_left_before_char(c, current_line); - } else { - self.line_buffer.delete_left_until_char(c, current_line); - } - } + self.maybe_cut(cut_range, ClipboardMode::Normal, CutDirection::Left); } } diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 4428c645..0e8eef46 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -264,4 +264,84 @@ mod test { assert_eq!(result, ReedlineEvent::None); } + + #[allow(non_snake_case)] + mod integration { + use super::*; + use crate::{Reedline, ReedlineEvent}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + fn unwrap_edits(event: ReedlineEvent) -> Vec { + match event { + ReedlineEvent::Edit(c) => c, + // It's not clear what the difference is between these, but vi mode often returns Multiple + ReedlineEvent::Multiple(events) => { + events.into_iter().flat_map(unwrap_edits).collect() + } + other => panic!("unexpected event {other:#?}"), + } + } + + #[rstest] + #[case::edit_dw_P(&["^", "dw", "P"])] + #[case::edit_db_P(&["db", "P"])] + #[case::edit_dW_P(&["^", "dW", "P"])] + #[case::edit_dB_P(&["dB", "P"])] + #[case::edit_3dw_P(&["^", "3dw", "P"])] + #[case::edit_d3w_P(&["^", "d3w", "P"])] + #[case::edit_dtz_P(&["^", "dtz", "P"])] + #[case::edit_dTz_P(&["dTz", "P"])] + #[case::edit_dfz_P(&["^", "dfz", "P"])] + #[case::edit_dFz_P(&["dFz", "P"])] + #[case::edit_dw_dw_P(&[ + // duplicate the first word, because dw dw P should drop the first word + "^", "dw", "P", "P", "Fj", + // run the actual test, and it should put us back where we started. + "dw", "dw", "P"])] + #[case::edit_dW_dW_P(&[ + // duplicate the first word, because dW dW P should drop the first word + "^", "dW", "P", "P", "B", + // run the actual test, and it should put us back where we started. + "dW", "dW", "P"])] + #[case::edit_dd_u(&["dd", "u"])] + // not actually a no-op because it adds a newline, but we .trim_end() + #[case::edit_dd_p(&["dd", "p"])] + #[case::edit_dd_P_uu(&["dd", "P", "u", "u"])] + // FIXME: this happens on the second line, so doesn't actually delete two lines + // I can't work out how to use "k" to go to the line above because it generates an + // UntilFound([MenuUp, Up]) event, and I'm not sure how to handle that. + #[case::edit_d2d_p(&["d2d", "p"])] + fn sum_to_zero(#[case] commands: &[&str]) { + let initial_input = "the quick brown fox\njumps-over the lazy-dog"; + let keybindings = default_vi_normal_keybindings(); + let mut vi = Vi { + insert_keybindings: default_vi_insert_keybindings(), + normal_keybindings: keybindings, + mode: ViMode::Normal, + ..Default::default() + }; + + let mut reedline = Reedline::create(); + let mut has_changed = false; + reedline.run_edit_commands(&[EditCommand::InsertString(initial_input.into())]); + + for command in commands { + let command: Vec = command.chars().collect(); + let parsed = parse(&mut command.iter().peekable()); + let commands = unwrap_edits(parsed.to_reedline_event(&mut vi)); + + reedline.run_edit_commands(&commands); + + dbg!(command); + has_changed |= initial_input != dbg!(reedline.current_buffer_contents().trim_end()); + } + + assert_eq!(initial_input, reedline.current_buffer_contents().trim_end()); + assert!( + has_changed, + "The sequence of commands should change the input and then restore it" + ) + } + } } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index f243e53b..bf6554be 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -91,6 +91,20 @@ impl ParsedViSequence { } } + fn add_stop_cutting_event(event: ReedlineEvent) -> ReedlineEvent { + match event { + ReedlineEvent::Edit(mut edits) => { + edits.push(EditCommand::StopCutting); + ReedlineEvent::Edit(edits) + } + ReedlineEvent::Multiple(mut edits) => { + edits.push(ReedlineEvent::Edit(vec![EditCommand::StopCutting])); + ReedlineEvent::Multiple(edits) + } + _ => event, + } + } + pub fn enters_insert_mode(&self) -> bool { matches!( (&self.command, &self.motion), @@ -117,7 +131,7 @@ impl ParsedViSequence { ReedlineEvent::None => {} event => vi_state.previous = Some(event.clone()), } - events + Self::add_stop_cutting_event(events) } // This case handles all combinations of commands and motions that could exist (_, Some(command), _, ParseResult::Valid(motion)) => { @@ -127,7 +141,7 @@ impl ParsedViSequence { ReedlineEvent::None => {} event => vi_state.previous = Some(event.clone()), } - events + Self::add_stop_cutting_event(events) } (_, None, _, ParseResult::Valid(motion)) => { self.apply_multiplier(Some(motion.to_reedline(vi_state))) @@ -438,24 +452,52 @@ mod tests { ])]))] #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd])]))] - #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] - #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['i'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Repaint, + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), - ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), ]))] - #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::Undo]), - ReedlineEvent::Edit(vec![EditCommand::Undo]) + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), ]))] #[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::StopCutting]), + ]))] + #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutWordRight]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] + #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft]), + ReedlineEvent::Edit(vec![EditCommand::StopCutting]), + ]))] fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi::default(); let res = vi_parse(input); diff --git a/src/enums.rs b/src/enums.rs index 210dac8e..59fc412e 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -189,6 +189,9 @@ pub enum EditCommand { /// CutUntil left before char MoveLeftBefore(char), + + /// Stop emacs-style cut buffer growth. Next cut command will make a new cut buffer. + StopCutting, } impl Display for EditCommand { @@ -250,6 +253,7 @@ impl Display for EditCommand { EditCommand::CutLeftBefore(_) => write!(f, "CutLeftBefore Value: "), EditCommand::MoveLeftUntil(_) => write!(f, "MoveLeftUntil Value: "), EditCommand::MoveLeftBefore(_) => write!(f, "MoveLeftBefore Value: "), + EditCommand::StopCutting => write!(f, "StopCutting"), } } } @@ -277,7 +281,8 @@ impl EditCommand { | EditCommand::MoveRightUntil(_) | EditCommand::MoveRightBefore(_) | EditCommand::MoveLeftUntil(_) - | EditCommand::MoveLeftBefore(_) => EditType::MoveCursor, + | EditCommand::MoveLeftBefore(_) + | EditCommand::StopCutting => EditType::MoveCursor, // Text edits EditCommand::InsertChar(_)