diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 02c09b33af..fc54934f2b 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -103,6 +103,7 @@ ], "v": "vim::ToggleVisual", "shift-v": "vim::ToggleVisualLine", + "ctrl-v": "vim::ToggleVisualBlock", "*": "vim::MoveToNext", "#": "vim::MoveToPrev", "0": "vim::StartOfLine", // When no number operator present, use start of line motion diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 1921bc0738..6a21c898ef 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,7 +1,7 @@ use std::{ cell::Ref, cmp, iter, mem, - ops::{Deref, Range, Sub}, + ops::{Deref, DerefMut, Range, Sub}, sync::Arc, }; @@ -53,7 +53,7 @@ impl SelectionsCollection { } } - fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot { + pub fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot { self.display_map.update(cx, |map, cx| map.snapshot(cx)) } @@ -250,6 +250,10 @@ impl SelectionsCollection { resolve(self.oldest_anchor(), &self.buffer(cx)) } + pub fn first_anchor(&self) -> Selection { + self.disjoint[0].clone() + } + pub fn first>( &self, cx: &AppContext, @@ -352,7 +356,7 @@ pub struct MutableSelectionsCollection<'a> { } impl<'a> MutableSelectionsCollection<'a> { - fn display_map(&mut self) -> DisplaySnapshot { + pub fn display_map(&mut self) -> DisplaySnapshot { self.collection.display_map(self.cx) } @@ -607,6 +611,10 @@ impl<'a> MutableSelectionsCollection<'a> { self.select_anchors(selections) } + pub fn new_selection_id(&mut self) -> usize { + post_inc(&mut self.next_selection_id) + } + pub fn select_display_ranges(&mut self, ranges: T) where T: IntoIterator>, @@ -831,6 +839,12 @@ impl<'a> Deref for MutableSelectionsCollection<'a> { } } +impl<'a> DerefMut for MutableSelectionsCollection<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.collection + } +} + // Panics if passed selections are not in order pub fn resolve_multiple<'a, D, I>( selections: I, diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index e1e21e4e3b..37476caed5 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -72,6 +72,18 @@ fn object(object: Object, cx: &mut WindowContext) { } impl Object { + pub fn is_multiline(self) -> bool { + match self { + Object::Word { .. } | Object::Quotes | Object::BackQuotes | Object::DoubleQuotes => { + false + } + Object::Sentence + | Object::Parentheses + | Object::AngleBrackets + | Object::CurlyBrackets + | Object::SquareBrackets => true, + } + } pub fn range( self, map: &DisplaySnapshot, @@ -87,13 +99,27 @@ impl Object { } } Object::Sentence => sentence(map, relative_to, around), - Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''), - Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'), - Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'), - Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'), - Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'), - Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'), - Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'), + Object::Quotes => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'') + } + Object::BackQuotes => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`') + } + Object::DoubleQuotes => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"') + } + Object::Parentheses => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')') + } + Object::SquareBrackets => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']') + } + Object::CurlyBrackets => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}') + } + Object::AngleBrackets => { + surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>') + } } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b38dac4aa8..66aaec02b9 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -44,6 +44,7 @@ pub enum Operator { #[derive(Default)] pub struct VimState { pub mode: Mode, + pub last_mode: Mode, pub operator_stack: Vec, pub search: SearchState, diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 1c7559e440..263692b36e 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -160,7 +160,7 @@ impl<'a> NeovimBackedTestContext<'a> { pub async fn neovim_state(&mut self) -> String { generate_marked_text( self.neovim.text().await.as_str(), - &vec![self.neovim_selection().await], + &self.neovim_selections().await[..], true, ) } @@ -169,9 +169,12 @@ impl<'a> NeovimBackedTestContext<'a> { self.neovim.mode().await.unwrap() } - async fn neovim_selection(&mut self) -> Range { - let neovim_selection = self.neovim.selection().await; - neovim_selection.to_offset(&self.buffer_snapshot()) + async fn neovim_selections(&mut self) -> Vec> { + let neovim_selections = self.neovim.selections().await; + neovim_selections + .into_iter() + .map(|selection| selection.to_offset(&self.buffer_snapshot())) + .collect() } pub async fn assert_state_matches(&mut self) { diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index e983d5ceec..ddeb26164b 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -1,5 +1,8 @@ #[cfg(feature = "neovim")] -use std::ops::{Deref, DerefMut}; +use std::{ + cmp, + ops::{Deref, DerefMut}, +}; use std::{ops::Range, path::PathBuf}; #[cfg(feature = "neovim")] @@ -135,7 +138,7 @@ impl NeovimConnection { #[cfg(feature = "neovim")] pub async fn set_state(&mut self, marked_text: &str) { - let (text, selection) = parse_state(&marked_text); + let (text, selections) = parse_state(&marked_text); let nvim_buffer = self .nvim @@ -167,6 +170,11 @@ impl NeovimConnection { .await .expect("Could not get neovim window"); + if selections.len() != 1 { + panic!("must have one selection"); + } + let selection = &selections[0]; + let cursor = selection.start; nvim_window .set_cursor((cursor.row as i64 + 1, cursor.column as i64)) @@ -224,7 +232,7 @@ impl NeovimConnection { } #[cfg(feature = "neovim")] - pub async fn state(&mut self) -> (Option, String, Range) { + pub async fn state(&mut self) -> (Option, String, Vec>) { let nvim_buffer = self .nvim .get_current_buf() @@ -263,14 +271,48 @@ impl NeovimConnection { "n" => Some(Mode::Normal), "v" => Some(Mode::Visual), "V" => Some(Mode::VisualLine), - "CTRL-V" => Some(Mode::VisualBlock), + "\x16" => Some(Mode::VisualBlock), _ => None, }; + let mut selections = Vec::new(); // Vim uses the index of the first and last character in the selection // Zed uses the index of the positions between the characters, so we need // to add one to the end in visual mode. match mode { + Some(Mode::VisualBlock) if selection_row != cursor_row => { + // in zed we fake a block selecrtion by using multiple cursors (one per line) + // this code emulates that. + // to deal with casees where the selection is not perfectly rectangular we extract + // the content of the selection via the "a register to get the shape correctly. + self.nvim.input("\"aygv").await.unwrap(); + let content = self.nvim.command_output("echo getreg('a')").await.unwrap(); + let lines = content.split("\n").collect::>(); + let top = cmp::min(selection_row, cursor_row); + let left = cmp::min(selection_col, cursor_col); + for row in top..=cmp::max(selection_row, cursor_row) { + let content = if row - top >= lines.len() as u32 { + "" + } else { + lines[(row - top) as usize] + }; + let line_len = self + .read_position(format!("echo strlen(getline({}))", row + 1).as_str()) + .await; + + if left > line_len { + continue; + } + + let start = Point::new(row, left); + let end = Point::new(row, left + content.len() as u32); + if cursor_col >= selection_col { + selections.push(start..end) + } else { + selections.push(end..start) + } + } + } Some(Mode::Visual) | Some(Mode::VisualLine) | Some(Mode::VisualBlock) => { if selection_col > cursor_col { let selection_line_length = @@ -291,38 +333,37 @@ impl NeovimConnection { cursor_row += 1; } } + selections.push( + Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col), + ) } - Some(Mode::Insert) | Some(Mode::Normal) | None => {} + Some(Mode::Insert) | Some(Mode::Normal) | None => selections + .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)), } - let (start, end) = ( - Point::new(selection_row, selection_col), - Point::new(cursor_row, cursor_col), - ); - let state = NeovimData::Get { mode, - state: encode_range(&text, start..end), + state: encode_ranges(&text, &selections), }; if self.data.back() != Some(&state) { self.data.push_back(state.clone()); } - (mode, text, start..end) + (mode, text, selections) } #[cfg(not(feature = "neovim"))] - pub async fn state(&mut self) -> (Option, String, Range) { + pub async fn state(&mut self) -> (Option, String, Vec>) { if let Some(NeovimData::Get { state: text, mode }) = self.data.front() { - let (text, range) = parse_state(text); - (*mode, text, range) + let (text, ranges) = parse_state(text); + (*mode, text, ranges) } else { panic!("operation does not match recorded script. re-record with --features=neovim"); } } - pub async fn selection(&mut self) -> Range { + pub async fn selections(&mut self) -> Vec> { self.state().await.2 } @@ -422,51 +463,63 @@ impl Handler for NvimHandler { } } -fn parse_state(marked_text: &str) -> (String, Range) { +fn parse_state(marked_text: &str) -> (String, Vec>) { let (text, ranges) = util::test::marked_text_ranges(marked_text, true); - let byte_range = ranges[0].clone(); - let mut point_range = Point::zero()..Point::zero(); - let mut ix = 0; - let mut position = Point::zero(); - for c in text.chars().chain(['\0']) { - if ix == byte_range.start { - point_range.start = position; - } - if ix == byte_range.end { - point_range.end = position; - } - let len_utf8 = c.len_utf8(); - ix += len_utf8; - if c == '\n' { - position.row += 1; - position.column = 0; - } else { - position.column += len_utf8 as u32; - } - } - (text, point_range) + let point_ranges = ranges + .into_iter() + .map(|byte_range| { + let mut point_range = Point::zero()..Point::zero(); + let mut ix = 0; + let mut position = Point::zero(); + for c in text.chars().chain(['\0']) { + if ix == byte_range.start { + point_range.start = position; + } + if ix == byte_range.end { + point_range.end = position; + } + let len_utf8 = c.len_utf8(); + ix += len_utf8; + if c == '\n' { + position.row += 1; + position.column = 0; + } else { + position.column += len_utf8 as u32; + } + } + point_range + }) + .collect::>(); + (text, point_ranges) } #[cfg(feature = "neovim")] -fn encode_range(text: &str, range: Range) -> String { - let mut byte_range = 0..0; - let mut ix = 0; - let mut position = Point::zero(); - for c in text.chars().chain(['\0']) { - if position == range.start { - byte_range.start = ix; - } - if position == range.end { - byte_range.end = ix; - } - let len_utf8 = c.len_utf8(); - ix += len_utf8; - if c == '\n' { - position.row += 1; - position.column = 0; - } else { - position.column += len_utf8 as u32; - } - } - util::test::generate_marked_text(text, &[byte_range], true) +fn encode_ranges(text: &str, point_ranges: &Vec>) -> String { + let byte_ranges = point_ranges + .into_iter() + .map(|range| { + let mut byte_range = 0..0; + let mut ix = 0; + let mut position = Point::zero(); + for c in text.chars().chain(['\0']) { + if position == range.start { + byte_range.start = ix; + } + if position == range.end { + byte_range.end = ix; + } + let len_utf8 = c.len_utf8(); + ix += len_utf8; + if c == '\n' { + position.row += 1; + position.column = 0; + } else { + position.column += len_utf8 as u32; + } + } + byte_range + }) + .collect::>(); + let ret = util::test::generate_marked_text(text, &byte_ranges[..], true); + ret } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 038e47659d..df35e951d2 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -26,7 +26,7 @@ use serde::Deserialize; use settings::{Setting, SettingsStore}; use state::{Mode, Operator, VimState}; use std::sync::Arc; -use visual::visual_replace; +use visual::{visual_block_motion, visual_replace}; use workspace::{self, Workspace}; struct VimModeSetting(bool); @@ -182,6 +182,8 @@ impl Vim { fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) { let last_mode = self.state.mode; + let prior_mode = self.state.last_mode; + self.state.last_mode = last_mode; self.state.mode = mode; self.state.operator_stack.clear(); @@ -196,7 +198,27 @@ impl Vim { // Adjust selections self.update_active_editor(cx, |editor, cx| { + if last_mode != Mode::VisualBlock && last_mode.is_visual() && mode == Mode::VisualBlock + { + visual_block_motion(true, editor, cx, |_, point, goal| Some((point, goal))) + } + editor.change_selections(None, cx, |s| { + // we cheat with visual block mode and use multiple cursors. + // the cost of this cheat is we need to convert back to a single + // cursor whenever vim would. + if last_mode == Mode::VisualBlock && mode != Mode::VisualBlock { + let tail = s.oldest_anchor().tail(); + let head = s.newest_anchor().head(); + s.select_anchor_ranges(vec![tail..head]); + } else if last_mode == Mode::Insert + && prior_mode == Mode::VisualBlock + && mode != Mode::VisualBlock + { + let pos = s.first_anchor().head(); + s.select_anchor_ranges(vec![pos..pos]) + } + s.move_with(|map, selection| { if last_mode.is_visual() && !mode.is_visual() { let mut point = selection.head(); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index e5f9d8c459..cb4d865dc9 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -2,10 +2,13 @@ use std::{borrow::Cow, sync::Arc}; use collections::HashMap; use editor::{ - display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection, + display_map::{DisplaySnapshot, ToDisplayPoint}, + movement, + scroll::autoscroll::Autoscroll, + Bias, ClipboardSelection, DisplayPoint, Editor, }; use gpui::{actions, AppContext, ViewContext, WindowContext}; -use language::{AutoindentMode, SelectionGoal}; +use language::{AutoindentMode, Selection, SelectionGoal}; use workspace::Workspace; use crate::{ @@ -21,6 +24,7 @@ actions!( [ ToggleVisual, ToggleVisualLine, + ToggleVisualBlock, VisualDelete, VisualYank, VisualPaste, @@ -29,8 +33,17 @@ actions!( ); pub fn init(cx: &mut AppContext) { - cx.add_action(toggle_visual); - cx.add_action(toggle_visual_line); + cx.add_action(|_, _: &ToggleVisual, cx: &mut ViewContext| { + toggle_mode(Mode::Visual, cx) + }); + cx.add_action(|_, _: &ToggleVisualLine, cx: &mut ViewContext| { + toggle_mode(Mode::VisualLine, cx) + }); + cx.add_action( + |_, _: &ToggleVisualBlock, cx: &mut ViewContext| { + toggle_mode(Mode::VisualBlock, cx) + }, + ); cx.add_action(other_end); cx.add_action(delete); cx.add_action(yank); @@ -40,55 +53,169 @@ pub fn init(cx: &mut AppContext) { pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { - editor.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_with(|map, selection| { - let was_reversed = selection.reversed; + if vim.state.mode == Mode::VisualBlock && !matches!(motion, Motion::EndOfLine) { + let is_up_or_down = matches!(motion, Motion::Up | Motion::Down); + visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| { + motion.move_point(map, point, goal, times) + }) + } else { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let was_reversed = selection.reversed; + let mut current_head = selection.head(); - let mut current_head = selection.head(); + // our motions assume the current character is after the cursor, + // but in (forward) visual mode the current character is just + // before the end of the selection. - // our motions assume the current character is after the cursor, - // but in (forward) visual mode the current character is just - // before the end of the selection. + // If the file ends with a newline (which is common) we don't do this. + // so that if you go to the end of such a file you can use "up" to go + // to the previous line and have it work somewhat as expected. + if !selection.reversed + && !selection.is_empty() + && !(selection.end.column() == 0 && selection.end == map.max_point()) + { + current_head = movement::left(map, selection.end) + } - // If the file ends with a newline (which is common) we don't do this. - // so that if you go to the end of such a file you can use "up" to go - // to the previous line and have it work somewhat as expected. - if !selection.reversed - && !selection.is_empty() - && !(selection.end.column() == 0 && selection.end == map.max_point()) - { - current_head = movement::left(map, selection.end) - } - - let Some((new_head, goal)) = + let Some((new_head, goal)) = motion.move_point(map, current_head, selection.goal, times) else { return }; - selection.set_head(new_head, goal); + selection.set_head(new_head, goal); - // ensure the current character is included in the selection. - if !selection.reversed { - // TODO: maybe try clipping left for multi-buffers - let next_point = movement::right(map, selection.end); + // ensure the current character is included in the selection. + if !selection.reversed { + let next_point = if vim.state.mode == Mode::VisualBlock { + movement::saturating_right(map, selection.end) + } else { + movement::right(map, selection.end) + }; - if !(next_point.column() == 0 && next_point == map.max_point()) { - selection.end = movement::right(map, selection.end) + if !(next_point.column() == 0 && next_point == map.max_point()) { + selection.end = next_point; + } } - } - // vim always ensures the anchor character stays selected. - // if our selection has reversed, we need to move the opposite end - // to ensure the anchor is still selected. - if was_reversed && !selection.reversed { - selection.start = movement::left(map, selection.start); - } else if !was_reversed && selection.reversed { - selection.end = movement::right(map, selection.end); - } + // vim always ensures the anchor character stays selected. + // if our selection has reversed, we need to move the opposite end + // to ensure the anchor is still selected. + if was_reversed && !selection.reversed { + selection.start = movement::left(map, selection.start); + } else if !was_reversed && selection.reversed { + selection.end = movement::right(map, selection.end); + } + }) }); - }); + } }); }); } +pub fn visual_block_motion( + preserve_goal: bool, + editor: &mut Editor, + cx: &mut ViewContext, + mut move_selection: impl FnMut( + &DisplaySnapshot, + DisplayPoint, + SelectionGoal, + ) -> Option<(DisplayPoint, SelectionGoal)>, +) { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + let map = &s.display_map(); + let mut head = s.newest_anchor().head().to_display_point(map); + let mut tail = s.oldest_anchor().tail().to_display_point(map); + let mut goal = s.newest_anchor().goal; + + let was_reversed = tail.column() > head.column(); + + if !was_reversed && !(head.column() == 0 && head == map.max_point()) { + head = movement::saturating_left(map, head); + } + + let Some((new_head, new_goal)) = move_selection(&map, head, goal) else { + return + }; + head = new_head; + if goal == SelectionGoal::None { + goal = new_goal; + } + + let mut is_reversed = tail.column() > head.column(); + if was_reversed && !is_reversed { + tail = movement::left(map, tail) + } else if !was_reversed && is_reversed { + tail = movement::right(map, tail) + } + if !is_reversed { + head = movement::saturating_right(map, head) + } + + if !preserve_goal + || !matches!( + goal, + SelectionGoal::ColumnRange { .. } | SelectionGoal::Column(_) + ) + { + goal = SelectionGoal::ColumnRange { + start: tail.column(), + end: head.column(), + } + } + + let mut columns = if let SelectionGoal::ColumnRange { start, end } = goal { + if start > end { + is_reversed = true; + end..start + } else { + is_reversed = false; + start..end + } + } else if let SelectionGoal::Column(column) = goal { + is_reversed = false; + column..(column + 1) + } else { + unreachable!() + }; + + if columns.start >= map.line_len(head.row()) { + columns.start = map.line_len(head.row()).saturating_sub(1); + } + if columns.start >= map.line_len(tail.row()) { + columns.start = map.line_len(tail.row()).saturating_sub(1); + } + + let mut selections = Vec::new(); + let mut row = tail.row(); + + loop { + let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left); + let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left); + if columns.start <= map.line_len(row) { + let mut selection = Selection { + id: s.new_selection_id(), + start: start.to_point(map), + end: end.to_point(map), + reversed: is_reversed, + goal: goal.clone(), + }; + + selections.push(selection); + } + if row == head.row() { + break; + } + if tail.row() > head.row() { + row -= 1 + } else { + row += 1 + } + } + + s.select(selections); + }) +} + pub fn visual_object(object: Object, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { if let Some(Operator::Object { around }) = vim.active_operator() { @@ -136,28 +263,12 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) { }); } -pub fn toggle_visual(_: &mut Workspace, _: &ToggleVisual, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| match vim.state.mode { - Mode::Normal | Mode::Insert | Mode::VisualLine | Mode::VisualBlock => { - vim.switch_mode(Mode::Visual, false, cx); - } - Mode::Visual => { - vim.switch_mode(Mode::Normal, false, cx); - } - }) -} - -pub fn toggle_visual_line( - _: &mut Workspace, - _: &ToggleVisualLine, - cx: &mut ViewContext, -) { - Vim::update(cx, |vim, cx| match vim.state.mode { - Mode::Normal | Mode::Insert | Mode::Visual | Mode::VisualBlock => { - vim.switch_mode(Mode::VisualLine, false, cx); - } - Mode::VisualLine => { +fn toggle_mode(mode: Mode, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + if vim.state.mode == mode { vim.switch_mode(Mode::Normal, false, cx); + } else { + vim.switch_mode(mode, false, cx); } }) } @@ -207,6 +318,9 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) s.move_with(|_, selection| { selection.collapse_to(selection.start, SelectionGoal::None) }); + if vim.state.mode == Mode::VisualBlock { + s.select_anchors(vec![s.first_anchor()]) + } }); }); vim.switch_mode(Mode::Normal, true, cx); @@ -275,7 +392,7 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext linewise = all_selections_were_entire_line; } - let mut selection = selection.clone(); + let selection = selection.clone(); if !selection.reversed { let adjusted = selection.end; // If the selection is empty, move both the start and end forward one @@ -751,4 +868,119 @@ mod test { Mode::Normal, ); } + + #[gpui::test] + async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "The ˇquick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v"]).await; + cx.assert_shared_state(indoc! { + "The «qˇ»uick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["2", "down"]).await; + cx.assert_shared_state(indoc! { + "The «qˇ»uick brown + fox «jˇ»umps over + the «lˇ»azy dog" + }) + .await; + cx.simulate_shared_keystrokes(["e"]).await; + cx.assert_shared_state(indoc! { + "The «quicˇ»k brown + fox «jumpˇ»s over + the «lazyˇ» dog" + }) + .await; + cx.simulate_shared_keystrokes(["^"]).await; + cx.assert_shared_state(indoc! { + "«ˇThe q»uick brown + «ˇfox j»umps over + «ˇthe l»azy dog" + }) + .await; + cx.simulate_shared_keystrokes(["$"]).await; + cx.assert_shared_state(indoc! { + "The «quick brownˇ» + fox «jumps overˇ» + the «lazy dogˇ»" + }) + .await; + cx.simulate_shared_keystrokes(["shift-f", " "]).await; + cx.assert_shared_state(indoc! { + "The «quickˇ» brown + fox «jumpsˇ» over + the «lazy ˇ»dog" + }) + .await; + + // toggling through visual mode works as expected + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state(indoc! { + "The «quick brown + fox jumps over + the lazy ˇ»dog" + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v"]).await; + cx.assert_shared_state(indoc! { + "The «quickˇ» brown + fox «jumpsˇ» over + the «lazy ˇ»dog" + }) + .await; + + cx.set_shared_state(indoc! { + "The ˇquick + brown + fox + jumps over the + + lazy dog + " + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v", "down", "down", "down"]) + .await; + cx.assert_shared_state(indoc! { + "The «qˇ»uick + brow«nˇ» + fox + jump«sˇ» over the + + lazy dog + " + }) + .await; + cx.simulate_shared_keystroke("left").await; + cx.assert_shared_state(indoc! { + "The«ˇ q»uick + bro«ˇwn» + foxˇ + jum«ˇps» over the + + lazy dog + " + }) + .await; + cx.simulate_shared_keystrokes(["s", "o", "escape"]).await; + cx.assert_shared_state(indoc! { + "Theˇouick + broo + foxo + jumo over the + + lazy dog + " + }) + .await; + } } diff --git a/crates/vim/test_data/test_visual_block_mode.json b/crates/vim/test_data/test_visual_block_mode.json new file mode 100644 index 0000000000..743f7fa76c --- /dev/null +++ b/crates/vim/test_data/test_visual_block_mode.json @@ -0,0 +1,31 @@ +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"ctrl-v"} +{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"VisualBlock"}} +{"Key":"2"} +{"Key":"down"} +{"Get":{"state":"The «qˇ»uick brown\nfox «jˇ»umps over\nthe «lˇ»azy dog","mode":"VisualBlock"}} +{"Key":"e"} +{"Get":{"state":"The «quicˇ»k brown\nfox «jumpˇ»s over\nthe «lazyˇ» dog","mode":"VisualBlock"}} +{"Key":"^"} +{"Get":{"state":"«ˇThe q»uick brown\n«ˇfox j»umps over\n«ˇthe l»azy dog","mode":"VisualBlock"}} +{"Key":"$"} +{"Get":{"state":"The «quick brownˇ»\nfox «jumps overˇ»\nthe «lazy dogˇ»","mode":"VisualBlock"}} +{"Key":"shift-f"} +{"Key":" "} +{"Get":{"state":"The «quickˇ» brown\nfox «jumpsˇ» over\nthe «lazy ˇ»dog","mode":"VisualBlock"}} +{"Key":"v"} +{"Get":{"state":"The «quick brown\nfox jumps over\nthe lazy ˇ»dog","mode":"Visual"}} +{"Key":"ctrl-v"} +{"Get":{"state":"The «quickˇ» brown\nfox «jumpsˇ» over\nthe «lazy ˇ»dog","mode":"VisualBlock"}} +{"Put":{"state":"The ˇquick\nbrown\nfox\njumps over the\n\nlazy dog\n"}} +{"Key":"ctrl-v"} +{"Key":"down"} +{"Key":"down"} +{"Key":"down"} +{"Get":{"state":"The «qˇ»uick\nbrow«nˇ»\nfox\njump«sˇ» over the\n\nlazy dog\n","mode":"VisualBlock"}} +{"Key":"left"} +{"Get":{"state":"The«ˇ q»uick\nbro«ˇwn»\nfoxˇ\njum«ˇps» over the\n\nlazy dog\n","mode":"VisualBlock"}} +{"Key":"s"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"Theˇouick\nbroo\nfoxo\njumo over the\n\nlazy dog\n","mode":"Normal"}}