helix.rs

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