mod boundary; mod duplicate; mod object; mod paste; mod select; use editor::display_map::DisplaySnapshot; use editor::{ DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, MultiBufferOffset, SelectionEffects, ToOffset, ToPoint, movement, }; use gpui::actions; use gpui::{Context, Window}; use language::{CharClassifier, CharKind, Point}; use search::{BufferSearchBar, SearchOptions}; use settings::Settings; use text::{Bias, SelectionGoal}; use workspace::searchable::FilteredSearchRange; use workspace::searchable::{self, Direction}; use crate::motion::{self, MotionKind}; use crate::state::SearchState; use crate::{ Vim, motion::{Motion, right}, state::Mode, }; actions!( vim, [ /// Yanks the current selection or character if no selection. HelixYank, /// Inserts at the beginning of the selection. HelixInsert, /// Appends at the end of the selection. HelixAppend, /// Goes to the location of the last modification. HelixGotoLastModification, /// Select entire line or multiple lines, extending downwards. HelixSelectLine, /// Select all matches of a given pattern within the current selection. HelixSelectRegex, /// Removes all but the one selection that was created last. /// `Newest` can eventually be `Primary`. HelixKeepNewestSelection, /// Copies all selections below. HelixDuplicateBelow, /// Copies all selections above. HelixDuplicateAbove, /// Delete the selection and enter edit mode. HelixSubstitute, /// Delete the selection and enter edit mode, without yanking the selection. HelixSubstituteNoYank, /// Delete the selection and enter edit mode. HelixSelectNext, /// Delete the selection and enter edit mode, without yanking the selection. HelixSelectPrevious, ] ); pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_select_lines); Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_append); Vim::action(editor, cx, Vim::helix_yank); Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_paste); Vim::action(editor, cx, Vim::helix_select_regex); Vim::action(editor, cx, Vim::helix_keep_newest_selection); Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| { let times = Vim::take_count(cx); vim.helix_duplicate_selections_below(times, window, cx); }); Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| { let times = Vim::take_count(cx); vim.helix_duplicate_selections_above(times, window, cx); }); Vim::action(editor, cx, Vim::helix_substitute); Vim::action(editor, cx, Vim::helix_substitute_no_yank); Vim::action(editor, cx, Vim::helix_select_next); Vim::action(editor, cx, Vim::helix_select_previous); } impl Vim { pub fn helix_normal_motion( &mut self, motion: Motion, times: Option, window: &mut Window, cx: &mut Context, ) { self.helix_move_cursor(motion, times, window, cx); } pub fn helix_select_motion( &mut self, motion: Motion, times: Option, window: &mut Window, cx: &mut Context, ) { self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { if let Motion::ZedSearchResult { new_selections, .. } = &motion { s.select_anchor_ranges(new_selections.clone()); return; }; s.move_with(|map, selection| { let current_head = selection.head(); 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); }) }); }); } /// Updates all selections based on where the cursors are. fn helix_new_selections( &mut self, window: &mut Window, cx: &mut Context, mut change: impl FnMut( // the start of the cursor DisplayPoint, &DisplaySnapshot, ) -> Option<(DisplayPoint, DisplayPoint)>, ) { self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let cursor_start = if selection.reversed || selection.is_empty() { selection.head() } else { movement::left(map, selection.head()) }; let Some((head, tail)) = change(cursor_start, map) else { return; }; selection.set_head_tail(head, tail, SelectionGoal::None); }); }); }); } fn helix_find_range_forward( &mut self, times: Option, window: &mut Window, cx: &mut Context, mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, ) { let times = times.unwrap_or(1); self.helix_new_selections(window, cx, |cursor, map| { let mut head = movement::right(map, cursor); let mut tail = cursor; let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map)); if head == map.max_point() { return None; } for _ in 0..times { let (maybe_next_tail, next_head) = movement::find_boundary_trail(map, head, |left, right| { is_boundary(left, right, &classifier) }); if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail { break; } head = next_head; if let Some(next_tail) = maybe_next_tail { tail = next_tail; } } Some((head, tail)) }); } fn helix_find_range_backward( &mut self, times: Option, window: &mut Window, cx: &mut Context, mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, ) { let times = times.unwrap_or(1); self.helix_new_selections(window, cx, |cursor, map| { let mut head = cursor; // The original cursor was one character wide, // but the search starts from the left side of it, // so to include that space the selection must end one character to the right. let mut tail = movement::right(map, cursor); let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map)); if head == DisplayPoint::zero() { return None; } for _ in 0..times { let (maybe_next_tail, next_head) = movement::find_preceding_boundary_trail(map, head, |left, right| { is_boundary(left, right, &classifier) }); if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail { break; } head = next_head; if let Some(next_tail) = maybe_next_tail { tail = next_tail; } } Some((head, tail)) }); } pub fn helix_move_and_collapse( &mut self, motion: Motion, times: Option, window: &mut Window, cx: &mut Context, ) { self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let goal = selection.goal; let cursor = if selection.is_empty() || selection.reversed { selection.head() } else { movement::left(map, selection.head()) }; let (point, goal) = motion .move_point(map, cursor, selection.goal, times, &text_layout_details) .unwrap_or((cursor, goal)); selection.collapse_to(point, goal) }) }); }); } pub fn helix_move_cursor( &mut self, motion: Motion, times: Option, window: &mut Window, cx: &mut Context, ) { match motion { Motion::NextWordStart { ignore_punctuation } => { self.helix_find_range_forward(times, window, cx, |left, right, classifier| { let left_kind = classifier.kind_with(left, ignore_punctuation); let right_kind = classifier.kind_with(right, ignore_punctuation); let at_newline = (left == '\n') ^ (right == '\n'); (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline }) } Motion::NextWordEnd { ignore_punctuation } => { self.helix_find_range_forward(times, window, cx, |left, right, classifier| { let left_kind = classifier.kind_with(left, ignore_punctuation); let right_kind = classifier.kind_with(right, ignore_punctuation); let at_newline = (left == '\n') ^ (right == '\n'); (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline }) } Motion::PreviousWordStart { ignore_punctuation } => { self.helix_find_range_backward(times, window, cx, |left, right, classifier| { let left_kind = classifier.kind_with(left, ignore_punctuation); let right_kind = classifier.kind_with(right, ignore_punctuation); let at_newline = (left == '\n') ^ (right == '\n'); (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline }) } Motion::PreviousWordEnd { ignore_punctuation } => { self.helix_find_range_backward(times, window, cx, |left, right, classifier| { let left_kind = classifier.kind_with(left, ignore_punctuation); let right_kind = classifier.kind_with(right, ignore_punctuation); let at_newline = (left == '\n') ^ (right == '\n'); (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline }) } Motion::FindForward { before, char, mode, smartcase, } => { self.helix_new_selections(window, cx, |cursor, map| { let start = cursor; let mut last_boundary = start; for _ in 0..times.unwrap_or(1) { last_boundary = movement::find_boundary( map, movement::right(map, last_boundary), mode, |left, right| { let current_char = if before { right } else { left }; motion::is_character_match(char, current_char, smartcase) }, ); } Some((last_boundary, start)) }); } Motion::FindBackward { after, char, mode, smartcase, } => { self.helix_new_selections(window, cx, |cursor, map| { let start = cursor; let mut last_boundary = start; for _ in 0..times.unwrap_or(1) { last_boundary = movement::find_preceding_boundary_display_point( map, last_boundary, mode, |left, right| { let current_char = if after { left } else { right }; motion::is_character_match(char, current_char, smartcase) }, ); } // The original cursor was one character wide, // but the search started from the left side of it, // so to include that space the selection must end one character to the right. Some((last_boundary, movement::right(map, start))) }); } _ => self.helix_move_and_collapse(motion, times, window, cx), } } pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |vim, editor, cx| { let has_selection = editor .selections .all_adjusted(&editor.display_snapshot(cx)) .iter() .any(|selection| !selection.is_empty()); if !has_selection { // If no selection, expand to current character (like 'v' does) editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let head = selection.head(); let new_head = movement::saturating_right(map, head); selection.set_tail(head, SelectionGoal::None); selection.set_head(new_head, SelectionGoal::None); }); }); vim.yank_selections_content( editor, crate::motion::MotionKind::Exclusive, window, cx, ); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|_map, selection| { selection.collapse_to(selection.start, SelectionGoal::None); }); }); } else { // Yank the selection(s) vim.yank_selections_content( editor, crate::motion::MotionKind::Exclusive, window, cx, ); } }); // Drop back to normal mode after yanking self.switch_mode(Mode::HelixNormal, true, window, cx); } fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context) { self.start_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|_map, selection| { // In helix normal mode, move cursor to start of selection and collapse if !selection.is_empty() { selection.collapse_to(selection.start, SelectionGoal::None); } }); }); }); self.switch_mode(Mode::Insert, false, window, cx); } fn helix_select_regex( &mut self, _: &HelixSelectRegex, window: &mut Window, cx: &mut Context, ) { Vim::take_forced_motion(cx); let Some(pane) = self.pane(window, cx) else { return; }; let prior_selections = self.editor_selections(window, cx); 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.show(window, cx) { return; } search_bar.select_query(window, cx); cx.focus_self(window); search_bar.set_replacement(None, cx); let mut options = SearchOptions::NONE; options |= SearchOptions::REGEX; if EditorSettings::get_global(cx).search.case_sensitive { options |= SearchOptions::CASE_SENSITIVE; } search_bar.set_search_options(options, cx); if let Some(search) = search_bar.set_search_within_selection( Some(FilteredSearchRange::Selection), window, cx, ) { cx.spawn_in(window, async move |search_bar, cx| { if search.await.is_ok() { search_bar.update_in(cx, |search_bar, window, cx| { search_bar.activate_current_match(window, cx) }) } else { Ok(()) } }) .detach_and_log_err(cx); } self.search = SearchState { direction: searchable::Direction::Next, count: 1, prior_selections, prior_operator: self.operator_stack.last().cloned(), prior_mode: self.mode, helix_select: true, } }); } }); self.start_recording(cx); } fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let point = if selection.is_empty() { right(map, selection.head(), 1) } else { selection.end }; selection.collapse_to(point, SelectionGoal::None); }); }); }); } pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let display_map = editor.display_snapshot(cx); let selections = editor.selections.all_display(&display_map); // Store selection info for positioning after edit let selection_info: Vec<_> = selections .iter() .map(|selection| { let range = selection.range(); let start_offset = range.start.to_offset(&display_map, Bias::Left); let end_offset = range.end.to_offset(&display_map, Bias::Left); let was_empty = range.is_empty(); let was_reversed = selection.reversed; ( display_map.buffer_snapshot().anchor_before(start_offset), end_offset - start_offset, was_empty, was_reversed, ) }) .collect(); let mut edits = Vec::new(); for selection in &selections { let mut range = selection.range(); // For empty selections, extend to replace one character if range.is_empty() { range.end = movement::saturating_right(&display_map, range.start); } let byte_range = range.start.to_offset(&display_map, Bias::Left) ..range.end.to_offset(&display_map, Bias::Left); if !byte_range.is_empty() { let replacement_text = text.repeat(byte_range.end - byte_range.start); edits.push((byte_range, replacement_text)); } } editor.edit(edits, cx); // Restore selections based on original info let snapshot = editor.buffer().read(cx).snapshot(cx); let ranges: Vec<_> = selection_info .into_iter() .map(|(start_anchor, original_len, was_empty, was_reversed)| { let start_point = start_anchor.to_point(&snapshot); if was_empty { // For cursor-only, collapse to start start_point..start_point } else { // For selections, span the replaced text let replacement_len = text.len() * original_len; let end_offset = start_anchor.to_offset(&snapshot) + replacement_len; let end_point = snapshot.offset_to_point(end_offset); if was_reversed { end_point..start_point } else { start_point..end_point } } }) .collect(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges(ranges); }); }); }); self.switch_mode(Mode::HelixNormal, true, window, cx); } pub fn helix_goto_last_modification( &mut self, _: &HelixGotoLastModification, window: &mut Window, cx: &mut Context, ) { self.jump(".".into(), false, false, window, cx); } pub fn helix_select_lines( &mut self, _: &HelixSelectLine, window: &mut Window, cx: &mut Context, ) { let count = Vim::take_count(cx).unwrap_or(1); self.update_editor(cx, |_, editor, cx| { editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut selections = editor.selections.all::(&display_map); let max_point = display_map.buffer_snapshot().max_point(); let buffer_snapshot = &display_map.buffer_snapshot(); for selection in &mut selections { // Start always goes to column 0 of the first selected line let start_row = selection.start.row; let current_end_row = selection.end.row; // Check if cursor is on empty line by checking first character let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0)); let first_char = buffer_snapshot.chars_at(line_start_offset).next(); let extra_line = if first_char == Some('\n') { 1 } else { 0 }; let end_row = current_end_row + count as u32 + extra_line; selection.start = Point::new(start_row, 0); selection.end = if end_row > max_point.row { max_point } else { Point::new(end_row, 0) }; selection.reversed = false; } editor.change_selections(Default::default(), window, cx, |s| { s.select(selections); }); }); } fn helix_keep_newest_selection( &mut self, _: &HelixKeepNewestSelection, window: &mut Window, cx: &mut Context, ) { self.update_editor(cx, |_, editor, cx| { let newest = editor .selections .newest::(&editor.display_snapshot(cx)); editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest])); }); } fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.transact(window, cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|map, selection| { if selection.start == selection.end { selection.end = movement::right(map, selection.end); } // If the selection starts and ends on a newline, we exclude the last one. if !selection.is_empty() && selection.start.column() == 0 && selection.end.column() == 0 { selection.end = movement::left(map, selection.end); } }) }); if yank { vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); } let selections = editor .selections .all::(&editor.display_snapshot(cx)) .into_iter(); let edits = selections.map(|selection| (selection.start..selection.end, "")); editor.edit(edits, cx); }); }); self.switch_mode(Mode::Insert, true, window, cx); } fn helix_substitute( &mut self, _: &HelixSubstitute, window: &mut Window, cx: &mut Context, ) { self.do_helix_substitute(true, window, cx); } fn helix_substitute_no_yank( &mut self, _: &HelixSubstituteNoYank, window: &mut Window, cx: &mut Context, ) { self.do_helix_substitute(false, window, cx); } fn helix_select_next( &mut self, _: &HelixSelectNext, window: &mut Window, cx: &mut Context, ) { self.do_helix_select(Direction::Next, window, cx); } fn helix_select_previous( &mut self, _: &HelixSelectPrevious, window: &mut Window, cx: &mut Context, ) { self.do_helix_select(Direction::Prev, window, cx); } fn do_helix_select( &mut self, direction: searchable::Direction, window: &mut Window, cx: &mut Context, ) { let Some(pane) = self.pane(window, cx) else { return; }; let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); let prior_selections = self.editor_selections(window, cx); let success = pane.update(cx, |pane, cx| { let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() else { return false; }; search_bar.update(cx, |search_bar, cx| { if !search_bar.has_active_match() || !search_bar.show(window, cx) { return false; } search_bar.select_match(direction, count, window, cx); true }) }); if !success { return; } if self.mode == Mode::HelixSelect { self.update_editor(cx, |_vim, editor, cx| { let snapshot = editor.snapshot(window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_anchor_ranges( prior_selections .iter() .cloned() .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())), ); }) }); } } } #[cfg(test)] mod test { use indoc::indoc; use crate::{state::Mode, test::VimTestContext}; #[gpui::test] async fn test_word_motions(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // « // ˇ // » cx.set_state( indoc! {" Th«e quiˇ»ck brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("w"); cx.assert_state( indoc! {" The qu«ick ˇ»brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("w"); cx.assert_state( indoc! {" The quick «brownˇ» fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("2 b"); cx.assert_state( indoc! {" The «ˇquick »brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("down e up"); cx.assert_state( indoc! {" The quicˇk brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.set_state("aa\n «ˇbb»", Mode::HelixNormal); cx.simulate_keystroke("b"); cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal); } #[gpui::test] async fn test_delete(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // test delete a selection cx.set_state( indoc! {" The qu«ick ˇ»brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("d"); cx.assert_state( indoc! {" The quˇbrown fox jumps over the lazy dog."}, Mode::HelixNormal, ); // test deleting a single character cx.simulate_keystrokes("d"); cx.assert_state( indoc! {" The quˇrown fox jumps over the lazy dog."}, Mode::HelixNormal, ); } #[gpui::test] async fn test_delete_character_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::HelixNormal, ); cx.simulate_keystrokes("d"); cx.assert_state( indoc! {" The quick brownˇfox jumps over the lazy dog."}, Mode::HelixNormal, ); } // #[gpui::test] // async fn test_delete_character_end_of_buffer(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::HelixNormal, // ); // cx.simulate_keystrokes("d"); // cx.assert_state( // indoc! {" // The quick brown // fox jumps over // the lazy dog.ˇ"}, // Mode::HelixNormal, // ); // } #[gpui::test] async fn test_f_and_t(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); cx.set_state( indoc! {" The quˇick brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("f z"); cx.assert_state( indoc! {" The qu«ick brown fox jumps over the lazˇ»y dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("F e F e"); cx.assert_state( indoc! {" The quick brown fox jumps ov«ˇer the» lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("e 2 F e"); cx.assert_state( indoc! {" Th«ˇe quick brown fox jumps over» the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("t r t r"); cx.assert_state( indoc! {" The quick «brown fox jumps oveˇ»r the lazy dog."}, Mode::HelixNormal, ); } #[gpui::test] async fn test_newline_char(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal); cx.simulate_keystroke("w"); cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal); cx.set_state("aa«\nˇ»", Mode::HelixNormal); cx.simulate_keystroke("b"); cx.assert_state("«ˇaa»\n", Mode::HelixNormal); } #[gpui::test] async fn test_insert_selected(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); cx.set_state( indoc! {" «The ˇ»quick brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("i"); cx.assert_state( indoc! {" ˇThe quick brown fox jumps over the lazy dog."}, Mode::Insert, ); } #[gpui::test] async fn test_append(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // test from the end of the selection cx.set_state( indoc! {" «Theˇ» quick brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("a"); cx.assert_state( indoc! {" Theˇ quick brown fox jumps over the lazy dog."}, Mode::Insert, ); // test from the beginning of the selection cx.set_state( indoc! {" «ˇThe» quick brown fox jumps over the lazy dog."}, Mode::HelixNormal, ); cx.simulate_keystrokes("a"); cx.assert_state( indoc! {" Theˇ quick brown fox jumps over the lazy dog."}, Mode::Insert, ); } #[gpui::test] async fn test_replace(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // No selection (single character) cx.set_state("ˇaa", Mode::HelixNormal); cx.simulate_keystrokes("r x"); cx.assert_state("ˇxa", Mode::HelixNormal); // Cursor at the beginning cx.set_state("«ˇaa»", Mode::HelixNormal); cx.simulate_keystrokes("r x"); cx.assert_state("«ˇxx»", Mode::HelixNormal); // Cursor at the end cx.set_state("«aaˇ»", Mode::HelixNormal); cx.simulate_keystrokes("r x"); cx.assert_state("«xxˇ»", Mode::HelixNormal); } #[gpui::test] async fn test_helix_yank(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // Test yanking current character with no selection cx.set_state("hello ˇworld", Mode::HelixNormal); cx.simulate_keystrokes("y"); // Test cursor remains at the same position after yanking single character cx.assert_state("hello ˇworld", Mode::HelixNormal); cx.shared_clipboard().assert_eq("w"); // Move cursor and yank another character cx.simulate_keystrokes("l"); cx.simulate_keystrokes("y"); cx.shared_clipboard().assert_eq("o"); // Test yanking with existing selection cx.set_state("hello «worlˇ»d", Mode::HelixNormal); cx.simulate_keystrokes("y"); cx.shared_clipboard().assert_eq("worl"); cx.assert_state("hello «worlˇ»d", Mode::HelixNormal); // Test yanking in select mode character by character cx.set_state("hello ˇworld", Mode::HelixNormal); cx.simulate_keystroke("v"); cx.assert_state("hello «wˇ»orld", Mode::HelixSelect); cx.simulate_keystroke("y"); cx.assert_state("hello «wˇ»orld", Mode::HelixNormal); cx.shared_clipboard().assert_eq("w"); } #[gpui::test] async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // First copy some text to clipboard cx.set_state("«hello worldˇ»", Mode::HelixNormal); cx.simulate_keystrokes("y"); // Test paste with shift-r on single cursor cx.set_state("foo ˇbar", Mode::HelixNormal); cx.simulate_keystrokes("shift-r"); cx.assert_state("foo hello worldˇbar", Mode::HelixNormal); // Test paste with shift-r on selection cx.set_state("foo «barˇ» baz", Mode::HelixNormal); cx.simulate_keystrokes("shift-r"); cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal); } #[gpui::test] async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; assert_eq!(cx.mode(), Mode::Normal); cx.enable_helix(); cx.simulate_keystrokes("v"); assert_eq!(cx.mode(), Mode::HelixSelect); cx.simulate_keystrokes("escape"); assert_eq!(cx.mode(), Mode::HelixNormal); } #[gpui::test] async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // Make a modification at a specific location cx.set_state("ˇhello", Mode::HelixNormal); assert_eq!(cx.mode(), Mode::HelixNormal); cx.simulate_keystrokes("i"); assert_eq!(cx.mode(), Mode::Insert); cx.simulate_keystrokes("escape"); assert_eq!(cx.mode(), Mode::HelixNormal); } #[gpui::test] async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); // Make a modification at a specific location cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); cx.simulate_keystrokes("i"); cx.simulate_keystrokes("escape"); cx.simulate_keystrokes("i"); cx.simulate_keystrokes("m o d i f i e d space"); cx.simulate_keystrokes("escape"); // TODO: this fails, because state is no longer helix cx.assert_state( "line one\nline modified ˇtwo\nline three", Mode::HelixNormal, ); // Move cursor away from the modification cx.simulate_keystrokes("up"); // Use "g ." to go back to last modification cx.simulate_keystrokes("g ."); // Verify we're back at the modification location and still in HelixNormal mode cx.assert_state( "line one\nline modifiedˇ two\nline three", Mode::HelixNormal, ); } #[gpui::test] async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state( "line one\nline ˇtwo\nline three\nline four", Mode::HelixNormal, ); cx.simulate_keystrokes("2 x"); cx.assert_state( "line one\n«line two\nline three\nˇ»line four", Mode::HelixNormal, ); // Test extending existing line selection cx.set_state( indoc! {" li«ˇne one li»ne two line three line four"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" «line one line two ˇ»line three line four"}, Mode::HelixNormal, ); // Pressing x in empty line, select next line (because helix considers cursor a selection) cx.set_state( indoc! {" line one ˇ line three line four"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" line one « line three ˇ»line four"}, Mode::HelixNormal, ); // Empty line with count selects extra + count lines cx.set_state( indoc! {" line one ˇ line three line four line five"}, Mode::HelixNormal, ); cx.simulate_keystrokes("2 x"); cx.assert_state( indoc! {" line one « line three line four ˇ»line five"}, Mode::HelixNormal, ); // Compare empty vs non-empty line behavior cx.set_state( indoc! {" ˇnon-empty line line two line three"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" «non-empty line ˇ»line two line three"}, Mode::HelixNormal, ); // Same test but with empty line - should select one extra cx.set_state( indoc! {" ˇ line two line three"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" « line two ˇ»line three"}, Mode::HelixNormal, ); // Test selecting multiple lines with count cx.set_state( indoc! {" ˇline one line two line threeˇ line four line five"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" «line one ˇ»line two «line three ˇ»line four line five"}, Mode::HelixNormal, ); cx.simulate_keystrokes("x"); cx.assert_state( indoc! {" «line one line two line three line four ˇ»line five"}, Mode::HelixNormal, ); } #[gpui::test] async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; assert_eq!(cx.mode(), Mode::Normal); cx.enable_helix(); cx.set_state("ˇhello", Mode::HelixNormal); cx.simulate_keystrokes("l v l l"); cx.assert_state("h«ellˇ»o", Mode::HelixSelect); } #[gpui::test] async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; assert_eq!(cx.mode(), Mode::Normal); cx.enable_helix(); // Start with multiple cursors (no selections) cx.set_state("ˇhello\nˇworld", Mode::HelixNormal); // Enter select mode and move right twice cx.simulate_keystrokes("v l l"); // Each cursor should independently create and extend its own selection cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect); } #[gpui::test] async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("ˇone two", Mode::Normal); cx.simulate_keystrokes("v w"); cx.assert_state("«one tˇ»wo", Mode::Visual); // In Vim, this selects "t". In helix selections stops just before "t" cx.enable_helix(); cx.set_state("ˇone two", Mode::HelixNormal); cx.simulate_keystrokes("v w"); cx.assert_state("«one ˇ»two", Mode::HelixSelect); } #[gpui::test] async fn test_exit_visual_mode(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("ˇone two", Mode::Normal); cx.simulate_keystrokes("v w"); cx.assert_state("«one tˇ»wo", Mode::Visual); cx.simulate_keystrokes("escape"); cx.assert_state("one ˇtwo", Mode::Normal); cx.enable_helix(); cx.set_state("ˇone two", Mode::HelixNormal); cx.simulate_keystrokes("v w"); cx.assert_state("«one ˇ»two", Mode::HelixSelect); cx.simulate_keystrokes("escape"); cx.assert_state("«one ˇ»two", Mode::HelixNormal); } #[gpui::test] async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.enable_helix(); cx.set_state("ˇone two one", Mode::HelixNormal); cx.simulate_keystrokes("x"); cx.assert_state("«one two oneˇ»", Mode::HelixNormal); cx.simulate_keystrokes("s o n e"); cx.run_until_parked(); cx.simulate_keystrokes("enter"); cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal); cx.simulate_keystrokes("x"); cx.simulate_keystrokes("s"); cx.run_until_parked(); cx.simulate_keystrokes("enter"); cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal); // TODO: change "search_in_selection" to not perform any search when in helix select mode with no selection // cx.set_state("ˇstuff one two one", Mode::HelixNormal); // cx.simulate_keystrokes("s o n e enter"); // cx.assert_state("ˇstuff one two one", Mode::HelixNormal); } #[gpui::test] async fn test_helix_select_next_match(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("ˇhello two one two one two one", Mode::Visual); cx.simulate_keystrokes("/ o n e"); cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("n n"); cx.assert_state("«hello two one two one two oˇ»ne", Mode::Visual); cx.set_state("ˇhello two one two one two one", Mode::Normal); cx.simulate_keystrokes("/ o n e"); cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("n n"); cx.assert_state("hello two one two one two ˇone", Mode::Normal); cx.set_state("ˇhello two one two one two one", Mode::Normal); cx.simulate_keystrokes("/ o n e"); cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("n g n g n"); cx.assert_state("hello two one two «one two oneˇ»", Mode::Visual); cx.enable_helix(); cx.set_state("ˇhello two one two one two one", Mode::HelixNormal); cx.simulate_keystrokes("/ o n e"); cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("n n"); cx.assert_state("hello two one two one two «oneˇ»", Mode::HelixNormal); cx.set_state("ˇhello two one two one two one", Mode::HelixSelect); cx.simulate_keystrokes("/ o n e"); cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("n n"); cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); } #[gpui::test] async fn test_helix_substitute(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; cx.set_state("ˇone two", Mode::HelixNormal); cx.simulate_keystrokes("c"); cx.assert_state("ˇne two", Mode::Insert); cx.set_state("«oneˇ» two", Mode::HelixNormal); cx.simulate_keystrokes("c"); cx.assert_state("ˇ two", Mode::Insert); cx.set_state( indoc! {" oneˇ two three "}, Mode::HelixNormal, ); cx.simulate_keystrokes("x c"); cx.assert_state( indoc! {" ˇ three "}, Mode::Insert, ); cx.set_state( indoc! {" one twoˇ three "}, Mode::HelixNormal, ); cx.simulate_keystrokes("c"); cx.assert_state( indoc! {" one twoˇthree "}, Mode::Insert, ); // Helix doesn't set the cursor to the first non-blank one when // replacing lines: it uses language-dependent indent queries instead. cx.set_state( indoc! {" one two « indented three not indentedˇ» "}, Mode::HelixNormal, ); cx.simulate_keystrokes("c"); cx.set_state( indoc! {" one two ˇ "}, Mode::Insert, ); } }