From 480059a3f52cf919341cda88e8c544edd846bc73 Mon Sep 17 00:00:00 2001 From: Adam Schmalhofer Date: Sat, 6 Jul 2024 14:26:54 +0200 Subject: [PATCH] Feature: vi visual mode (#800) * Add vi visual mode as a proof of concept * Fix h, l in vi visual mode * Extend vi command parsing for vi visual mode Commands requiring motion in normal mode, don't in visual mode. * Add delete command to vi visual mode * Refractor: generalized enters_insert_mode() to allow switching from vi visual mode to vi normal mode instead of just to vi insert mode. * Add switch from vi visual mode to normal mode after deleting selection. * Dokumentation: Visual selection implemented * Cleanup: `cargo fmt --all` * Made clippy clean * Made `cargo fmt --call` clean --- README.md | 2 +- src/edit_mode/vi/command.rs | 5 +- src/edit_mode/vi/mod.rs | 18 ++-- src/edit_mode/vi/motion.rs | 53 ++++++----- src/edit_mode/vi/parser.rs | 175 +++++++++++++++++++++++++++++------- 5 files changed, 190 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index b8110c4f..4b485e9b 100644 --- a/README.md +++ b/README.md @@ -222,13 +222,13 @@ Reedline has now all the basic features to become the primary line editor for [n - Undo support. - Clipboard integration - Line completeness validation for seamless entry of multiline command sequences. +- Visual selection ### Areas for future improvements - [ ] Support for Unicode beyond simple left-to-right scripts - [ ] Easier keybinding configuration - [ ] Support for more advanced vi commands -- [ ] Visual selection - [ ] Smooth experience if completion or prompt content takes long to compute - [ ] Support for a concurrent output stream from background tasks to be displayed, while the input prompt is active. ("Full duplex" mode) diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 145e3dd0..e7bf0426 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -147,8 +147,9 @@ impl Command { Self::SubstituteCharWithInsert => vec![ReedlineOption::Edit(EditCommand::CutChar)], Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], - // Mark a command as incomplete whenever a motion is required to finish the command - Self::Delete | Self::Change | Self::Incomplete => vec![ReedlineOption::Incomplete], + // 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::Incomplete => vec![ReedlineOption::Incomplete], Command::RepeatLastAction => match &vi_state.previous { Some(event) => vec![ReedlineOption::Event(event.clone())], None => vec![], diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 4428c645..7c7601d1 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -19,6 +19,7 @@ use crate::{ enum ViMode { Normal, Insert, + Visual, } /// This parses incoming input `Event`s like a Vi-Style editor @@ -62,7 +63,12 @@ impl EditMode for Vi { Event::Key(KeyEvent { code, modifiers, .. }) => match (self.mode, modifiers, code) { - (ViMode::Normal, modifier, KeyCode::Char(c)) => { + (ViMode::Normal, KeyModifiers::NONE, KeyCode::Char('v')) => { + self.cache.clear(); + self.mode = ViMode::Visual; + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + } + (ViMode::Normal | ViMode::Visual, modifier, KeyCode::Char(c)) => { let c = c.to_ascii_lowercase(); if let Some(event) = self @@ -82,9 +88,9 @@ impl EditMode for Vi { if !res.is_valid() { self.cache.clear(); ReedlineEvent::None - } else if res.is_complete() { - if res.enters_insert_mode() { - self.mode = ViMode::Insert; + } else if res.is_complete(self.mode) { + if let Some(mode) = res.changes_mode() { + self.mode = mode; } let event = res.to_reedline_event(self); @@ -143,7 +149,7 @@ impl EditMode for Vi { self.mode = ViMode::Insert; ReedlineEvent::Enter } - (ViMode::Normal, _, _) => self + (ViMode::Normal | ViMode::Visual, _, _) => self .normal_keybindings .find_binding(modifiers, code) .unwrap_or(ReedlineEvent::None), @@ -165,7 +171,7 @@ impl EditMode for Vi { fn edit_mode(&self) -> PromptEditMode { match self.mode { - ViMode::Normal => PromptEditMode::Vi(PromptViMode::Normal), + ViMode::Normal | ViMode::Visual => PromptEditMode::Vi(PromptViMode::Normal), ViMode::Insert => PromptEditMode::Vi(PromptViMode::Insert), } } diff --git a/src/edit_mode/vi/motion.rs b/src/edit_mode/vi/motion.rs index 2095b100..a0e1ad3c 100644 --- a/src/edit_mode/vi/motion.rs +++ b/src/edit_mode/vi/motion.rs @@ -1,6 +1,6 @@ use std::iter::Peekable; -use crate::{EditCommand, ReedlineEvent, Vi}; +use crate::{edit_mode::vi::ViMode, EditCommand, ReedlineEvent, Vi}; use super::parser::{ParseResult, ReedlineOption}; @@ -142,89 +142,98 @@ pub enum Motion { impl Motion { pub fn to_reedline(&self, vi_state: &mut Vi) -> Vec { + let select_mode = vi_state.mode == ViMode::Visual; match self { Motion::Left => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ ReedlineEvent::MenuLeft, - ReedlineEvent::Left, + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { + select: select_mode, + }]), ]))], Motion::Right => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ ReedlineEvent::HistoryHintComplete, ReedlineEvent::MenuRight, - ReedlineEvent::Right, + ReedlineEvent::Edit(vec![EditCommand::MoveRight { + select: select_mode, + }]), ]))], Motion::Up => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ ReedlineEvent::MenuUp, ReedlineEvent::Up, + // todo: add EditCommand::MoveLineUp ]))], Motion::Down => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ ReedlineEvent::MenuDown, ReedlineEvent::Down, + // todo: add EditCommand::MoveLineDown ]))], Motion::NextWord => vec![ReedlineOption::Edit(EditCommand::MoveWordRightStart { - select: false, + select: select_mode, })], Motion::NextBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightStart { - select: false, + select: select_mode, })], Motion::NextWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveWordRightEnd { - select: false, + select: select_mode, })], Motion::NextBigWordEnd => { vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightEnd { - select: false, + select: select_mode, })] } Motion::PreviousWord => vec![ReedlineOption::Edit(EditCommand::MoveWordLeft { - select: false, + select: select_mode, })], Motion::PreviousBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordLeft { - select: false, + select: select_mode, })], Motion::Line => vec![], // Placeholder as unusable standalone motion Motion::Start => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart { - select: false, + select: select_mode, })], Motion::End => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd { - select: false, + select: select_mode, })], Motion::RightUntil(ch) => { vi_state.last_char_search = Some(ViCharSearch::ToRight(*ch)); vec![ReedlineOption::Edit(EditCommand::MoveRightUntil { c: *ch, - select: false, + select: select_mode, })] } Motion::RightBefore(ch) => { vi_state.last_char_search = Some(ViCharSearch::TillRight(*ch)); vec![ReedlineOption::Edit(EditCommand::MoveRightBefore { c: *ch, - select: false, + select: select_mode, })] } Motion::LeftUntil(ch) => { vi_state.last_char_search = Some(ViCharSearch::ToLeft(*ch)); vec![ReedlineOption::Edit(EditCommand::MoveLeftUntil { c: *ch, - select: false, + select: select_mode, })] } Motion::LeftBefore(ch) => { vi_state.last_char_search = Some(ViCharSearch::TillLeft(*ch)); vec![ReedlineOption::Edit(EditCommand::MoveLeftBefore { c: *ch, - select: false, + select: select_mode, })] } Motion::ReplayCharSearch => { if let Some(char_search) = vi_state.last_char_search.as_ref() { - vec![ReedlineOption::Edit(char_search.to_move())] + vec![ReedlineOption::Edit(char_search.to_move(select_mode))] } else { vec![] } } Motion::ReverseCharSearch => { if let Some(char_search) = vi_state.last_char_search.as_ref() { - vec![ReedlineOption::Edit(char_search.reverse().to_move())] + vec![ReedlineOption::Edit( + char_search.reverse().to_move(select_mode), + )] } else { vec![] } @@ -257,23 +266,23 @@ impl ViCharSearch { } } - pub fn to_move(&self) -> EditCommand { + pub fn to_move(&self, select_mode: bool) -> EditCommand { match self { ViCharSearch::ToRight(c) => EditCommand::MoveRightUntil { c: *c, - select: false, + select: select_mode, }, ViCharSearch::ToLeft(c) => EditCommand::MoveLeftUntil { c: *c, - select: false, + select: select_mode, }, ViCharSearch::TillRight(c) => EditCommand::MoveRightBefore { c: *c, - select: false, + select: select_mode, }, ViCharSearch::TillLeft(c) => EditCommand::MoveLeftBefore { c: *c, - select: false, + select: select_mode, }, } } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index 777d7cba..d3d9289d 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -1,6 +1,6 @@ use super::command::{parse_command, Command}; use super::motion::{parse_motion, Motion}; -use crate::{EditCommand, ReedlineEvent, Vi}; +use crate::{edit_mode::vi::ViMode, EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; #[derive(Debug, Clone)] @@ -50,11 +50,16 @@ impl ParsedViSequence { !self.motion.is_invalid() } - pub fn is_complete(&self) -> bool { + pub fn is_complete(&self, mode: ViMode) -> bool { + assert!(mode == ViMode::Normal || mode == ViMode::Visual); match (&self.command, &self.motion) { (None, ParseResult::Valid(_)) => true, (Some(Command::Incomplete), _) => false, - (Some(cmd), ParseResult::Incomplete) if !cmd.requires_motion() => true, + (Some(cmd), ParseResult::Incomplete) + if !cmd.requires_motion() || mode == ViMode::Visual => + { + true + } (Some(_), ParseResult::Valid(_)) => true, (Some(cmd), ParseResult::Incomplete) if cmd.requires_motion() => false, _ => false, @@ -91,22 +96,20 @@ impl ParsedViSequence { } } - pub fn enters_insert_mode(&self) -> bool { - matches!( - (&self.command, &self.motion), + pub fn changes_mode(&self) -> Option { + match (&self.command, &self.motion) { (Some(Command::EnterViInsert), ParseResult::Incomplete) - | (Some(Command::EnterViAppend), ParseResult::Incomplete) - | (Some(Command::ChangeToLineEnd), ParseResult::Incomplete) - | (Some(Command::AppendToEnd), ParseResult::Incomplete) - | (Some(Command::PrependToStart), ParseResult::Incomplete) - | (Some(Command::RewriteCurrentLine), ParseResult::Incomplete) - | ( - Some(Command::SubstituteCharWithInsert), - ParseResult::Incomplete - ) - | (Some(Command::HistorySearch), ParseResult::Incomplete) - | (Some(Command::Change), ParseResult::Valid(_)) - ) + | (Some(Command::EnterViAppend), ParseResult::Incomplete) + | (Some(Command::ChangeToLineEnd), ParseResult::Incomplete) + | (Some(Command::AppendToEnd), ParseResult::Incomplete) + | (Some(Command::PrependToStart), ParseResult::Incomplete) + | (Some(Command::RewriteCurrentLine), ParseResult::Incomplete) + | (Some(Command::SubstituteCharWithInsert), ParseResult::Incomplete) + | (Some(Command::HistorySearch), ParseResult::Incomplete) + | (Some(Command::Change), ParseResult::Valid(_)) => Some(ViMode::Insert), + (Some(Command::Delete), ParseResult::Incomplete) => Some(ViMode::Normal), + _ => None, + } } pub fn to_reedline_event(&self, vi_state: &mut Vi) -> ReedlineEvent { @@ -188,6 +191,25 @@ mod tests { parse(&mut input.iter().peekable()) } + #[test] + fn test_delete_without_motion() { + let input = ['d']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::Delete), + count: None, + motion: ParseResult::Incomplete, + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(ViMode::Normal), false); + assert_eq!(output.is_complete(ViMode::Visual), true); + } + #[test] fn test_delete_word() { let input = ['d', 'w']; @@ -203,7 +225,29 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); + } + + #[test] + fn test_two_delete_without_motion() { + let input = ['2', 'd']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: None, + motion: ParseResult::Incomplete, + } + ); + assert_eq!(output.is_valid(), true); + // in visual mode vim ignores the multiplier, + // so we can accept this as valid even there + assert_eq!(output.is_complete(ViMode::Normal), false); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -221,7 +265,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -239,7 +284,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -257,7 +303,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -275,7 +322,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -293,7 +341,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -329,7 +378,8 @@ mod tests { ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), false); + assert_eq!(output.is_complete(ViMode::Normal), false); + assert_eq!(output.is_complete(ViMode::Visual), false); } #[test] @@ -347,7 +397,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), false); + assert_eq!(output.is_complete(ViMode::Normal), false); + assert_eq!(output.is_complete(ViMode::Visual), false); } #[test] @@ -366,7 +417,8 @@ mod tests { ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -384,7 +436,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[test] @@ -402,7 +455,8 @@ mod tests { } ); assert_eq!(output.is_valid(), true); - assert_eq!(output.is_complete(), true); + assert_eq!(output.is_complete(ViMode::Normal), true); + assert_eq!(output.is_complete(ViMode::Visual), true); } #[rstest] @@ -425,16 +479,16 @@ mod tests { ReedlineEvent::UntilFound(vec![ ReedlineEvent::HistoryHintComplete, ReedlineEvent::MenuRight, - ReedlineEvent::Right, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:false}]), ]),ReedlineEvent::UntilFound(vec![ ReedlineEvent::HistoryHintComplete, ReedlineEvent::MenuRight, - ReedlineEvent::Right, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:false}]), ]) ]))] #[case(&['l'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ ReedlineEvent::HistoryHintComplete, ReedlineEvent::MenuRight, - ReedlineEvent::Right, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:false}]), ])]))] #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart{select:false}])]))] #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd{select:false}])]))] @@ -463,4 +517,61 @@ mod tests { assert_eq!(output, expected); } + + #[rstest] + #[case(&['2', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ]), ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['w'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveWordRightStart{select:true}])]))] + #[case(&['W'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveBigWordRightStart{select:true}])]))] + #[case(&['2', 'l'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:true}]), + ]),ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:true}]), + ]) ]))] + #[case(&['l'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Edit(vec![EditCommand::MoveRight{select:true}]), + ])]))] + #[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(&['2', 'p'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ]))] + #[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]) + ]))] + #[case(&['d'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutSelection])]))] + fn test_reedline_move_in_visual_mode(#[case] input: &[char], #[case] expected: ReedlineEvent) { + let mut vi = Vi { + mode: ViMode::Visual, + ..Default::default() + }; + let res = vi_parse(input); + let output = res.to_reedline_event(&mut vi); + + assert_eq!(output, expected); + } }