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