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