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