1use editor::{DisplayPoint, Editor, movement};
2use gpui::{Action, actions};
3use gpui::{Context, Window};
4use language::{CharClassifier, CharKind};
5use text::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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, 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
346#[cfg(test)]
347mod test {
348 use indoc::indoc;
349
350 use crate::{state::Mode, test::VimTestContext};
351
352 #[gpui::test]
353 async fn test_word_motions(cx: &mut gpui::TestAppContext) {
354 let mut cx = VimTestContext::new(cx, true).await;
355 // «
356 // ˇ
357 // »
358 cx.set_state(
359 indoc! {"
360 Th«e quiˇ»ck brown
361 fox jumps over
362 the lazy dog."},
363 Mode::HelixNormal,
364 );
365
366 cx.simulate_keystrokes("w");
367
368 cx.assert_state(
369 indoc! {"
370 The qu«ick ˇ»brown
371 fox jumps over
372 the lazy dog."},
373 Mode::HelixNormal,
374 );
375
376 cx.simulate_keystrokes("w");
377
378 cx.assert_state(
379 indoc! {"
380 The quick «brownˇ»
381 fox jumps over
382 the lazy dog."},
383 Mode::HelixNormal,
384 );
385
386 cx.simulate_keystrokes("2 b");
387
388 cx.assert_state(
389 indoc! {"
390 The «ˇquick »brown
391 fox jumps over
392 the lazy dog."},
393 Mode::HelixNormal,
394 );
395
396 cx.simulate_keystrokes("down e up");
397
398 cx.assert_state(
399 indoc! {"
400 The quicˇk brown
401 fox jumps over
402 the lazy dog."},
403 Mode::HelixNormal,
404 );
405
406 cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
407
408 cx.simulate_keystroke("b");
409
410 cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
411 }
412
413 #[gpui::test]
414 async fn test_delete(cx: &mut gpui::TestAppContext) {
415 let mut cx = VimTestContext::new(cx, true).await;
416
417 // test delete a selection
418 cx.set_state(
419 indoc! {"
420 The qu«ick ˇ»brown
421 fox jumps over
422 the lazy dog."},
423 Mode::HelixNormal,
424 );
425
426 cx.simulate_keystrokes("d");
427
428 cx.assert_state(
429 indoc! {"
430 The quˇbrown
431 fox jumps over
432 the lazy dog."},
433 Mode::HelixNormal,
434 );
435
436 // test deleting a single character
437 cx.simulate_keystrokes("d");
438
439 cx.assert_state(
440 indoc! {"
441 The quˇrown
442 fox jumps over
443 the lazy dog."},
444 Mode::HelixNormal,
445 );
446 }
447
448 // #[gpui::test]
449 // async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
450 // let mut cx = VimTestContext::new(cx, true).await;
451
452 // cx.set_state(
453 // indoc! {"
454 // The quick brownˇ
455 // fox jumps over
456 // the lazy dog."},
457 // Mode::HelixNormal,
458 // );
459
460 // cx.simulate_keystrokes("d");
461
462 // cx.assert_state(
463 // indoc! {"
464 // The quick brownˇfox jumps over
465 // the lazy dog."},
466 // Mode::HelixNormal,
467 // );
468 // }
469
470 // #[gpui::test]
471 // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
472 // let mut cx = VimTestContext::new(cx, true).await;
473
474 // cx.set_state(
475 // indoc! {"
476 // The quick brown
477 // fox jumps over
478 // the lazy dog.ˇ"},
479 // Mode::HelixNormal,
480 // );
481
482 // cx.simulate_keystrokes("d");
483
484 // cx.assert_state(
485 // indoc! {"
486 // The quick brown
487 // fox jumps over
488 // the lazy dog.ˇ"},
489 // Mode::HelixNormal,
490 // );
491 // }
492
493 #[gpui::test]
494 async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
495 let mut cx = VimTestContext::new(cx, true).await;
496
497 cx.set_state(
498 indoc! {"
499 The quˇick brown
500 fox jumps over
501 the lazy dog."},
502 Mode::HelixNormal,
503 );
504
505 cx.simulate_keystrokes("f z");
506
507 cx.assert_state(
508 indoc! {"
509 The qu«ick brown
510 fox jumps over
511 the lazˇ»y dog."},
512 Mode::HelixNormal,
513 );
514
515 cx.simulate_keystrokes("2 T r");
516
517 cx.assert_state(
518 indoc! {"
519 The quick br«ˇown
520 fox jumps over
521 the laz»y dog."},
522 Mode::HelixNormal,
523 );
524 }
525
526 #[gpui::test]
527 async fn test_newline_char(cx: &mut gpui::TestAppContext) {
528 let mut cx = VimTestContext::new(cx, true).await;
529
530 cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
531
532 cx.simulate_keystroke("w");
533
534 cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
535
536 cx.set_state("aa«\nˇ»", Mode::HelixNormal);
537
538 cx.simulate_keystroke("b");
539
540 cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
541 }
542
543 #[gpui::test]
544 async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
545 let mut cx = VimTestContext::new(cx, true).await;
546 cx.set_state(
547 indoc! {"
548 «The ˇ»quick brown
549 fox jumps over
550 the lazy dog."},
551 Mode::HelixNormal,
552 );
553
554 cx.simulate_keystrokes("i");
555
556 cx.assert_state(
557 indoc! {"
558 ˇThe quick brown
559 fox jumps over
560 the lazy dog."},
561 Mode::Insert,
562 );
563 }
564
565 #[gpui::test]
566 async fn test_append(cx: &mut gpui::TestAppContext) {
567 let mut cx = VimTestContext::new(cx, true).await;
568 // test from the end of the selection
569 cx.set_state(
570 indoc! {"
571 «Theˇ» quick brown
572 fox jumps over
573 the lazy dog."},
574 Mode::HelixNormal,
575 );
576
577 cx.simulate_keystrokes("a");
578
579 cx.assert_state(
580 indoc! {"
581 Theˇ quick brown
582 fox jumps over
583 the lazy dog."},
584 Mode::Insert,
585 );
586
587 // test from the beginning of the selection
588 cx.set_state(
589 indoc! {"
590 «ˇThe» quick brown
591 fox jumps over
592 the lazy dog."},
593 Mode::HelixNormal,
594 );
595
596 cx.simulate_keystrokes("a");
597
598 cx.assert_state(
599 indoc! {"
600 Theˇ quick brown
601 fox jumps over
602 the lazy dog."},
603 Mode::Insert,
604 );
605 }
606}