1mod boundary;
2mod object;
3mod paste;
4mod select;
5
6use editor::display_map::DisplaySnapshot;
7use editor::{
8 DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, SelectionEffects, ToOffset,
9 ToPoint, movement,
10};
11use gpui::actions;
12use gpui::{Context, Window};
13use language::{CharClassifier, CharKind, Point};
14use search::{BufferSearchBar, SearchOptions};
15use settings::Settings;
16use text::{Bias, SelectionGoal};
17use workspace::searchable;
18use workspace::searchable::FilteredSearchRange;
19
20use crate::motion;
21use crate::state::SearchState;
22use crate::{
23 Vim,
24 motion::{Motion, right},
25 state::Mode,
26};
27
28actions!(
29 vim,
30 [
31 /// Yanks the current selection or character if no selection.
32 HelixYank,
33 /// Inserts at the beginning of the selection.
34 HelixInsert,
35 /// Appends at the end of the selection.
36 HelixAppend,
37 /// Goes to the location of the last modification.
38 HelixGotoLastModification,
39 /// Select entire line or multiple lines, extending downwards.
40 HelixSelectLine,
41 /// Select all matches of a given pattern within the current selection.
42 HelixSelectRegex,
43 ]
44);
45
46pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
47 Vim::action(editor, cx, Vim::helix_select_lines);
48 Vim::action(editor, cx, Vim::helix_insert);
49 Vim::action(editor, cx, Vim::helix_append);
50 Vim::action(editor, cx, Vim::helix_yank);
51 Vim::action(editor, cx, Vim::helix_goto_last_modification);
52 Vim::action(editor, cx, Vim::helix_paste);
53 Vim::action(editor, cx, Vim::helix_select_regex);
54}
55
56impl Vim {
57 pub fn helix_normal_motion(
58 &mut self,
59 motion: Motion,
60 times: Option<usize>,
61 window: &mut Window,
62 cx: &mut Context<Self>,
63 ) {
64 self.helix_move_cursor(motion, times, window, cx);
65 }
66
67 pub fn helix_select_motion(
68 &mut self,
69 motion: Motion,
70 times: Option<usize>,
71 window: &mut Window,
72 cx: &mut Context<Self>,
73 ) {
74 self.update_editor(cx, |_, editor, cx| {
75 let text_layout_details = editor.text_layout_details(window);
76 editor.change_selections(Default::default(), window, cx, |s| {
77 s.move_with(|map, selection| {
78 let current_head = selection.head();
79
80 let Some((new_head, goal)) = motion.move_point(
81 map,
82 current_head,
83 selection.goal,
84 times,
85 &text_layout_details,
86 ) else {
87 return;
88 };
89
90 selection.set_head(new_head, goal);
91 })
92 });
93 });
94 }
95
96 /// Updates all selections based on where the cursors are.
97 fn helix_new_selections(
98 &mut self,
99 window: &mut Window,
100 cx: &mut Context<Self>,
101 mut change: impl FnMut(
102 // the start of the cursor
103 DisplayPoint,
104 &DisplaySnapshot,
105 ) -> Option<(DisplayPoint, DisplayPoint)>,
106 ) {
107 self.update_editor(cx, |_, editor, cx| {
108 editor.change_selections(Default::default(), window, cx, |s| {
109 s.move_with(|map, selection| {
110 let cursor_start = if selection.reversed || selection.is_empty() {
111 selection.head()
112 } else {
113 movement::left(map, selection.head())
114 };
115 let Some((head, tail)) = change(cursor_start, map) else {
116 return;
117 };
118
119 selection.set_head_tail(head, tail, SelectionGoal::None);
120 });
121 });
122 });
123 }
124
125 fn helix_find_range_forward(
126 &mut self,
127 times: Option<usize>,
128 window: &mut Window,
129 cx: &mut Context<Self>,
130 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
131 ) {
132 let times = times.unwrap_or(1);
133 self.helix_new_selections(window, cx, |cursor, map| {
134 let mut head = movement::right(map, cursor);
135 let mut tail = cursor;
136 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
137 if head == map.max_point() {
138 return None;
139 }
140 for _ in 0..times {
141 let (maybe_next_tail, next_head) =
142 movement::find_boundary_trail(map, head, |left, right| {
143 is_boundary(left, right, &classifier)
144 });
145
146 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
147 break;
148 }
149
150 head = next_head;
151 if let Some(next_tail) = maybe_next_tail {
152 tail = next_tail;
153 }
154 }
155 Some((head, tail))
156 });
157 }
158
159 fn helix_find_range_backward(
160 &mut self,
161 times: Option<usize>,
162 window: &mut Window,
163 cx: &mut Context<Self>,
164 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
165 ) {
166 let times = times.unwrap_or(1);
167 self.helix_new_selections(window, cx, |cursor, map| {
168 let mut head = cursor;
169 // The original cursor was one character wide,
170 // but the search starts from the left side of it,
171 // so to include that space the selection must end one character to the right.
172 let mut tail = movement::right(map, cursor);
173 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
174 if head == DisplayPoint::zero() {
175 return None;
176 }
177 for _ in 0..times {
178 let (maybe_next_tail, next_head) =
179 movement::find_preceding_boundary_trail(map, head, |left, right| {
180 is_boundary(left, right, &classifier)
181 });
182
183 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
184 break;
185 }
186
187 head = next_head;
188 if let Some(next_tail) = maybe_next_tail {
189 tail = next_tail;
190 }
191 }
192 Some((head, tail))
193 });
194 }
195
196 pub fn helix_move_and_collapse(
197 &mut self,
198 motion: Motion,
199 times: Option<usize>,
200 window: &mut Window,
201 cx: &mut Context<Self>,
202 ) {
203 self.update_editor(cx, |_, editor, cx| {
204 let text_layout_details = editor.text_layout_details(window);
205 editor.change_selections(Default::default(), window, cx, |s| {
206 s.move_with(|map, selection| {
207 let goal = selection.goal;
208 let cursor = if selection.is_empty() || selection.reversed {
209 selection.head()
210 } else {
211 movement::left(map, selection.head())
212 };
213
214 let (point, goal) = motion
215 .move_point(map, cursor, selection.goal, times, &text_layout_details)
216 .unwrap_or((cursor, goal));
217
218 selection.collapse_to(point, goal)
219 })
220 });
221 });
222 }
223
224 pub fn helix_move_cursor(
225 &mut self,
226 motion: Motion,
227 times: Option<usize>,
228 window: &mut Window,
229 cx: &mut Context<Self>,
230 ) {
231 match motion {
232 Motion::NextWordStart { ignore_punctuation } => {
233 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
234 let left_kind = classifier.kind_with(left, ignore_punctuation);
235 let right_kind = classifier.kind_with(right, ignore_punctuation);
236 let at_newline = (left == '\n') ^ (right == '\n');
237
238 (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
239 })
240 }
241 Motion::NextWordEnd { ignore_punctuation } => {
242 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
243 let left_kind = classifier.kind_with(left, ignore_punctuation);
244 let right_kind = classifier.kind_with(right, ignore_punctuation);
245 let at_newline = (left == '\n') ^ (right == '\n');
246
247 (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
248 })
249 }
250 Motion::PreviousWordStart { ignore_punctuation } => {
251 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
252 let left_kind = classifier.kind_with(left, ignore_punctuation);
253 let right_kind = classifier.kind_with(right, ignore_punctuation);
254 let at_newline = (left == '\n') ^ (right == '\n');
255
256 (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
257 })
258 }
259 Motion::PreviousWordEnd { ignore_punctuation } => {
260 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
261 let left_kind = classifier.kind_with(left, ignore_punctuation);
262 let right_kind = classifier.kind_with(right, ignore_punctuation);
263 let at_newline = (left == '\n') ^ (right == '\n');
264
265 (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
266 })
267 }
268 Motion::FindForward {
269 before,
270 char,
271 mode,
272 smartcase,
273 } => {
274 self.helix_new_selections(window, cx, |cursor, map| {
275 let start = cursor;
276 let mut last_boundary = start;
277 for _ in 0..times.unwrap_or(1) {
278 last_boundary = movement::find_boundary(
279 map,
280 movement::right(map, last_boundary),
281 mode,
282 |left, right| {
283 let current_char = if before { right } else { left };
284 motion::is_character_match(char, current_char, smartcase)
285 },
286 );
287 }
288 Some((last_boundary, start))
289 });
290 }
291 Motion::FindBackward {
292 after,
293 char,
294 mode,
295 smartcase,
296 } => {
297 self.helix_new_selections(window, cx, |cursor, map| {
298 let start = cursor;
299 let mut last_boundary = start;
300 for _ in 0..times.unwrap_or(1) {
301 last_boundary = movement::find_preceding_boundary_display_point(
302 map,
303 last_boundary,
304 mode,
305 |left, right| {
306 let current_char = if after { left } else { right };
307 motion::is_character_match(char, current_char, smartcase)
308 },
309 );
310 }
311 // The original cursor was one character wide,
312 // but the search started from the left side of it,
313 // so to include that space the selection must end one character to the right.
314 Some((last_boundary, movement::right(map, start)))
315 });
316 }
317 _ => self.helix_move_and_collapse(motion, times, window, cx),
318 }
319 }
320
321 pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
322 self.update_editor(cx, |vim, editor, cx| {
323 let has_selection = editor
324 .selections
325 .all_adjusted(cx)
326 .iter()
327 .any(|selection| !selection.is_empty());
328
329 if !has_selection {
330 // If no selection, expand to current character (like 'v' does)
331 editor.change_selections(Default::default(), window, cx, |s| {
332 s.move_with(|map, selection| {
333 let head = selection.head();
334 let new_head = movement::saturating_right(map, head);
335 selection.set_tail(head, SelectionGoal::None);
336 selection.set_head(new_head, SelectionGoal::None);
337 });
338 });
339 vim.yank_selections_content(
340 editor,
341 crate::motion::MotionKind::Exclusive,
342 window,
343 cx,
344 );
345 editor.change_selections(Default::default(), window, cx, |s| {
346 s.move_with(|_map, selection| {
347 selection.collapse_to(selection.start, SelectionGoal::None);
348 });
349 });
350 } else {
351 // Yank the selection(s)
352 vim.yank_selections_content(
353 editor,
354 crate::motion::MotionKind::Exclusive,
355 window,
356 cx,
357 );
358 }
359 });
360
361 // Drop back to normal mode after yanking
362 self.switch_mode(Mode::HelixNormal, true, window, cx);
363 }
364
365 fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
366 self.start_recording(cx);
367 self.update_editor(cx, |_, editor, cx| {
368 editor.change_selections(Default::default(), window, cx, |s| {
369 s.move_with(|_map, selection| {
370 // In helix normal mode, move cursor to start of selection and collapse
371 if !selection.is_empty() {
372 selection.collapse_to(selection.start, SelectionGoal::None);
373 }
374 });
375 });
376 });
377 self.switch_mode(Mode::Insert, false, window, cx);
378 }
379
380 fn helix_select_regex(
381 &mut self,
382 _: &HelixSelectRegex,
383 window: &mut Window,
384 cx: &mut Context<Self>,
385 ) {
386 Vim::take_forced_motion(cx);
387 let Some(pane) = self.pane(window, cx) else {
388 return;
389 };
390 let prior_selections = self.editor_selections(window, cx);
391 pane.update(cx, |pane, cx| {
392 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
393 search_bar.update(cx, |search_bar, cx| {
394 if !search_bar.show(window, cx) {
395 return;
396 }
397
398 search_bar.select_query(window, cx);
399 cx.focus_self(window);
400
401 search_bar.set_replacement(None, cx);
402 let mut options = SearchOptions::NONE;
403 options |= SearchOptions::REGEX;
404 if EditorSettings::get_global(cx).search.case_sensitive {
405 options |= SearchOptions::CASE_SENSITIVE;
406 }
407 search_bar.set_search_options(options, cx);
408 if let Some(search) = search_bar.set_search_within_selection(
409 Some(FilteredSearchRange::Selection),
410 window,
411 cx,
412 ) {
413 cx.spawn_in(window, async move |search_bar, cx| {
414 if search.await.is_ok() {
415 search_bar.update_in(cx, |search_bar, window, cx| {
416 search_bar.activate_current_match(window, cx)
417 })
418 } else {
419 Ok(())
420 }
421 })
422 .detach_and_log_err(cx);
423 }
424 self.search = SearchState {
425 direction: searchable::Direction::Next,
426 count: 1,
427 prior_selections,
428 prior_operator: self.operator_stack.last().cloned(),
429 prior_mode: self.mode,
430 helix_select: true,
431 }
432 });
433 }
434 });
435 self.start_recording(cx);
436 }
437
438 fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
439 self.start_recording(cx);
440 self.switch_mode(Mode::Insert, false, window, cx);
441 self.update_editor(cx, |_, editor, cx| {
442 editor.change_selections(Default::default(), window, cx, |s| {
443 s.move_with(|map, selection| {
444 let point = if selection.is_empty() {
445 right(map, selection.head(), 1)
446 } else {
447 selection.end
448 };
449 selection.collapse_to(point, SelectionGoal::None);
450 });
451 });
452 });
453 }
454
455 pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
456 self.update_editor(cx, |_, editor, cx| {
457 editor.transact(window, cx, |editor, window, cx| {
458 let (map, selections) = editor.selections.all_display(cx);
459
460 // Store selection info for positioning after edit
461 let selection_info: Vec<_> = selections
462 .iter()
463 .map(|selection| {
464 let range = selection.range();
465 let start_offset = range.start.to_offset(&map, Bias::Left);
466 let end_offset = range.end.to_offset(&map, Bias::Left);
467 let was_empty = range.is_empty();
468 let was_reversed = selection.reversed;
469 (
470 map.buffer_snapshot().anchor_before(start_offset),
471 end_offset - start_offset,
472 was_empty,
473 was_reversed,
474 )
475 })
476 .collect();
477
478 let mut edits = Vec::new();
479 for selection in &selections {
480 let mut range = selection.range();
481
482 // For empty selections, extend to replace one character
483 if range.is_empty() {
484 range.end = movement::saturating_right(&map, range.start);
485 }
486
487 let byte_range = range.start.to_offset(&map, Bias::Left)
488 ..range.end.to_offset(&map, Bias::Left);
489
490 if !byte_range.is_empty() {
491 let replacement_text = text.repeat(byte_range.len());
492 edits.push((byte_range, replacement_text));
493 }
494 }
495
496 editor.edit(edits, cx);
497
498 // Restore selections based on original info
499 let snapshot = editor.buffer().read(cx).snapshot(cx);
500 let ranges: Vec<_> = selection_info
501 .into_iter()
502 .map(|(start_anchor, original_len, was_empty, was_reversed)| {
503 let start_point = start_anchor.to_point(&snapshot);
504 if was_empty {
505 // For cursor-only, collapse to start
506 start_point..start_point
507 } else {
508 // For selections, span the replaced text
509 let replacement_len = text.len() * original_len;
510 let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
511 let end_point = snapshot.offset_to_point(end_offset);
512 if was_reversed {
513 end_point..start_point
514 } else {
515 start_point..end_point
516 }
517 }
518 })
519 .collect();
520
521 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
522 s.select_ranges(ranges);
523 });
524 });
525 });
526 self.switch_mode(Mode::HelixNormal, true, window, cx);
527 }
528
529 pub fn helix_goto_last_modification(
530 &mut self,
531 _: &HelixGotoLastModification,
532 window: &mut Window,
533 cx: &mut Context<Self>,
534 ) {
535 self.jump(".".into(), false, false, window, cx);
536 }
537
538 pub fn helix_select_lines(
539 &mut self,
540 _: &HelixSelectLine,
541 window: &mut Window,
542 cx: &mut Context<Self>,
543 ) {
544 let count = Vim::take_count(cx).unwrap_or(1);
545 self.update_editor(cx, |_, editor, cx| {
546 editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
547 let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
548 let mut selections = editor.selections.all::<Point>(cx);
549 let max_point = display_map.buffer_snapshot().max_point();
550 let buffer_snapshot = &display_map.buffer_snapshot();
551
552 for selection in &mut selections {
553 // Start always goes to column 0 of the first selected line
554 let start_row = selection.start.row;
555 let current_end_row = selection.end.row;
556
557 // Check if cursor is on empty line by checking first character
558 let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
559 let first_char = buffer_snapshot.chars_at(line_start_offset).next();
560 let extra_line = if first_char == Some('\n') { 1 } else { 0 };
561
562 let end_row = current_end_row + count as u32 + extra_line;
563
564 selection.start = Point::new(start_row, 0);
565 selection.end = if end_row > max_point.row {
566 max_point
567 } else {
568 Point::new(end_row, 0)
569 };
570 selection.reversed = false;
571 }
572
573 editor.change_selections(Default::default(), window, cx, |s| {
574 s.select(selections);
575 });
576 });
577 }
578}
579
580#[cfg(test)]
581mod test {
582 use indoc::indoc;
583
584 use crate::{state::Mode, test::VimTestContext};
585
586 #[gpui::test]
587 async fn test_word_motions(cx: &mut gpui::TestAppContext) {
588 let mut cx = VimTestContext::new(cx, true).await;
589 cx.enable_helix();
590 // «
591 // ˇ
592 // »
593 cx.set_state(
594 indoc! {"
595 Th«e quiˇ»ck brown
596 fox jumps over
597 the lazy dog."},
598 Mode::HelixNormal,
599 );
600
601 cx.simulate_keystrokes("w");
602
603 cx.assert_state(
604 indoc! {"
605 The qu«ick ˇ»brown
606 fox jumps over
607 the lazy dog."},
608 Mode::HelixNormal,
609 );
610
611 cx.simulate_keystrokes("w");
612
613 cx.assert_state(
614 indoc! {"
615 The quick «brownˇ»
616 fox jumps over
617 the lazy dog."},
618 Mode::HelixNormal,
619 );
620
621 cx.simulate_keystrokes("2 b");
622
623 cx.assert_state(
624 indoc! {"
625 The «ˇquick »brown
626 fox jumps over
627 the lazy dog."},
628 Mode::HelixNormal,
629 );
630
631 cx.simulate_keystrokes("down e up");
632
633 cx.assert_state(
634 indoc! {"
635 The quicˇk brown
636 fox jumps over
637 the lazy dog."},
638 Mode::HelixNormal,
639 );
640
641 cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
642
643 cx.simulate_keystroke("b");
644
645 cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
646 }
647
648 #[gpui::test]
649 async fn test_delete(cx: &mut gpui::TestAppContext) {
650 let mut cx = VimTestContext::new(cx, true).await;
651 cx.enable_helix();
652
653 // test delete a selection
654 cx.set_state(
655 indoc! {"
656 The qu«ick ˇ»brown
657 fox jumps over
658 the lazy dog."},
659 Mode::HelixNormal,
660 );
661
662 cx.simulate_keystrokes("d");
663
664 cx.assert_state(
665 indoc! {"
666 The quˇbrown
667 fox jumps over
668 the lazy dog."},
669 Mode::HelixNormal,
670 );
671
672 // test deleting a single character
673 cx.simulate_keystrokes("d");
674
675 cx.assert_state(
676 indoc! {"
677 The quˇrown
678 fox jumps over
679 the lazy dog."},
680 Mode::HelixNormal,
681 );
682 }
683
684 #[gpui::test]
685 async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
686 let mut cx = VimTestContext::new(cx, true).await;
687
688 cx.set_state(
689 indoc! {"
690 The quick brownˇ
691 fox jumps over
692 the lazy dog."},
693 Mode::HelixNormal,
694 );
695
696 cx.simulate_keystrokes("d");
697
698 cx.assert_state(
699 indoc! {"
700 The quick brownˇfox jumps over
701 the lazy dog."},
702 Mode::HelixNormal,
703 );
704 }
705
706 // #[gpui::test]
707 // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
708 // let mut cx = VimTestContext::new(cx, true).await;
709
710 // cx.set_state(
711 // indoc! {"
712 // The quick brown
713 // fox jumps over
714 // the lazy dog.ˇ"},
715 // Mode::HelixNormal,
716 // );
717
718 // cx.simulate_keystrokes("d");
719
720 // cx.assert_state(
721 // indoc! {"
722 // The quick brown
723 // fox jumps over
724 // the lazy dog.ˇ"},
725 // Mode::HelixNormal,
726 // );
727 // }
728
729 #[gpui::test]
730 async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
731 let mut cx = VimTestContext::new(cx, true).await;
732 cx.enable_helix();
733
734 cx.set_state(
735 indoc! {"
736 The quˇick brown
737 fox jumps over
738 the lazy dog."},
739 Mode::HelixNormal,
740 );
741
742 cx.simulate_keystrokes("f z");
743
744 cx.assert_state(
745 indoc! {"
746 The qu«ick brown
747 fox jumps over
748 the lazˇ»y dog."},
749 Mode::HelixNormal,
750 );
751
752 cx.simulate_keystrokes("F e F e");
753
754 cx.assert_state(
755 indoc! {"
756 The quick brown
757 fox jumps ov«ˇer
758 the» lazy dog."},
759 Mode::HelixNormal,
760 );
761
762 cx.simulate_keystrokes("e 2 F e");
763
764 cx.assert_state(
765 indoc! {"
766 Th«ˇe quick brown
767 fox jumps over»
768 the lazy dog."},
769 Mode::HelixNormal,
770 );
771
772 cx.simulate_keystrokes("t r t r");
773
774 cx.assert_state(
775 indoc! {"
776 The quick «brown
777 fox jumps oveˇ»r
778 the lazy dog."},
779 Mode::HelixNormal,
780 );
781 }
782
783 #[gpui::test]
784 async fn test_newline_char(cx: &mut gpui::TestAppContext) {
785 let mut cx = VimTestContext::new(cx, true).await;
786 cx.enable_helix();
787
788 cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
789
790 cx.simulate_keystroke("w");
791
792 cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
793
794 cx.set_state("aa«\nˇ»", Mode::HelixNormal);
795
796 cx.simulate_keystroke("b");
797
798 cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
799 }
800
801 #[gpui::test]
802 async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
803 let mut cx = VimTestContext::new(cx, true).await;
804 cx.enable_helix();
805 cx.set_state(
806 indoc! {"
807 «The ˇ»quick brown
808 fox jumps over
809 the lazy dog."},
810 Mode::HelixNormal,
811 );
812
813 cx.simulate_keystrokes("i");
814
815 cx.assert_state(
816 indoc! {"
817 ˇThe quick brown
818 fox jumps over
819 the lazy dog."},
820 Mode::Insert,
821 );
822 }
823
824 #[gpui::test]
825 async fn test_append(cx: &mut gpui::TestAppContext) {
826 let mut cx = VimTestContext::new(cx, true).await;
827 cx.enable_helix();
828 // test from the end of the selection
829 cx.set_state(
830 indoc! {"
831 «Theˇ» quick brown
832 fox jumps over
833 the lazy dog."},
834 Mode::HelixNormal,
835 );
836
837 cx.simulate_keystrokes("a");
838
839 cx.assert_state(
840 indoc! {"
841 Theˇ quick brown
842 fox jumps over
843 the lazy dog."},
844 Mode::Insert,
845 );
846
847 // test from the beginning of the selection
848 cx.set_state(
849 indoc! {"
850 «ˇThe» quick brown
851 fox jumps over
852 the lazy dog."},
853 Mode::HelixNormal,
854 );
855
856 cx.simulate_keystrokes("a");
857
858 cx.assert_state(
859 indoc! {"
860 Theˇ quick brown
861 fox jumps over
862 the lazy dog."},
863 Mode::Insert,
864 );
865 }
866
867 #[gpui::test]
868 async fn test_replace(cx: &mut gpui::TestAppContext) {
869 let mut cx = VimTestContext::new(cx, true).await;
870 cx.enable_helix();
871
872 // No selection (single character)
873 cx.set_state("ˇaa", Mode::HelixNormal);
874
875 cx.simulate_keystrokes("r x");
876
877 cx.assert_state("ˇxa", Mode::HelixNormal);
878
879 // Cursor at the beginning
880 cx.set_state("«ˇaa»", Mode::HelixNormal);
881
882 cx.simulate_keystrokes("r x");
883
884 cx.assert_state("«ˇxx»", Mode::HelixNormal);
885
886 // Cursor at the end
887 cx.set_state("«aaˇ»", Mode::HelixNormal);
888
889 cx.simulate_keystrokes("r x");
890
891 cx.assert_state("«xxˇ»", Mode::HelixNormal);
892 }
893
894 #[gpui::test]
895 async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
896 let mut cx = VimTestContext::new(cx, true).await;
897 cx.enable_helix();
898
899 // Test yanking current character with no selection
900 cx.set_state("hello ˇworld", Mode::HelixNormal);
901 cx.simulate_keystrokes("y");
902
903 // Test cursor remains at the same position after yanking single character
904 cx.assert_state("hello ˇworld", Mode::HelixNormal);
905 cx.shared_clipboard().assert_eq("w");
906
907 // Move cursor and yank another character
908 cx.simulate_keystrokes("l");
909 cx.simulate_keystrokes("y");
910 cx.shared_clipboard().assert_eq("o");
911
912 // Test yanking with existing selection
913 cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
914 cx.simulate_keystrokes("y");
915 cx.shared_clipboard().assert_eq("worl");
916 cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
917
918 // Test yanking in select mode character by character
919 cx.set_state("hello ˇworld", Mode::HelixNormal);
920 cx.simulate_keystroke("v");
921 cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
922 cx.simulate_keystroke("y");
923 cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
924 cx.shared_clipboard().assert_eq("w");
925 }
926
927 #[gpui::test]
928 async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
929 let mut cx = VimTestContext::new(cx, true).await;
930 cx.enable_helix();
931
932 // First copy some text to clipboard
933 cx.set_state("«hello worldˇ»", Mode::HelixNormal);
934 cx.simulate_keystrokes("y");
935
936 // Test paste with shift-r on single cursor
937 cx.set_state("foo ˇbar", Mode::HelixNormal);
938 cx.simulate_keystrokes("shift-r");
939
940 cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
941
942 // Test paste with shift-r on selection
943 cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
944 cx.simulate_keystrokes("shift-r");
945
946 cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
947 }
948
949 #[gpui::test]
950 async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
951 let mut cx = VimTestContext::new(cx, true).await;
952
953 assert_eq!(cx.mode(), Mode::Normal);
954 cx.enable_helix();
955
956 cx.simulate_keystrokes("v");
957 assert_eq!(cx.mode(), Mode::HelixSelect);
958 cx.simulate_keystrokes("escape");
959 assert_eq!(cx.mode(), Mode::HelixNormal);
960 }
961
962 #[gpui::test]
963 async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
964 let mut cx = VimTestContext::new(cx, true).await;
965 cx.enable_helix();
966
967 // Make a modification at a specific location
968 cx.set_state("ˇhello", Mode::HelixNormal);
969 assert_eq!(cx.mode(), Mode::HelixNormal);
970 cx.simulate_keystrokes("i");
971 assert_eq!(cx.mode(), Mode::Insert);
972 cx.simulate_keystrokes("escape");
973 assert_eq!(cx.mode(), Mode::HelixNormal);
974 }
975
976 #[gpui::test]
977 async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
978 let mut cx = VimTestContext::new(cx, true).await;
979 cx.enable_helix();
980
981 // Make a modification at a specific location
982 cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
983 cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
984 cx.simulate_keystrokes("i");
985 cx.simulate_keystrokes("escape");
986 cx.simulate_keystrokes("i");
987 cx.simulate_keystrokes("m o d i f i e d space");
988 cx.simulate_keystrokes("escape");
989
990 // TODO: this fails, because state is no longer helix
991 cx.assert_state(
992 "line one\nline modified ˇtwo\nline three",
993 Mode::HelixNormal,
994 );
995
996 // Move cursor away from the modification
997 cx.simulate_keystrokes("up");
998
999 // Use "g ." to go back to last modification
1000 cx.simulate_keystrokes("g .");
1001
1002 // Verify we're back at the modification location and still in HelixNormal mode
1003 cx.assert_state(
1004 "line one\nline modifiedˇ two\nline three",
1005 Mode::HelixNormal,
1006 );
1007 }
1008
1009 #[gpui::test]
1010 async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
1011 let mut cx = VimTestContext::new(cx, true).await;
1012 cx.set_state(
1013 "line one\nline ˇtwo\nline three\nline four",
1014 Mode::HelixNormal,
1015 );
1016 cx.simulate_keystrokes("2 x");
1017 cx.assert_state(
1018 "line one\n«line two\nline three\nˇ»line four",
1019 Mode::HelixNormal,
1020 );
1021
1022 // Test extending existing line selection
1023 cx.set_state(
1024 indoc! {"
1025 li«ˇne one
1026 li»ne two
1027 line three
1028 line four"},
1029 Mode::HelixNormal,
1030 );
1031 cx.simulate_keystrokes("x");
1032 cx.assert_state(
1033 indoc! {"
1034 «line one
1035 line two
1036 ˇ»line three
1037 line four"},
1038 Mode::HelixNormal,
1039 );
1040
1041 // Pressing x in empty line, select next line (because helix considers cursor a selection)
1042 cx.set_state(
1043 indoc! {"
1044 line one
1045 ˇ
1046 line three
1047 line four"},
1048 Mode::HelixNormal,
1049 );
1050 cx.simulate_keystrokes("x");
1051 cx.assert_state(
1052 indoc! {"
1053 line one
1054 «
1055 line three
1056 ˇ»line four"},
1057 Mode::HelixNormal,
1058 );
1059
1060 // Empty line with count selects extra + count lines
1061 cx.set_state(
1062 indoc! {"
1063 line one
1064 ˇ
1065 line three
1066 line four
1067 line five"},
1068 Mode::HelixNormal,
1069 );
1070 cx.simulate_keystrokes("2 x");
1071 cx.assert_state(
1072 indoc! {"
1073 line one
1074 «
1075 line three
1076 line four
1077 ˇ»line five"},
1078 Mode::HelixNormal,
1079 );
1080
1081 // Compare empty vs non-empty line behavior
1082 cx.set_state(
1083 indoc! {"
1084 ˇnon-empty line
1085 line two
1086 line three"},
1087 Mode::HelixNormal,
1088 );
1089 cx.simulate_keystrokes("x");
1090 cx.assert_state(
1091 indoc! {"
1092 «non-empty line
1093 ˇ»line two
1094 line three"},
1095 Mode::HelixNormal,
1096 );
1097
1098 // Same test but with empty line - should select one extra
1099 cx.set_state(
1100 indoc! {"
1101 ˇ
1102 line two
1103 line three"},
1104 Mode::HelixNormal,
1105 );
1106 cx.simulate_keystrokes("x");
1107 cx.assert_state(
1108 indoc! {"
1109 «
1110 line two
1111 ˇ»line three"},
1112 Mode::HelixNormal,
1113 );
1114
1115 // Test selecting multiple lines with count
1116 cx.set_state(
1117 indoc! {"
1118 ˇline one
1119 line two
1120 line threeˇ
1121 line four
1122 line five"},
1123 Mode::HelixNormal,
1124 );
1125 cx.simulate_keystrokes("x");
1126 cx.assert_state(
1127 indoc! {"
1128 «line one
1129 ˇ»line two
1130 «line three
1131 ˇ»line four
1132 line five"},
1133 Mode::HelixNormal,
1134 );
1135 cx.simulate_keystrokes("x");
1136 cx.assert_state(
1137 indoc! {"
1138 «line one
1139 line two
1140 line three
1141 line four
1142 ˇ»line five"},
1143 Mode::HelixNormal,
1144 );
1145 }
1146
1147 #[gpui::test]
1148 async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
1149 let mut cx = VimTestContext::new(cx, true).await;
1150
1151 assert_eq!(cx.mode(), Mode::Normal);
1152 cx.enable_helix();
1153
1154 cx.set_state("ˇhello", Mode::HelixNormal);
1155 cx.simulate_keystrokes("l v l l");
1156 cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
1157 }
1158
1159 #[gpui::test]
1160 async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) {
1161 let mut cx = VimTestContext::new(cx, true).await;
1162
1163 assert_eq!(cx.mode(), Mode::Normal);
1164 cx.enable_helix();
1165
1166 // Start with multiple cursors (no selections)
1167 cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
1168
1169 // Enter select mode and move right twice
1170 cx.simulate_keystrokes("v l l");
1171
1172 // Each cursor should independently create and extend its own selection
1173 cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
1174 }
1175
1176 #[gpui::test]
1177 async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
1178 let mut cx = VimTestContext::new(cx, true).await;
1179
1180 cx.set_state("ˇone two", Mode::Normal);
1181 cx.simulate_keystrokes("v w");
1182 cx.assert_state("«one tˇ»wo", Mode::Visual);
1183
1184 // In Vim, this selects "t". In helix selections stops just before "t"
1185
1186 cx.enable_helix();
1187 cx.set_state("ˇone two", Mode::HelixNormal);
1188 cx.simulate_keystrokes("v w");
1189 cx.assert_state("«one ˇ»two", Mode::HelixSelect);
1190 }
1191
1192 #[gpui::test]
1193 async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) {
1194 let mut cx = VimTestContext::new(cx, true).await;
1195 cx.enable_helix();
1196
1197 cx.set_state("ˇone two one", Mode::HelixNormal);
1198 cx.simulate_keystrokes("x");
1199 cx.assert_state("«one two oneˇ»", Mode::HelixNormal);
1200 cx.simulate_keystrokes("s o n e");
1201 cx.run_until_parked();
1202 cx.simulate_keystrokes("enter");
1203 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
1204
1205 cx.simulate_keystrokes("x");
1206 cx.simulate_keystrokes("s");
1207 cx.run_until_parked();
1208 cx.simulate_keystrokes("enter");
1209 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
1210
1211 cx.set_state("ˇone two one", Mode::HelixNormal);
1212 cx.simulate_keystrokes("s o n e enter");
1213 cx.assert_state("ˇone two one", Mode::HelixNormal);
1214 }
1215}