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