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