helix.rs

  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}