helix.rs

  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}