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