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