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