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