use std::sync::Arc; use collections::HashMap; use editor::{ Bias, DisplayPoint, Editor, display_map::{DisplaySnapshot, ToDisplayPoint}, movement, scroll::Autoscroll, }; use gpui::{Context, Window, actions}; use language::{Point, Selection, SelectionGoal}; use multi_buffer::MultiBufferRow; use search::BufferSearchBar; use util::ResultExt; use workspace::searchable::Direction; use crate::{ Vim, motion::{Motion, MotionKind, first_non_whitespace, next_line_end, start_of_line}, object::Object, state::{Mark, Mode, Operator}, }; actions!( vim, [ ToggleVisual, ToggleVisualLine, ToggleVisualBlock, VisualDelete, VisualDeleteLine, VisualYank, VisualYankLine, OtherEnd, OtherEndRowAware, SelectNext, SelectPrevious, SelectNextMatch, SelectPreviousMatch, SelectSmallerSyntaxNode, SelectLargerSyntaxNode, RestoreVisualSelection, VisualInsertEndOfLine, VisualInsertFirstNonWhiteSpace, ] ); pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &ToggleVisual, window, cx| { vim.toggle_mode(Mode::Visual, window, cx) }); Vim::action(editor, cx, |vim, _: &ToggleVisualLine, window, cx| { vim.toggle_mode(Mode::VisualLine, window, cx) }); Vim::action(editor, cx, |vim, _: &ToggleVisualBlock, window, cx| { vim.toggle_mode(Mode::VisualBlock, window, cx) }); Vim::action(editor, cx, Vim::other_end); Vim::action(editor, cx, Vim::other_end_row_aware); Vim::action(editor, cx, Vim::visual_insert_end_of_line); Vim::action(editor, cx, Vim::visual_insert_first_non_white_space); Vim::action(editor, cx, |vim, _: &VisualDelete, window, cx| { vim.record_current_action(cx); vim.visual_delete(false, window, cx); }); Vim::action(editor, cx, |vim, _: &VisualDeleteLine, window, cx| { vim.record_current_action(cx); vim.visual_delete(true, window, cx); }); Vim::action(editor, cx, |vim, _: &VisualYank, window, cx| { vim.visual_yank(false, window, cx) }); Vim::action(editor, cx, |vim, _: &VisualYankLine, window, cx| { vim.visual_yank(true, window, cx) }); Vim::action(editor, cx, Vim::select_next); Vim::action(editor, cx, Vim::select_previous); Vim::action(editor, cx, |vim, _: &SelectNextMatch, window, cx| { vim.select_match(Direction::Next, window, cx); }); Vim::action(editor, cx, |vim, _: &SelectPreviousMatch, window, cx| { vim.select_match(Direction::Prev, window, cx); }); Vim::action(editor, cx, |vim, _: &SelectLargerSyntaxNode, window, cx| { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); for _ in 0..count { vim.update_editor(window, cx, |_, editor, window, cx| { editor.select_larger_syntax_node(&Default::default(), window, cx); }); } }); Vim::action( editor, cx, |vim, _: &SelectSmallerSyntaxNode, window, cx| { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); for _ in 0..count { vim.update_editor(window, cx, |_, editor, window, cx| { editor.select_smaller_syntax_node(&Default::default(), window, cx); }); } }, ); Vim::action(editor, cx, |vim, _: &RestoreVisualSelection, window, cx| { let Some((stored_mode, reversed)) = vim.stored_visual_mode.take() else { return; }; let marks = vim .update_editor(window, cx, |vim, editor, window, cx| { vim.get_mark("<", editor, window, cx) .zip(vim.get_mark(">", editor, window, cx)) }) .flatten(); let Some((Mark::Local(start), Mark::Local(end))) = marks else { return; }; let ranges = start .iter() .zip(end) .zip(reversed) .map(|((start, end), reversed)| (*start, end, reversed)) .collect::>(); if vim.mode.is_visual() { vim.create_visual_marks(vim.mode, window, cx); } vim.update_editor(window, cx, |_, editor, window, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { let map = s.display_map(); let ranges = ranges .into_iter() .map(|(start, end, reversed)| { let mut new_end = movement::saturating_right(&map, end.to_display_point(&map)); let mut new_start = start.to_display_point(&map); if new_start >= new_end { if new_end.column() == 0 { new_end = movement::right(&map, new_end) } else { new_start = movement::saturating_left(&map, new_end); } } Selection { id: s.new_selection_id(), start: new_start.to_point(&map), end: new_end.to_point(&map), reversed, goal: SelectionGoal::None, } }) .collect(); s.select(ranges); }) }); vim.switch_mode(stored_mode, true, window, cx) }); } impl Vim { pub fn visual_motion( &mut self, motion: Motion, times: Option, window: &mut Window, cx: &mut Context, ) { self.update_editor(window, cx, |vim, editor, window, cx| { let text_layout_details = editor.text_layout_details(window); if vim.mode == Mode::VisualBlock && !matches!( motion, Motion::EndOfLine { display_lines: false } ) { let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. }); vim.visual_block_motion(is_up_or_down, editor, window, cx, |map, point, goal| { motion.move_point(map, point, goal, times, &text_layout_details) }) } else { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { let was_reversed = selection.reversed; 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. // 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. #[allow(clippy::nonminimal_bool)] 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)) = motion.move_point( map, current_head, selection.goal, times, &text_layout_details, ) else { return; }; selection.set_head(new_head, goal); // ensure the current character is included in the selection. if !selection.reversed { let next_point = if vim.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 = 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); } }) }); } }); } pub fn visual_block_motion( &mut self, preserve_goal: bool, editor: &mut Editor, window: &mut Window, cx: &mut Context, mut move_selection: impl FnMut( &DisplaySnapshot, DisplayPoint, SelectionGoal, ) -> Option<(DisplayPoint, SelectionGoal)>, ) { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Some(Autoscroll::fit()), window, 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 head_x = map.x_for_display_point(head, &text_layout_details); let mut tail_x = map.x_for_display_point(tail, &text_layout_details); let (start, end) = match s.newest_anchor().goal { SelectionGoal::HorizontalRange { start, end } if preserve_goal => (start, end), SelectionGoal::HorizontalPosition(start) if preserve_goal => (start, start), _ => (tail_x.0, head_x.0), }; let mut goal = SelectionGoal::HorizontalRange { start, end }; let was_reversed = tail_x > head_x; if !was_reversed && !preserve_goal { head = movement::saturating_left(map, head); } let reverse_aware_goal = if was_reversed { SelectionGoal::HorizontalRange { start: end, end: start, } } else { goal }; let Some((new_head, _)) = move_selection(map, head, reverse_aware_goal) else { return; }; head = new_head; head_x = map.x_for_display_point(head, &text_layout_details); let is_reversed = tail_x > head_x; if was_reversed && !is_reversed { tail = movement::saturating_left(map, tail); tail_x = map.x_for_display_point(tail, &text_layout_details); } else if !was_reversed && is_reversed { tail = movement::saturating_right(map, tail); tail_x = map.x_for_display_point(tail, &text_layout_details); } if !is_reversed && !preserve_goal { head = movement::saturating_right(map, head); head_x = map.x_for_display_point(head, &text_layout_details); } let positions = if is_reversed { head_x..tail_x } else { tail_x..head_x }; if !preserve_goal { goal = SelectionGoal::HorizontalRange { start: positions.start.0, end: positions.end.0, }; } let mut selections = Vec::new(); let mut row = tail.row(); loop { let laid_out_line = map.layout_row(row, &text_layout_details); let start = DisplayPoint::new( row, laid_out_line.closest_index_for_x(positions.start) as u32, ); let mut end = DisplayPoint::new(row, laid_out_line.closest_index_for_x(positions.end) as u32); if end <= start { if start.column() == map.line_len(start.row()) { end = start; } else { end = movement::saturating_right(map, start); } } if positions.start <= laid_out_line.width { let selection = Selection { id: s.new_selection_id(), start: start.to_point(map), end: end.to_point(map), reversed: is_reversed && // For neovim parity: cursor is not reversed when column is a single character end.column() - start.column() > 1, goal, }; selections.push(selection); } if row == head.row() { break; } if tail.row() > head.row() { row.0 -= 1 } else { row.0 += 1 } } s.select(selections); }) } pub fn visual_object(&mut self, object: Object, window: &mut Window, cx: &mut Context) { if let Some(Operator::Object { around }) = self.active_operator() { self.pop_operator(window, cx); let current_mode = self.mode; let target_mode = object.target_visual_mode(current_mode, around); if target_mode != current_mode { self.switch_mode(target_mode, true, window, cx); } self.update_editor(window, cx, |_, editor, window, cx| { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { let mut mut_selection = selection.clone(); // all our motions assume that the current character is // after the cursor; however in the case of a visual selection // the current character is before the cursor. // But this will affect the judgment of the html tag // so the html tag needs to skip this logic. if !selection.reversed && object != Object::Tag { mut_selection.set_head( movement::left(map, mut_selection.head()), mut_selection.goal, ); } if let Some(range) = object.range(map, mut_selection, around) { if !range.is_empty() { let expand_both_ways = object.always_expands_both_ways() || selection.is_empty() || movement::right(map, selection.start) == selection.end; if expand_both_ways { if selection.start == range.start && selection.end == range.end && object.always_expands_both_ways() { if let Some(range) = object.range(map, selection.clone(), around) { selection.start = range.start; selection.end = range.end; } } else { selection.start = range.start; selection.end = range.end; } } else if selection.reversed { selection.start = range.start; } else { selection.end = range.end; } } // In the visual selection result of a paragraph object, the cursor is // placed at the start of the last line. And in the visual mode, the // selection end is located after the end character. So, adjustment of // selection end is needed. // // We don't do this adjustment for a one-line blank paragraph since the // trailing newline is included in its selection from the beginning. if object == Object::Paragraph && range.start != range.end { let row_of_selection_end_line = selection.end.to_point(map).row; let new_selection_end = if map .buffer_snapshot .line_len(MultiBufferRow(row_of_selection_end_line)) == 0 { Point::new(row_of_selection_end_line + 1, 0) } else { Point::new(row_of_selection_end_line, 1) }; selection.end = new_selection_end.to_display_point(map); } } }); }); }); } } fn visual_insert_end_of_line( &mut self, _: &VisualInsertEndOfLine, window: &mut Window, cx: &mut Context, ) { self.update_editor(window, cx, |_, editor, window, cx| { editor.split_selection_into_lines(&Default::default(), window, cx); editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { (next_line_end(map, cursor, 1), SelectionGoal::None) }); }); }); self.switch_mode(Mode::Insert, false, window, cx); } fn visual_insert_first_non_white_space( &mut self, _: &VisualInsertFirstNonWhiteSpace, window: &mut Window, cx: &mut Context, ) { self.update_editor(window, cx, |_, editor, window, cx| { editor.split_selection_into_lines(&Default::default(), window, cx); editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { ( first_non_whitespace(map, false, cursor), SelectionGoal::None, ) }); }); }); self.switch_mode(Mode::Insert, false, window, cx); } fn toggle_mode(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { if self.mode == mode { self.switch_mode(Mode::Normal, false, window, cx); } else { self.switch_mode(mode, false, window, cx); } } pub fn other_end(&mut self, _: &OtherEnd, window: &mut Window, cx: &mut Context) { self.update_editor(window, cx, |_, editor, window, cx| { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|_, selection| { selection.reversed = !selection.reversed; }); }) }); } pub fn other_end_row_aware( &mut self, _: &OtherEndRowAware, window: &mut Window, cx: &mut Context, ) { let mode = self.mode; self.update_editor(window, cx, |_, editor, window, cx| { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|_, selection| { selection.reversed = !selection.reversed; }); if mode == Mode::VisualBlock { s.reverse_selections(); } }) }); } pub fn visual_delete(&mut self, line_mode: bool, window: &mut Window, cx: &mut Context) { self.store_visual_marks(window, cx); self.update_editor(window, cx, |vim, editor, window, cx| { let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = line_mode || editor.selections.line_mode; editor.selections.line_mode = false; editor.transact(window, cx, |editor, window, cx| { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { if line_mode { let mut position = selection.head(); if !selection.reversed { position = movement::left(map, position); } original_columns.insert(selection.id, position.to_point(map).column); if vim.mode == Mode::VisualBlock { *selection.end.column_mut() = map.line_len(selection.end.row()) } else { let start = selection.start.to_point(map); let end = selection.end.to_point(map); selection.start = map.prev_line_boundary(start).1; if end.column == 0 && end > start { let row = end.row.saturating_sub(1); selection.end = Point::new( row, map.buffer_snapshot.line_len(MultiBufferRow(row)), ) .to_display_point(map) } else { selection.end = map.next_line_boundary(end).1; } } } selection.goal = SelectionGoal::None; }); }); let kind = if line_mode { MotionKind::Linewise } else { MotionKind::Exclusive }; vim.copy_selections_content(editor, kind, window, cx); if line_mode && vim.mode != Mode::VisualBlock { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { let end = selection.end.to_point(map); let start = selection.start.to_point(map); if end.row < map.buffer_snapshot.max_point().row { selection.end = Point::new(end.row + 1, 0).to_display_point(map) } else if start.row > 0 { selection.start = Point::new( start.row - 1, map.buffer_snapshot.line_len(MultiBufferRow(start.row - 1)), ) .to_display_point(map) } }); }); } editor.insert("", window, cx); // Fixup cursor position after the deletion editor.set_clip_at_line_ends(true, cx); editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { let mut cursor = selection.head().to_point(map); if let Some(column) = original_columns.get(&selection.id) { cursor.column = *column } let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left); selection.collapse_to(cursor, selection.goal) }); if vim.mode == Mode::VisualBlock { s.select_anchors(vec![s.first_anchor()]) } }); }) }); self.switch_mode(Mode::Normal, true, window, cx); } pub fn visual_yank(&mut self, line_mode: bool, window: &mut Window, cx: &mut Context) { self.store_visual_marks(window, cx); self.update_editor(window, cx, |vim, editor, window, cx| { let line_mode = line_mode || editor.selections.line_mode; // For visual line mode, adjust selections to avoid yanking the next line when on \n if line_mode && vim.mode != Mode::VisualBlock { editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { let start = selection.start.to_point(map); let end = selection.end.to_point(map); if end.column == 0 && end > start { let row = end.row.saturating_sub(1); selection.end = Point::new(row, map.buffer_snapshot.line_len(MultiBufferRow(row))) .to_display_point(map); } }); }); } editor.selections.line_mode = line_mode; let kind = if line_mode { MotionKind::Linewise } else { MotionKind::Exclusive }; vim.yank_selections_content(editor, kind, window, cx); editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { if line_mode { selection.start = start_of_line(map, false, selection.start); }; selection.collapse_to(selection.start, SelectionGoal::None) }); if vim.mode == Mode::VisualBlock { s.select_anchors(vec![s.first_anchor()]) } }); }); self.switch_mode(Mode::Normal, true, window, cx); } pub(crate) fn visual_replace( &mut self, text: Arc, window: &mut Window, cx: &mut Context, ) { self.stop_recording(cx); self.update_editor(window, cx, |_, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { let (display_map, selections) = editor.selections.all_adjusted_display(cx); // Selections are biased right at the start. So we need to store // anchors that are biased left so that we can restore the selections // after the change let stable_anchors = editor .selections .disjoint_anchors() .iter() .map(|selection| { let start = selection.start.bias_left(&display_map.buffer_snapshot); start..start }) .collect::>(); let mut edits = Vec::new(); for selection in selections.iter() { let selection = selection.clone(); for row_range in movement::split_display_range_by_lines(&display_map, selection.range()) { let range = row_range.start.to_offset(&display_map, Bias::Right) ..row_range.end.to_offset(&display_map, Bias::Right); let text = text.repeat(range.len()); edits.push((range, text)); } } editor.edit(edits, cx); editor.change_selections(None, window, cx, |s| s.select_ranges(stable_anchors)); }); }); self.switch_mode(Mode::Normal, false, window, cx); } pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(window, cx, |_, editor, window, cx| { editor.set_clip_at_line_ends(false, cx); for _ in 0..count { if editor .select_next(&Default::default(), window, cx) .log_err() .is_none() { break; } } }); } pub fn select_previous( &mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context, ) { Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(window, cx, |_, editor, window, cx| { for _ in 0..count { if editor .select_previous(&Default::default(), window, cx) .log_err() .is_none() { break; } } }); } pub fn select_match( &mut self, direction: Direction, window: &mut Window, cx: &mut Context, ) { Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or(1); let Some(pane) = self.pane(window, cx) else { return; }; let vim_is_normal = self.mode == Mode::Normal; let mut start_selection = 0usize; let mut end_selection = 0usize; self.update_editor(window, cx, |_, editor, _, _| { editor.set_collapse_matches(false); }); if vim_is_normal { pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { if !search_bar.has_active_match() || !search_bar.show(window, cx) { return; } // without update_match_index there is a bug when the cursor is before the first match search_bar.update_match_index(window, cx); search_bar.select_match(direction.opposite(), 1, window, cx); }); } }); } self.update_editor(window, cx, |_, editor, _, cx| { let latest = editor.selections.newest::(cx); start_selection = latest.start; end_selection = latest.end; }); let mut match_exists = false; pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { search_bar.update_match_index(window, cx); search_bar.select_match(direction, count, window, cx); match_exists = search_bar.match_exists(window, cx); }); } }); if !match_exists { self.clear_operator(window, cx); self.stop_replaying(cx); return; } self.update_editor(window, cx, |_, editor, window, cx| { let latest = editor.selections.newest::(cx); if vim_is_normal { start_selection = latest.start; end_selection = latest.end; } else { start_selection = start_selection.min(latest.start); end_selection = end_selection.max(latest.end); } if direction == Direction::Prev { std::mem::swap(&mut start_selection, &mut end_selection); } editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.select_ranges([start_selection..end_selection]); }); editor.set_collapse_matches(true); }); match self.maybe_pop_operator() { Some(Operator::Change) => self.substitute(None, false, window, cx), Some(Operator::Delete) => { self.stop_recording(cx); self.visual_delete(false, window, cx) } Some(Operator::Yank) => self.visual_yank(false, window, cx), _ => {} // Ignoring other operators } } } #[cfg(test)] mod test { use indoc::indoc; use workspace::item::Item; use crate::{ state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; #[gpui::test] async fn test_enter_visual_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; let cursor = cx.update_editor(|editor, _, cx| editor.pixel_position_of_cursor(cx)); // entering visual mode should select the character // under cursor cx.simulate_shared_keystrokes("v").await; cx.shared_state() .await .assert_eq(indoc! { "The «qˇ»uick brown fox jumps over the lazy dog"}); cx.update_editor(|editor, _, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx))); // forwards motions should extend the selection cx.simulate_shared_keystrokes("w j").await; cx.shared_state().await.assert_eq(indoc! { "The «quick brown fox jumps oˇ»ver the lazy dog"}); cx.simulate_shared_keystrokes("escape").await; cx.shared_state().await.assert_eq(indoc! { "The quick brown fox jumps ˇover the lazy dog"}); // motions work backwards cx.simulate_shared_keystrokes("v k b").await; cx.shared_state() .await .assert_eq(indoc! { "The «ˇquick brown fox jumps o»ver the lazy dog"}); // works on empty lines cx.set_shared_state(indoc! {" a ˇ b "}) .await; let cursor = cx.update_editor(|editor, _, cx| editor.pixel_position_of_cursor(cx)); cx.simulate_shared_keystrokes("v").await; cx.shared_state().await.assert_eq(indoc! {" a « ˇ»b "}); cx.update_editor(|editor, _, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx))); // toggles off again cx.simulate_shared_keystrokes("v").await; cx.shared_state().await.assert_eq(indoc! {" a ˇ b "}); // works at the end of a document cx.set_shared_state(indoc! {" a b ˇ"}) .await; cx.simulate_shared_keystrokes("v").await; cx.shared_state().await.assert_eq(indoc! {" a b ˇ"}); } #[gpui::test] async fn test_visual_insert_first_non_whitespace(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state( indoc! { "«The quick brown fox jumps over the lazy dogˇ»" }, Mode::Visual, ); cx.simulate_keystrokes("g shift-i"); cx.assert_state( indoc! { "ˇThe quick brown ˇfox jumps over ˇthe lazy dog" }, Mode::Insert, ); } #[gpui::test] async fn test_visual_insert_end_of_line(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state( indoc! { "«The quick brown fox jumps over the lazy dogˇ»" }, Mode::Visual, ); cx.simulate_keystrokes("g shift-a"); cx.assert_state( indoc! { "The quick brownˇ fox jumps overˇ the lazy dogˇ" }, Mode::Insert, ); } #[gpui::test] async fn test_enter_visual_line_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("shift-v").await; cx.shared_state() .await .assert_eq(indoc! { "The «qˇ»uick brown fox jumps over the lazy dog"}); cx.simulate_shared_keystrokes("x").await; cx.shared_state().await.assert_eq(indoc! { "fox ˇjumps over the lazy dog"}); // it should work on empty lines cx.set_shared_state(indoc! {" a ˇ b"}) .await; cx.simulate_shared_keystrokes("shift-v").await; cx.shared_state().await.assert_eq(indoc! {" a « ˇ»b"}); cx.simulate_shared_keystrokes("x").await; cx.shared_state().await.assert_eq(indoc! {" a ˇb"}); // it should work at the end of the document cx.set_shared_state(indoc! {" a b ˇ"}) .await; let cursor = cx.update_editor(|editor, _, cx| editor.pixel_position_of_cursor(cx)); cx.simulate_shared_keystrokes("shift-v").await; cx.shared_state().await.assert_eq(indoc! {" a b ˇ"}); cx.update_editor(|editor, _, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx))); cx.simulate_shared_keystrokes("x").await; cx.shared_state().await.assert_eq(indoc! {" a ˇb"}); } #[gpui::test] async fn test_visual_delete(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.simulate("v w", "The quick ˇbrown") .await .assert_matches(); cx.simulate("v w x", "The quick ˇbrown") .await .assert_matches(); cx.simulate( "v w j x", indoc! {" The ˇquick brown fox jumps over the lazy dog"}, ) .await .assert_matches(); // Test pasting code copied on delete cx.simulate_shared_keystrokes("j p").await; cx.shared_state().await.assert_matches(); cx.simulate_at_each_offset( "v w j x", indoc! {" The ˇquick brown fox jumps over the ˇlazy dog"}, ) .await .assert_matches(); cx.simulate_at_each_offset( "v b k x", indoc! {" The ˇquick brown fox jumps ˇover the ˇlazy dog"}, ) .await .assert_matches(); } #[gpui::test] async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! {" The quˇick brown fox jumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("shift-v x").await; cx.shared_state().await.assert_matches(); // Test pasting code copied on delete cx.simulate_shared_keystrokes("p").await; cx.shared_state().await.assert_matches(); cx.set_shared_state(indoc! {" The quick brown fox jumps over the laˇzy dog"}) .await; cx.simulate_shared_keystrokes("shift-v x").await; cx.shared_state().await.assert_matches(); cx.shared_clipboard().await.assert_eq("the lazy dog\n"); cx.set_shared_state(indoc! {" The quˇick brown fox jumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("shift-v j x").await; cx.shared_state().await.assert_matches(); // Test pasting code copied on delete cx.simulate_shared_keystrokes("p").await; cx.shared_state().await.assert_matches(); cx.set_shared_state(indoc! {" The ˇlong line should not crash "}) .await; cx.simulate_shared_keystrokes("shift-v $ x").await; cx.shared_state().await.assert_matches(); } #[gpui::test] async fn test_visual_yank(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("The quick ˇbrown").await; cx.simulate_shared_keystrokes("v w y").await; cx.shared_state().await.assert_eq("The quick ˇbrown"); cx.shared_clipboard().await.assert_eq("brown"); cx.set_shared_state(indoc! {" The ˇquick brown fox jumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("v w j y").await; cx.shared_state().await.assert_eq(indoc! {" The ˇquick brown fox jumps over the lazy dog"}); cx.shared_clipboard().await.assert_eq(indoc! {" quick brown fox jumps o"}); cx.set_shared_state(indoc! {" The quick brown fox jumps over the ˇlazy dog"}) .await; cx.simulate_shared_keystrokes("v w j y").await; cx.shared_state().await.assert_eq(indoc! {" The quick brown fox jumps over the ˇlazy dog"}); cx.shared_clipboard().await.assert_eq("lazy d"); cx.simulate_shared_keystrokes("shift-v y").await; cx.shared_clipboard().await.assert_eq("the lazy dog\n"); cx.set_shared_state(indoc! {" The ˇquick brown fox jumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("v b k y").await; cx.shared_state().await.assert_eq(indoc! {" ˇThe quick brown fox jumps over the lazy dog"}); assert_eq!( cx.read_from_clipboard() .map(|item| item.text().unwrap().to_string()) .unwrap(), "The q" ); cx.set_shared_state(indoc! {" The quick brown fox ˇjumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("shift-v shift-g shift-y") .await; cx.shared_state().await.assert_eq(indoc! {" The quick brown ˇfox jumps over the lazy dog"}); cx.shared_clipboard() .await .assert_eq("fox jumps over\nthe lazy dog\n"); cx.set_shared_state(indoc! {" The quick brown fox ˇjumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("shift-v $ shift-y").await; cx.shared_state().await.assert_eq(indoc! {" The quick brown ˇfox jumps over the lazy dog"}); cx.shared_clipboard().await.assert_eq("fox jumps over\n"); } #[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.shared_state().await.assert_eq(indoc! { "The «qˇ»uick brown fox jumps over the lazy dog" }); cx.simulate_shared_keystrokes("2 down").await; cx.shared_state().await.assert_eq(indoc! { "The «qˇ»uick brown fox «jˇ»umps over the «lˇ»azy dog" }); cx.simulate_shared_keystrokes("e").await; cx.shared_state().await.assert_eq(indoc! { "The «quicˇ»k brown fox «jumpˇ»s over the «lazyˇ» dog" }); cx.simulate_shared_keystrokes("^").await; cx.shared_state().await.assert_eq(indoc! { "«ˇThe q»uick brown «ˇfox j»umps over «ˇthe l»azy dog" }); cx.simulate_shared_keystrokes("$").await; cx.shared_state().await.assert_eq(indoc! { "The «quick brownˇ» fox «jumps overˇ» the «lazy dogˇ»" }); cx.simulate_shared_keystrokes("shift-f space").await; cx.shared_state().await.assert_eq(indoc! { "The «quickˇ» brown fox «jumpsˇ» over the «lazy ˇ»dog" }); // toggling through visual mode works as expected cx.simulate_shared_keystrokes("v").await; cx.shared_state().await.assert_eq(indoc! { "The «quick brown fox jumps over the lazy ˇ»dog" }); cx.simulate_shared_keystrokes("ctrl-v").await; cx.shared_state().await.assert_eq(indoc! { "The «quickˇ» brown fox «jumpsˇ» over the «lazy ˇ»dog" }); cx.set_shared_state(indoc! { "The ˇquick brown fox jumps over the lazy dog " }) .await; cx.simulate_shared_keystrokes("ctrl-v down down").await; cx.shared_state().await.assert_eq(indoc! { "The«ˇ q»uick bro«ˇwn» foxˇ jumps over the lazy dog " }); cx.simulate_shared_keystrokes("down").await; cx.shared_state().await.assert_eq(indoc! { "The «qˇ»uick brow«nˇ» fox jump«sˇ» over the lazy dog " }); cx.simulate_shared_keystrokes("left").await; cx.shared_state().await.assert_eq(indoc! { "The«ˇ q»uick bro«ˇwn» foxˇ jum«ˇps» over the lazy dog " }); cx.simulate_shared_keystrokes("s o escape").await; cx.shared_state().await.assert_eq(indoc! { "Theˇouick broo foxo jumo over the lazy dog " }); // https://github.com/zed-industries/zed/issues/6274 cx.set_shared_state(indoc! { "Theˇ quick brown fox jumps over the lazy dog " }) .await; cx.simulate_shared_keystrokes("l ctrl-v j j").await; cx.shared_state().await.assert_eq(indoc! { "The «qˇ»uick brown fox «jˇ»umps over the lazy dog " }); } #[gpui::test] async fn test_visual_block_issue_2123(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 right down").await; cx.shared_state().await.assert_eq(indoc! { "The «quˇ»ick brown fox «juˇ»mps over the lazy dog " }); } #[gpui::test] async fn test_visual_block_mode_down_right(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 l l l l l j").await; cx.shared_state().await.assert_eq(indoc! {" The «quick ˇ»brown fox «jumps ˇ»over the lazy dog"}); } #[gpui::test] async fn test_visual_block_mode_up_left(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 h h h h h k").await; cx.shared_state().await.assert_eq(indoc! {" The «ˇquick »brown fox «ˇjumps »over the lazy dog"}); } #[gpui::test] async fn test_visual_block_mode_other_end(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! {" The quick brown fox jˇumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("ctrl-v l l l l j").await; cx.shared_state().await.assert_eq(indoc! {" The quick brown fox j«umps ˇ»over the l«azy dˇ»og"}); cx.simulate_shared_keystrokes("o k").await; cx.shared_state().await.assert_eq(indoc! {" The q«ˇuick »brown fox j«ˇumps »over the l«ˇazy d»og"}); } #[gpui::test] async fn test_visual_block_mode_shift_other_end(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! {" The quick brown fox jˇumps over the lazy dog"}) .await; cx.simulate_shared_keystrokes("ctrl-v l l l l j").await; cx.shared_state().await.assert_eq(indoc! {" The quick brown fox j«umps ˇ»over the l«azy dˇ»og"}); cx.simulate_shared_keystrokes("shift-o k").await; cx.shared_state().await.assert_eq(indoc! {" The quick brown fox j«ˇumps »over the lazy dog"}); } #[gpui::test] async fn test_visual_block_insert(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 9 down").await; cx.shared_state().await.assert_eq(indoc! { "«Tˇ»he quick brown «fˇ»ox jumps over «tˇ»he lazy dog ˇ" }); cx.simulate_shared_keystrokes("shift-i k escape").await; cx.shared_state().await.assert_eq(indoc! { "ˇkThe quick brown kfox jumps over kthe lazy dog k" }); cx.set_shared_state(indoc! { "ˇThe quick brown fox jumps over the lazy dog " }) .await; cx.simulate_shared_keystrokes("ctrl-v 9 down").await; cx.shared_state().await.assert_eq(indoc! { "«Tˇ»he quick brown «fˇ»ox jumps over «tˇ»he lazy dog ˇ" }); cx.simulate_shared_keystrokes("c k escape").await; cx.shared_state().await.assert_eq(indoc! { "ˇkhe quick brown kox jumps over khe lazy dog k" }); } #[gpui::test] async fn test_visual_object(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("hello (in [parˇens] o)").await; cx.simulate_shared_keystrokes("ctrl-v l").await; cx.simulate_shared_keystrokes("a ]").await; cx.shared_state() .await .assert_eq("hello (in «[parens]ˇ» o)"); cx.simulate_shared_keystrokes("i (").await; cx.shared_state() .await .assert_eq("hello («in [parens] oˇ»)"); cx.set_shared_state("hello in a wˇord again.").await; cx.simulate_shared_keystrokes("ctrl-v l i w").await; cx.shared_state() .await .assert_eq("hello in a w«ordˇ» again."); assert_eq!(cx.mode(), Mode::VisualBlock); cx.simulate_shared_keystrokes("o a s").await; cx.shared_state() .await .assert_eq("«ˇhello in a word» again."); } #[gpui::test] async fn test_visual_object_expands(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! { "{ { ˇ } } { } " }) .await; cx.simulate_shared_keystrokes("v l").await; cx.shared_state().await.assert_eq(indoc! { "{ { « }ˇ» } { } " }); cx.simulate_shared_keystrokes("a {").await; cx.shared_state().await.assert_eq(indoc! { "{ «{ }ˇ» } { } " }); cx.simulate_shared_keystrokes("a {").await; cx.shared_state().await.assert_eq(indoc! { "«{ { } }ˇ» { } " }); // cx.simulate_shared_keystrokes("a {").await; // cx.shared_state().await.assert_eq(indoc! { // "{ // «{ // }ˇ» // } // { // } // " // }); } #[gpui::test] async fn test_mode_across_command(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("aˇbc", Mode::Normal); cx.simulate_keystrokes("ctrl-v"); assert_eq!(cx.mode(), Mode::VisualBlock); cx.simulate_keystrokes("cmd-shift-p escape"); assert_eq!(cx.mode(), Mode::VisualBlock); } #[gpui::test] async fn test_gn(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ a a enter").await; cx.shared_state().await.assert_eq("aa ˇaa aa aa aa"); cx.simulate_shared_keystrokes("g n").await; cx.shared_state().await.assert_eq("aa «aaˇ» aa aa aa"); cx.simulate_shared_keystrokes("g n").await; cx.shared_state().await.assert_eq("aa «aa aaˇ» aa aa"); cx.simulate_shared_keystrokes("escape d g n").await; cx.shared_state().await.assert_eq("aa aa ˇ aa aa"); cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ a a enter").await; cx.shared_state().await.assert_eq("aa ˇaa aa aa aa"); cx.simulate_shared_keystrokes("3 g n").await; cx.shared_state().await.assert_eq("aa aa aa «aaˇ» aa"); cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ a a enter").await; cx.shared_state().await.assert_eq("aa ˇaa aa aa aa"); cx.simulate_shared_keystrokes("g shift-n").await; cx.shared_state().await.assert_eq("aa «ˇaa» aa aa aa"); cx.simulate_shared_keystrokes("g shift-n").await; cx.shared_state().await.assert_eq("«ˇaa aa» aa aa aa"); } #[gpui::test] async fn test_gl(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("aaˇ aa\naa", Mode::Normal); cx.simulate_keystrokes("g l"); cx.assert_state("«aaˇ» «aaˇ»\naa", Mode::Visual); cx.simulate_keystrokes("g >"); cx.assert_state("«aaˇ» aa\n«aaˇ»", Mode::Visual); } #[gpui::test] async fn test_dgn_repeat(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ a a enter").await; cx.shared_state().await.assert_eq("aa ˇaa aa aa aa"); cx.simulate_shared_keystrokes("d g n").await; cx.shared_state().await.assert_eq("aa ˇ aa aa aa"); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("aa ˇ aa aa"); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("aa ˇ aa"); } #[gpui::test] async fn test_cgn_repeat(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ a a enter").await; cx.shared_state().await.assert_eq("aa ˇaa aa aa aa"); cx.simulate_shared_keystrokes("c g n x escape").await; cx.shared_state().await.assert_eq("aa ˇx aa aa aa"); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("aa x ˇx aa aa"); } #[gpui::test] async fn test_cgn_nomatch(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state("aaˇ aa aa aa aa").await; cx.simulate_shared_keystrokes("/ b b enter").await; cx.shared_state().await.assert_eq("aaˇ aa aa aa aa"); cx.simulate_shared_keystrokes("c g n x escape").await; cx.shared_state().await.assert_eq("aaˇaa aa aa aa"); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("aaˇa aa aa aa"); cx.set_shared_state("aaˇ bb aa aa aa").await; cx.simulate_shared_keystrokes("/ b b enter").await; cx.shared_state().await.assert_eq("aa ˇbb aa aa aa"); cx.simulate_shared_keystrokes("c g n x escape").await; cx.shared_state().await.assert_eq("aa ˇx aa aa aa"); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("aa ˇx aa aa aa"); } #[gpui::test] async fn test_visual_shift_d(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("v down shift-d").await; cx.shared_state().await.assert_eq(indoc! { "the ˇlazy dog\n" }); cx.set_shared_state(indoc! { "The ˇquick brown fox jumps over the lazy dog " }) .await; cx.simulate_shared_keystrokes("ctrl-v down shift-d").await; cx.shared_state().await.assert_eq(indoc! { "Theˇ• fox• the lazy dog " }); } #[gpui::test] async fn test_shift_y(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! { "The ˇquick brown\n" }) .await; cx.simulate_shared_keystrokes("v i w shift-y").await; cx.shared_clipboard().await.assert_eq(indoc! { "The quick brown\n" }); } #[gpui::test] async fn test_gv(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! { "The ˇquick brown" }) .await; cx.simulate_shared_keystrokes("v i w escape g v").await; cx.shared_state().await.assert_eq(indoc! { "The «quickˇ» brown" }); cx.simulate_shared_keystrokes("o escape g v").await; cx.shared_state().await.assert_eq(indoc! { "The «ˇquick» brown" }); cx.simulate_shared_keystrokes("escape ^ ctrl-v l").await; cx.shared_state().await.assert_eq(indoc! { "«Thˇ»e quick brown" }); cx.simulate_shared_keystrokes("g v").await; cx.shared_state().await.assert_eq(indoc! { "The «ˇquick» brown" }); cx.simulate_shared_keystrokes("g v").await; cx.shared_state().await.assert_eq(indoc! { "«Thˇ»e quick brown" }); cx.set_state( indoc! {" fiˇsh one fish two fish red fish blue "}, Mode::Normal, ); cx.simulate_keystrokes("4 g l escape escape g v"); cx.assert_state( indoc! {" «fishˇ» one «fishˇ» two «fishˇ» red «fishˇ» blue "}, Mode::Visual, ); cx.simulate_keystrokes("y g v"); cx.assert_state( indoc! {" «fishˇ» one «fishˇ» two «fishˇ» red «fishˇ» blue "}, Mode::Visual, ); } #[gpui::test] async fn test_p_g_v_y(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; cx.set_shared_state(indoc! { "The quicˇk brown fox" }) .await; cx.simulate_shared_keystrokes("y y j shift-v p g v y").await; cx.shared_state().await.assert_eq(indoc! { "The quick ˇquick fox" }); cx.shared_clipboard().await.assert_eq("quick\n"); } }