1use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
2use gpui::{Action, actions};
3use gpui::{Context, Window};
4use language::{CharClassifier, CharKind};
5use text::{Bias, SelectionGoal};
6
7use crate::{
8 Vim,
9 motion::{Motion, right},
10 state::Mode,
11};
12
13actions!(
14 vim,
15 [
16 /// Switches to normal mode after the cursor (Helix-style).
17 HelixNormalAfter,
18 /// Yanks the current selection or character if no selection.
19 HelixYank,
20 /// Inserts at the beginning of the selection.
21 HelixInsert,
22 /// Appends at the end of the selection.
23 HelixAppend,
24 ]
25);
26
27pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
28 Vim::action(editor, cx, Vim::helix_normal_after);
29 Vim::action(editor, cx, Vim::helix_insert);
30 Vim::action(editor, cx, Vim::helix_append);
31 Vim::action(editor, cx, Vim::helix_yank);
32}
33
34impl Vim {
35 pub fn helix_normal_after(
36 &mut self,
37 action: &HelixNormalAfter,
38 window: &mut Window,
39 cx: &mut Context<Self>,
40 ) {
41 if self.active_operator().is_some() {
42 self.operator_stack.clear();
43 self.sync_vim_settings(window, cx);
44 return;
45 }
46 self.stop_recording_immediately(action.boxed_clone(), cx);
47 self.switch_mode(Mode::HelixNormal, false, window, cx);
48 return;
49 }
50
51 pub fn helix_normal_motion(
52 &mut self,
53 motion: Motion,
54 times: Option<usize>,
55 window: &mut Window,
56 cx: &mut Context<Self>,
57 ) {
58 self.helix_move_cursor(motion, times, window, cx);
59 }
60
61 fn helix_find_range_forward(
62 &mut self,
63 times: Option<usize>,
64 window: &mut Window,
65 cx: &mut Context<Self>,
66 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
67 ) {
68 self.update_editor(cx, |_, editor, cx| {
69 editor.change_selections(Default::default(), window, cx, |s| {
70 s.move_with(|map, selection| {
71 let times = times.unwrap_or(1);
72 let new_goal = SelectionGoal::None;
73 let mut head = selection.head();
74 let mut tail = selection.tail();
75
76 if head == map.max_point() {
77 return;
78 }
79
80 // collapse to block cursor
81 if tail < head {
82 tail = movement::left(map, head);
83 } else {
84 tail = head;
85 head = movement::right(map, head);
86 }
87
88 // create a classifier
89 let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
90
91 for _ in 0..times {
92 let (maybe_next_tail, next_head) =
93 movement::find_boundary_trail(map, head, |left, right| {
94 is_boundary(left, right, &classifier)
95 });
96
97 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
98 break;
99 }
100
101 head = next_head;
102 if let Some(next_tail) = maybe_next_tail {
103 tail = next_tail;
104 }
105 }
106
107 selection.set_tail(tail, new_goal);
108 selection.set_head(head, new_goal);
109 });
110 });
111 });
112 }
113
114 fn helix_find_range_backward(
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 self.update_editor(cx, |_, editor, cx| {
122 editor.change_selections(Default::default(), window, cx, |s| {
123 s.move_with(|map, selection| {
124 let times = times.unwrap_or(1);
125 let new_goal = SelectionGoal::None;
126 let mut head = selection.head();
127 let mut tail = selection.tail();
128
129 if head == DisplayPoint::zero() {
130 return;
131 }
132
133 // collapse to block cursor
134 if tail < head {
135 tail = movement::left(map, head);
136 } else {
137 tail = head;
138 head = movement::right(map, head);
139 }
140
141 selection.set_head(head, new_goal);
142 selection.set_tail(tail, new_goal);
143 // flip the selection
144 selection.swap_head_tail();
145 head = selection.head();
146 tail = selection.tail();
147
148 // create a classifier
149 let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
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
167 selection.set_tail(tail, new_goal);
168 selection.set_head(head, new_goal);
169 });
170 })
171 });
172 }
173
174 pub fn helix_move_and_collapse(
175 &mut self,
176 motion: Motion,
177 times: Option<usize>,
178 window: &mut Window,
179 cx: &mut Context<Self>,
180 ) {
181 self.update_editor(cx, |_, editor, cx| {
182 let text_layout_details = editor.text_layout_details(window);
183 editor.change_selections(Default::default(), window, cx, |s| {
184 s.move_with(|map, selection| {
185 let goal = selection.goal;
186 let cursor = if selection.is_empty() || selection.reversed {
187 selection.head()
188 } else {
189 movement::left(map, selection.head())
190 };
191
192 let (point, goal) = motion
193 .move_point(map, cursor, selection.goal, times, &text_layout_details)
194 .unwrap_or((cursor, goal));
195
196 selection.collapse_to(point, goal)
197 })
198 });
199 });
200 }
201
202 pub fn helix_move_cursor(
203 &mut self,
204 motion: Motion,
205 times: Option<usize>,
206 window: &mut Window,
207 cx: &mut Context<Self>,
208 ) {
209 match motion {
210 Motion::NextWordStart { ignore_punctuation } => {
211 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
212 let left_kind = classifier.kind_with(left, ignore_punctuation);
213 let right_kind = classifier.kind_with(right, ignore_punctuation);
214 let at_newline = (left == '\n') ^ (right == '\n');
215
216 let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
217 || at_newline;
218
219 found
220 })
221 }
222 Motion::NextWordEnd { ignore_punctuation } => {
223 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
224 let left_kind = classifier.kind_with(left, ignore_punctuation);
225 let right_kind = classifier.kind_with(right, ignore_punctuation);
226 let at_newline = (left == '\n') ^ (right == '\n');
227
228 let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
229 || at_newline;
230
231 found
232 })
233 }
234 Motion::PreviousWordStart { ignore_punctuation } => {
235 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
236 let left_kind = classifier.kind_with(left, ignore_punctuation);
237 let right_kind = classifier.kind_with(right, ignore_punctuation);
238 let at_newline = (left == '\n') ^ (right == '\n');
239
240 let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
241 || at_newline;
242
243 found
244 })
245 }
246 Motion::PreviousWordEnd { ignore_punctuation } => {
247 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
248 let left_kind = classifier.kind_with(left, ignore_punctuation);
249 let right_kind = classifier.kind_with(right, ignore_punctuation);
250 let at_newline = (left == '\n') ^ (right == '\n');
251
252 let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
253 || at_newline;
254
255 found
256 })
257 }
258 Motion::FindForward { .. } => {
259 self.update_editor(cx, |_, editor, cx| {
260 let text_layout_details = editor.text_layout_details(window);
261 editor.change_selections(Default::default(), window, cx, |s| {
262 s.move_with(|map, selection| {
263 let goal = selection.goal;
264 let cursor = if selection.is_empty() || selection.reversed {
265 selection.head()
266 } else {
267 movement::left(map, selection.head())
268 };
269
270 let (point, goal) = motion
271 .move_point(
272 map,
273 cursor,
274 selection.goal,
275 times,
276 &text_layout_details,
277 )
278 .unwrap_or((cursor, goal));
279 selection.set_tail(selection.head(), goal);
280 selection.set_head(movement::right(map, point), goal);
281 })
282 });
283 });
284 }
285 Motion::FindBackward { .. } => {
286 self.update_editor(cx, |_, editor, cx| {
287 let text_layout_details = editor.text_layout_details(window);
288 editor.change_selections(Default::default(), window, cx, |s| {
289 s.move_with(|map, selection| {
290 let goal = selection.goal;
291 let cursor = if selection.is_empty() || selection.reversed {
292 selection.head()
293 } else {
294 movement::left(map, selection.head())
295 };
296
297 let (point, goal) = motion
298 .move_point(
299 map,
300 cursor,
301 selection.goal,
302 times,
303 &text_layout_details,
304 )
305 .unwrap_or((cursor, goal));
306 selection.set_tail(selection.head(), goal);
307 selection.set_head(point, goal);
308 })
309 });
310 });
311 }
312 _ => self.helix_move_and_collapse(motion, times, window, cx),
313 }
314 }
315
316 pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
317 self.update_editor(cx, |vim, editor, cx| {
318 let has_selection = editor
319 .selections
320 .all_adjusted(cx)
321 .iter()
322 .any(|selection| !selection.is_empty());
323
324 if !has_selection {
325 // If no selection, expand to current character (like 'v' does)
326 editor.change_selections(Default::default(), window, cx, |s| {
327 s.move_with(|map, selection| {
328 let head = selection.head();
329 let new_head = movement::saturating_right(map, head);
330 selection.set_tail(head, SelectionGoal::None);
331 selection.set_head(new_head, SelectionGoal::None);
332 });
333 });
334 vim.yank_selections_content(
335 editor,
336 crate::motion::MotionKind::Exclusive,
337 window,
338 cx,
339 );
340 editor.change_selections(Default::default(), window, cx, |s| {
341 s.move_with(|_map, selection| {
342 selection.collapse_to(selection.start, SelectionGoal::None);
343 });
344 });
345 } else {
346 // Yank the selection(s)
347 vim.yank_selections_content(
348 editor,
349 crate::motion::MotionKind::Exclusive,
350 window,
351 cx,
352 );
353 }
354 });
355 }
356
357 fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
358 self.start_recording(cx);
359 self.update_editor(cx, |_, editor, cx| {
360 editor.change_selections(Default::default(), window, cx, |s| {
361 s.move_with(|_map, selection| {
362 // In helix normal mode, move cursor to start of selection and collapse
363 if !selection.is_empty() {
364 selection.collapse_to(selection.start, SelectionGoal::None);
365 }
366 });
367 });
368 });
369 self.switch_mode(Mode::Insert, false, window, cx);
370 }
371
372 fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
373 self.start_recording(cx);
374 self.switch_mode(Mode::Insert, false, window, cx);
375 self.update_editor(cx, |_, editor, cx| {
376 editor.change_selections(Default::default(), window, cx, |s| {
377 s.move_with(|map, selection| {
378 let point = if selection.is_empty() {
379 right(map, selection.head(), 1)
380 } else {
381 selection.end
382 };
383 selection.collapse_to(point, SelectionGoal::None);
384 });
385 });
386 });
387 }
388
389 pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
390 self.update_editor(cx, |_, editor, cx| {
391 editor.transact(window, cx, |editor, window, cx| {
392 let (map, selections) = editor.selections.all_display(cx);
393
394 // Store selection info for positioning after edit
395 let selection_info: Vec<_> = selections
396 .iter()
397 .map(|selection| {
398 let range = selection.range();
399 let start_offset = range.start.to_offset(&map, Bias::Left);
400 let end_offset = range.end.to_offset(&map, Bias::Left);
401 let was_empty = range.is_empty();
402 let was_reversed = selection.reversed;
403 (
404 map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
405 end_offset - start_offset,
406 was_empty,
407 was_reversed,
408 )
409 })
410 .collect();
411
412 let mut edits = Vec::new();
413 for selection in &selections {
414 let mut range = selection.range();
415
416 // For empty selections, extend to replace one character
417 if range.is_empty() {
418 range.end = movement::saturating_right(&map, range.start);
419 }
420
421 let byte_range = range.start.to_offset(&map, Bias::Left)
422 ..range.end.to_offset(&map, Bias::Left);
423
424 if !byte_range.is_empty() {
425 let replacement_text = text.repeat(byte_range.len());
426 edits.push((byte_range, replacement_text));
427 }
428 }
429
430 editor.edit(edits, cx);
431
432 // Restore selections based on original info
433 let snapshot = editor.buffer().read(cx).snapshot(cx);
434 let ranges: Vec<_> = selection_info
435 .into_iter()
436 .map(|(start_anchor, original_len, was_empty, was_reversed)| {
437 let start_point = start_anchor.to_point(&snapshot);
438 if was_empty {
439 // For cursor-only, collapse to start
440 start_point..start_point
441 } else {
442 // For selections, span the replaced text
443 let replacement_len = text.len() * original_len;
444 let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
445 let end_point = snapshot.offset_to_point(end_offset);
446 if was_reversed {
447 end_point..start_point
448 } else {
449 start_point..end_point
450 }
451 }
452 })
453 .collect();
454
455 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
456 s.select_ranges(ranges);
457 });
458 });
459 });
460 self.switch_mode(Mode::HelixNormal, true, window, cx);
461 }
462}
463
464#[cfg(test)]
465mod test {
466 use indoc::indoc;
467
468 use crate::{state::Mode, test::VimTestContext};
469
470 #[gpui::test]
471 async fn test_word_motions(cx: &mut gpui::TestAppContext) {
472 let mut cx = VimTestContext::new(cx, true).await;
473 // «
474 // ˇ
475 // »
476 cx.set_state(
477 indoc! {"
478 Th«e quiˇ»ck brown
479 fox jumps over
480 the lazy dog."},
481 Mode::HelixNormal,
482 );
483
484 cx.simulate_keystrokes("w");
485
486 cx.assert_state(
487 indoc! {"
488 The qu«ick ˇ»brown
489 fox jumps over
490 the lazy dog."},
491 Mode::HelixNormal,
492 );
493
494 cx.simulate_keystrokes("w");
495
496 cx.assert_state(
497 indoc! {"
498 The quick «brownˇ»
499 fox jumps over
500 the lazy dog."},
501 Mode::HelixNormal,
502 );
503
504 cx.simulate_keystrokes("2 b");
505
506 cx.assert_state(
507 indoc! {"
508 The «ˇquick »brown
509 fox jumps over
510 the lazy dog."},
511 Mode::HelixNormal,
512 );
513
514 cx.simulate_keystrokes("down e up");
515
516 cx.assert_state(
517 indoc! {"
518 The quicˇk brown
519 fox jumps over
520 the lazy dog."},
521 Mode::HelixNormal,
522 );
523
524 cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
525
526 cx.simulate_keystroke("b");
527
528 cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
529 }
530
531 #[gpui::test]
532 async fn test_delete(cx: &mut gpui::TestAppContext) {
533 let mut cx = VimTestContext::new(cx, true).await;
534
535 // test delete a selection
536 cx.set_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("d");
545
546 cx.assert_state(
547 indoc! {"
548 The quˇbrown
549 fox jumps over
550 the lazy dog."},
551 Mode::HelixNormal,
552 );
553
554 // test deleting a single character
555 cx.simulate_keystrokes("d");
556
557 cx.assert_state(
558 indoc! {"
559 The quˇrown
560 fox jumps over
561 the lazy dog."},
562 Mode::HelixNormal,
563 );
564 }
565
566 // #[gpui::test]
567 // async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
568 // let mut cx = VimTestContext::new(cx, true).await;
569
570 // cx.set_state(
571 // indoc! {"
572 // The quick brownˇ
573 // fox jumps over
574 // the lazy dog."},
575 // Mode::HelixNormal,
576 // );
577
578 // cx.simulate_keystrokes("d");
579
580 // cx.assert_state(
581 // indoc! {"
582 // The quick brownˇfox jumps over
583 // the lazy dog."},
584 // Mode::HelixNormal,
585 // );
586 // }
587
588 // #[gpui::test]
589 // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
590 // let mut cx = VimTestContext::new(cx, true).await;
591
592 // cx.set_state(
593 // indoc! {"
594 // The quick brown
595 // fox jumps over
596 // the lazy dog.ˇ"},
597 // Mode::HelixNormal,
598 // );
599
600 // cx.simulate_keystrokes("d");
601
602 // cx.assert_state(
603 // indoc! {"
604 // The quick brown
605 // fox jumps over
606 // the lazy dog.ˇ"},
607 // Mode::HelixNormal,
608 // );
609 // }
610
611 #[gpui::test]
612 async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
613 let mut cx = VimTestContext::new(cx, true).await;
614
615 cx.set_state(
616 indoc! {"
617 The quˇick brown
618 fox jumps over
619 the lazy dog."},
620 Mode::HelixNormal,
621 );
622
623 cx.simulate_keystrokes("f z");
624
625 cx.assert_state(
626 indoc! {"
627 The qu«ick brown
628 fox jumps over
629 the lazˇ»y dog."},
630 Mode::HelixNormal,
631 );
632
633 cx.simulate_keystrokes("2 T r");
634
635 cx.assert_state(
636 indoc! {"
637 The quick br«ˇown
638 fox jumps over
639 the laz»y dog."},
640 Mode::HelixNormal,
641 );
642 }
643
644 #[gpui::test]
645 async fn test_newline_char(cx: &mut gpui::TestAppContext) {
646 let mut cx = VimTestContext::new(cx, true).await;
647
648 cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
649
650 cx.simulate_keystroke("w");
651
652 cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
653
654 cx.set_state("aa«\nˇ»", Mode::HelixNormal);
655
656 cx.simulate_keystroke("b");
657
658 cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
659 }
660
661 #[gpui::test]
662 async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
663 let mut cx = VimTestContext::new(cx, true).await;
664 cx.set_state(
665 indoc! {"
666 «The ˇ»quick brown
667 fox jumps over
668 the lazy dog."},
669 Mode::HelixNormal,
670 );
671
672 cx.simulate_keystrokes("i");
673
674 cx.assert_state(
675 indoc! {"
676 ˇThe quick brown
677 fox jumps over
678 the lazy dog."},
679 Mode::Insert,
680 );
681 }
682
683 #[gpui::test]
684 async fn test_append(cx: &mut gpui::TestAppContext) {
685 let mut cx = VimTestContext::new(cx, true).await;
686 // test from the end of the selection
687 cx.set_state(
688 indoc! {"
689 «Theˇ» quick brown
690 fox jumps over
691 the lazy dog."},
692 Mode::HelixNormal,
693 );
694
695 cx.simulate_keystrokes("a");
696
697 cx.assert_state(
698 indoc! {"
699 Theˇ quick brown
700 fox jumps over
701 the lazy dog."},
702 Mode::Insert,
703 );
704
705 // test from the beginning of the selection
706 cx.set_state(
707 indoc! {"
708 «ˇThe» quick brown
709 fox jumps over
710 the lazy dog."},
711 Mode::HelixNormal,
712 );
713
714 cx.simulate_keystrokes("a");
715
716 cx.assert_state(
717 indoc! {"
718 Theˇ quick brown
719 fox jumps over
720 the lazy dog."},
721 Mode::Insert,
722 );
723 }
724
725 #[gpui::test]
726 async fn test_replace(cx: &mut gpui::TestAppContext) {
727 let mut cx = VimTestContext::new(cx, true).await;
728
729 // No selection (single character)
730 cx.set_state("ˇaa", Mode::HelixNormal);
731
732 cx.simulate_keystrokes("r x");
733
734 cx.assert_state("ˇxa", Mode::HelixNormal);
735
736 // Cursor at the beginning
737 cx.set_state("«ˇaa»", Mode::HelixNormal);
738
739 cx.simulate_keystrokes("r x");
740
741 cx.assert_state("«ˇxx»", Mode::HelixNormal);
742
743 // Cursor at the end
744 cx.set_state("«aaˇ»", Mode::HelixNormal);
745
746 cx.simulate_keystrokes("r x");
747
748 cx.assert_state("«xxˇ»", Mode::HelixNormal);
749 }
750
751 #[gpui::test]
752 async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
753 let mut cx = VimTestContext::new(cx, true).await;
754 cx.enable_helix();
755
756 // Test yanking current character with no selection
757 cx.set_state("hello ˇworld", Mode::HelixNormal);
758 cx.simulate_keystrokes("y");
759
760 // Test cursor remains at the same position after yanking single character
761 cx.assert_state("hello ˇworld", Mode::HelixNormal);
762 cx.shared_clipboard().assert_eq("w");
763
764 // Move cursor and yank another character
765 cx.simulate_keystrokes("l");
766 cx.simulate_keystrokes("y");
767 cx.shared_clipboard().assert_eq("o");
768
769 // Test yanking with existing selection
770 cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
771 cx.simulate_keystrokes("y");
772 cx.shared_clipboard().assert_eq("worl");
773 cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
774 }
775}