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