From 1eaaa6cf035b4277026f9c17ef5a60830959f9dc Mon Sep 17 00:00:00 2001 From: Matei Stroia Date: Wed, 27 May 2026 13:30:40 +0300 Subject: [PATCH] feat(vi): visual mode `u`, `U` --- src/core_editor/editor.rs | 58 +++++++++++++++++++++++++++++++++++++ src/edit_mode/vi/command.rs | 26 +++++++++++++++-- src/edit_mode/vi/parser.rs | 7 ++++- src/enums.rs | 12 ++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba8..7dbcdefe3 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -140,6 +140,9 @@ impl Editor { EditCommand::SelectAll => self.select_all(), EditCommand::CutSelection => self.cut_selection_to_cut_buffer(), EditCommand::CopySelection => self.copy_selection_to_cut_buffer(), + EditCommand::LowercaseSelection => self.lowercase_selection(), + EditCommand::UppercaseSelection => self.uppercase_selection(), + EditCommand::SwitchcaseSelection => self.switchcase_selection(), EditCommand::Paste => self.paste_cut_buffer(), EditCommand::CopyFromStart => self.copy_from_start(), EditCommand::CopyFromStartLinewise => self.copy_from_start_linewise(), @@ -674,6 +677,37 @@ impl Editor { } } + fn lowercase_selection(&mut self) { + if let Some((start, end)) = self.get_selection() { + let lowercase_slice = self.line_buffer.get_buffer()[start..end].to_ascii_lowercase(); + self.line_buffer.replace_range(start..end, &lowercase_slice); + } + } + + fn uppercase_selection(&mut self) { + if let Some((start, end)) = self.get_selection() { + let uppercase_slice = self.line_buffer.get_buffer()[start..end].to_ascii_uppercase(); + self.line_buffer.replace_range(start..end, &uppercase_slice); + } + } + + fn switchcase_selection(&mut self) { + if let Some((start, end)) = self.get_selection() { + let switchcase_slice = self.line_buffer.get_buffer()[start..end] + .chars() + .map(|ch| { + if ch.is_ascii_lowercase() { + ch.to_ascii_uppercase() + } else { + ch.to_ascii_lowercase() + } + }) + .collect::(); + self.line_buffer + .replace_range(start..end, &switchcase_slice); + } + } + /// If a selection is active returns the selected range, otherwise None. /// The range is guaranteed to be ascending. pub fn get_selection(&self) -> Option<(usize, usize)> { @@ -1926,6 +1960,30 @@ mod test { assert_eq!(editor.cut_buffer.get().0, expected_cut); } + #[rstest] + #[case("foo bar baz", 0, 3, EditCommand::LowercaseSelection, "foo bar baz")] + #[case("Foo Bar Baz", 0, 7, EditCommand::LowercaseSelection, "foo bar Baz")] + #[case("FOO BAR BAZ", 0, 12, EditCommand::LowercaseSelection, "foo bar baz")] + #[case("foo bar baz", 0, 3, EditCommand::UppercaseSelection, "FOO bar baz")] + #[case("Foo Bar Baz", 0, 7, EditCommand::UppercaseSelection, "FOO BAR Baz")] + #[case("FOO BAR BAZ", 0, 12, EditCommand::UppercaseSelection, "FOO BAR BAZ")] + #[case("foo bar baz", 0, 3, EditCommand::SwitchcaseSelection, "FOO bar baz")] + #[case("Foo Bar Baz", 0, 7, EditCommand::SwitchcaseSelection, "fOO bAR Baz")] + #[case("FOO BAR BAZ", 0, 12, EditCommand::SwitchcaseSelection, "foo bar baz")] + fn test_lower_upper_switchcase_selection( + #[case] input: &str, + #[case] selection_start: usize, + #[case] selection_end: usize, + #[case] command: EditCommand, + #[case] expected_buffer: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(selection_start, false); + editor.move_to_position(selection_end, true); + editor.run_edit_command(&command); + assert_eq!(editor.get_buffer(), expected_buffer); + } + #[rstest] #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "-world test", "hello")] // small word gets just "hello" #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, " test", "hello-world")] // big word gets "hello-word" diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 18999e5c7..54f1fc0df 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -78,7 +78,7 @@ where let _ = input.next(); Some(Command::EnterViAppend) } - Some('u') => { + Some('u') if mode == ViMode::Normal => { let _ = input.next(); Some(Command::Undo) } @@ -168,6 +168,14 @@ where // This arm should be unreachable ViMode::Insert => None, }, + Some(&&u @ ('u' | 'U')) if mode == ViMode::Visual => { + let _ = input.next(); + if u.is_ascii_lowercase() { + Some(Command::Lowercase) + } else { + Some(Command::Uppercase) + } + } _ => None, } } @@ -193,6 +201,8 @@ pub enum Command { RewriteCurrentLine, Change, HistorySearch, + Lowercase, + Uppercase, Switchcase, RepeatLastAction, Yank, @@ -260,7 +270,19 @@ impl Command { } } Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], - Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], + Self::Lowercase => { + vec![ReedlineOption::Edit(EditCommand::LowercaseSelection)] + } + Self::Uppercase => { + vec![ReedlineOption::Edit(EditCommand::UppercaseSelection)] + } + Self::Switchcase => { + if vi_state.mode == ViMode::Visual { + vec![ReedlineOption::Edit(EditCommand::SwitchcaseSelection)] + } else { + 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)], diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index da0839cd0..b3cd05c6f 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -112,7 +112,12 @@ impl ParsedViSequence { (Some(Command::Change), ParseResult::Incomplete) if mode == ViMode::Visual => { Some(ViMode::Insert) } - (Some(Command::Delete), ParseResult::Incomplete) if mode == ViMode::Visual => { + (Some(Command::Delete), ParseResult::Incomplete) + | (Some(Command::Lowercase), ParseResult::Incomplete) + | (Some(Command::Uppercase), ParseResult::Incomplete) + | (Some(Command::Switchcase), ParseResult::Incomplete) + if mode == ViMode::Visual => + { Some(ViMode::Normal) } (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), diff --git a/src/enums.rs b/src/enums.rs index 160c9e256..d1cdbb33a 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -390,6 +390,15 @@ pub enum EditCommand { /// Copy selection to local buffer CopySelection, + /// LowercaseSelection + LowercaseSelection, + + /// Uppercase selection + UppercaseSelection, + + /// Switchcase selection + SwitchcaseSelection, + /// Paste content from local buffer at the current cursor position Paste, @@ -583,6 +592,9 @@ impl EditCommand { | EditCommand::CutLeftUntil(_) | EditCommand::CutLeftBefore(_) | EditCommand::CutSelection + | EditCommand::LowercaseSelection + | EditCommand::UppercaseSelection + | EditCommand::SwitchcaseSelection | EditCommand::Paste | EditCommand::CutInsidePair { .. } | EditCommand::CutAroundPair { .. }