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        /// Goes to the location of the last modification.
 27        HelixGotoLastModification,
 28    ]
 29);
 30
 31pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 32    Vim::action(editor, cx, Vim::helix_normal_after);
 33    Vim::action(editor, cx, Vim::helix_insert);
 34    Vim::action(editor, cx, Vim::helix_append);
 35    Vim::action(editor, cx, Vim::helix_yank);
 36    Vim::action(editor, cx, Vim::helix_goto_last_modification);
 37}
 38
 39impl Vim {
 40    pub fn helix_normal_after(
 41        &mut self,
 42        action: &HelixNormalAfter,
 43        window: &mut Window,
 44        cx: &mut Context<Self>,
 45    ) {
 46        if self.active_operator().is_some() {
 47            self.operator_stack.clear();
 48            self.sync_vim_settings(window, cx);
 49            return;
 50        }
 51        self.stop_recording_immediately(action.boxed_clone(), cx);
 52        self.switch_mode(Mode::HelixNormal, false, window, cx);
 53    }
 54
 55    pub fn helix_normal_motion(
 56        &mut self,
 57        motion: Motion,
 58        times: Option<usize>,
 59        window: &mut Window,
 60        cx: &mut Context<Self>,
 61    ) {
 62        self.helix_move_cursor(motion, times, window, cx);
 63    }
 64
 65    /// Updates all selections based on where the cursors are.
 66    fn helix_new_selections(
 67        &mut self,
 68        window: &mut Window,
 69        cx: &mut Context<Self>,
 70        mut change: impl FnMut(
 71            // the start of the cursor
 72            DisplayPoint,
 73            &DisplaySnapshot,
 74        ) -> Option<(DisplayPoint, DisplayPoint)>,
 75    ) {
 76        self.update_editor(cx, |_, editor, cx| {
 77            editor.change_selections(Default::default(), window, cx, |s| {
 78                s.move_with(|map, selection| {
 79                    let cursor_start = if selection.reversed || selection.is_empty() {
 80                        selection.head()
 81                    } else {
 82                        movement::left(map, selection.head())
 83                    };
 84                    let Some((head, tail)) = change(cursor_start, map) else {
 85                        return;
 86                    };
 87
 88                    selection.set_head_tail(head, tail, SelectionGoal::None);
 89                });
 90            });
 91        });
 92    }
 93
 94    fn helix_find_range_forward(
 95        &mut self,
 96        times: Option<usize>,
 97        window: &mut Window,
 98        cx: &mut Context<Self>,
 99        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
100    ) {
101        let times = times.unwrap_or(1);
102        self.helix_new_selections(window, cx, |cursor, map| {
103            let mut head = movement::right(map, cursor);
104            let mut tail = cursor;
105            let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
106            if head == map.max_point() {
107                return None;
108            }
109            for _ in 0..times {
110                let (maybe_next_tail, next_head) =
111                    movement::find_boundary_trail(map, head, |left, right| {
112                        is_boundary(left, right, &classifier)
113                    });
114
115                if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
116                    break;
117                }
118
119                head = next_head;
120                if let Some(next_tail) = maybe_next_tail {
121                    tail = next_tail;
122                }
123            }
124            Some((head, tail))
125        });
126    }
127
128    fn helix_find_range_backward(
129        &mut self,
130        times: Option<usize>,
131        window: &mut Window,
132        cx: &mut Context<Self>,
133        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
134    ) {
135        let times = times.unwrap_or(1);
136        self.helix_new_selections(window, cx, |cursor, map| {
137            let mut head = cursor;
138            // The original cursor was one character wide,
139            // but the search starts from the left side of it,
140            // so to include that space the selection must end one character to the right.
141            let mut tail = movement::right(map, cursor);
142            let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
143            if head == DisplayPoint::zero() {
144                return None;
145            }
146            for _ in 0..times {
147                let (maybe_next_tail, next_head) =
148                    movement::find_preceding_boundary_trail(map, head, |left, right| {
149                        is_boundary(left, right, &classifier)
150                    });
151
152                if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
153                    break;
154                }
155
156                head = next_head;
157                if let Some(next_tail) = maybe_next_tail {
158                    tail = next_tail;
159                }
160            }
161            Some((head, tail))
162        });
163    }
164
165    pub fn helix_move_and_collapse(
166        &mut self,
167        motion: Motion,
168        times: Option<usize>,
169        window: &mut Window,
170        cx: &mut Context<Self>,
171    ) {
172        self.update_editor(cx, |_, editor, cx| {
173            let text_layout_details = editor.text_layout_details(window);
174            editor.change_selections(Default::default(), window, cx, |s| {
175                s.move_with(|map, selection| {
176                    let goal = selection.goal;
177                    let cursor = if selection.is_empty() || selection.reversed {
178                        selection.head()
179                    } else {
180                        movement::left(map, selection.head())
181                    };
182
183                    let (point, goal) = motion
184                        .move_point(map, cursor, selection.goal, times, &text_layout_details)
185                        .unwrap_or((cursor, goal));
186
187                    selection.collapse_to(point, goal)
188                })
189            });
190        });
191    }
192
193    pub fn helix_move_cursor(
194        &mut self,
195        motion: Motion,
196        times: Option<usize>,
197        window: &mut Window,
198        cx: &mut Context<Self>,
199    ) {
200        match motion {
201            Motion::NextWordStart { ignore_punctuation } => {
202                self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
203                    let left_kind = classifier.kind_with(left, ignore_punctuation);
204                    let right_kind = classifier.kind_with(right, ignore_punctuation);
205                    let at_newline = (left == '\n') ^ (right == '\n');
206
207                    (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
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                    (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
217                })
218            }
219            Motion::PreviousWordStart { ignore_punctuation } => {
220                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
221                    let left_kind = classifier.kind_with(left, ignore_punctuation);
222                    let right_kind = classifier.kind_with(right, ignore_punctuation);
223                    let at_newline = (left == '\n') ^ (right == '\n');
224
225                    (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
226                })
227            }
228            Motion::PreviousWordEnd { ignore_punctuation } => {
229                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
230                    let left_kind = classifier.kind_with(left, ignore_punctuation);
231                    let right_kind = classifier.kind_with(right, ignore_punctuation);
232                    let at_newline = (left == '\n') ^ (right == '\n');
233
234                    (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
235                })
236            }
237            Motion::FindForward {
238                before,
239                char,
240                mode,
241                smartcase,
242            } => {
243                self.helix_new_selections(window, cx, |cursor, map| {
244                    let start = cursor;
245                    let mut last_boundary = start;
246                    for _ in 0..times.unwrap_or(1) {
247                        last_boundary = movement::find_boundary(
248                            map,
249                            movement::right(map, last_boundary),
250                            mode,
251                            |left, right| {
252                                let current_char = if before { right } else { left };
253                                motion::is_character_match(char, current_char, smartcase)
254                            },
255                        );
256                    }
257                    Some((last_boundary, start))
258                });
259            }
260            Motion::FindBackward {
261                after,
262                char,
263                mode,
264                smartcase,
265            } => {
266                self.helix_new_selections(window, cx, |cursor, map| {
267                    let start = cursor;
268                    let mut last_boundary = start;
269                    for _ in 0..times.unwrap_or(1) {
270                        last_boundary = movement::find_preceding_boundary_display_point(
271                            map,
272                            last_boundary,
273                            mode,
274                            |left, right| {
275                                let current_char = if after { left } else { right };
276                                motion::is_character_match(char, current_char, smartcase)
277                            },
278                        );
279                    }
280                    // The original cursor was one character wide,
281                    // but the search started from the left side of it,
282                    // so to include that space the selection must end one character to the right.
283                    Some((last_boundary, movement::right(map, start)))
284                });
285            }
286            _ => self.helix_move_and_collapse(motion, times, window, cx),
287        }
288    }
289
290    pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
291        self.update_editor(cx, |vim, editor, cx| {
292            let has_selection = editor
293                .selections
294                .all_adjusted(cx)
295                .iter()
296                .any(|selection| !selection.is_empty());
297
298            if !has_selection {
299                // If no selection, expand to current character (like 'v' does)
300                editor.change_selections(Default::default(), window, cx, |s| {
301                    s.move_with(|map, selection| {
302                        let head = selection.head();
303                        let new_head = movement::saturating_right(map, head);
304                        selection.set_tail(head, SelectionGoal::None);
305                        selection.set_head(new_head, SelectionGoal::None);
306                    });
307                });
308                vim.yank_selections_content(
309                    editor,
310                    crate::motion::MotionKind::Exclusive,
311                    window,
312                    cx,
313                );
314                editor.change_selections(Default::default(), window, cx, |s| {
315                    s.move_with(|_map, selection| {
316                        selection.collapse_to(selection.start, SelectionGoal::None);
317                    });
318                });
319            } else {
320                // Yank the selection(s)
321                vim.yank_selections_content(
322                    editor,
323                    crate::motion::MotionKind::Exclusive,
324                    window,
325                    cx,
326                );
327            }
328        });
329    }
330
331    fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
332        self.start_recording(cx);
333        self.update_editor(cx, |_, editor, cx| {
334            editor.change_selections(Default::default(), window, cx, |s| {
335                s.move_with(|_map, selection| {
336                    // In helix normal mode, move cursor to start of selection and collapse
337                    if !selection.is_empty() {
338                        selection.collapse_to(selection.start, SelectionGoal::None);
339                    }
340                });
341            });
342        });
343        self.switch_mode(Mode::Insert, false, window, cx);
344    }
345
346    fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
347        self.start_recording(cx);
348        self.switch_mode(Mode::Insert, false, window, cx);
349        self.update_editor(cx, |_, editor, cx| {
350            editor.change_selections(Default::default(), window, cx, |s| {
351                s.move_with(|map, selection| {
352                    let point = if selection.is_empty() {
353                        right(map, selection.head(), 1)
354                    } else {
355                        selection.end
356                    };
357                    selection.collapse_to(point, SelectionGoal::None);
358                });
359            });
360        });
361    }
362
363    pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
364        self.update_editor(cx, |_, editor, cx| {
365            editor.transact(window, cx, |editor, window, cx| {
366                let (map, selections) = editor.selections.all_display(cx);
367
368                // Store selection info for positioning after edit
369                let selection_info: Vec<_> = selections
370                    .iter()
371                    .map(|selection| {
372                        let range = selection.range();
373                        let start_offset = range.start.to_offset(&map, Bias::Left);
374                        let end_offset = range.end.to_offset(&map, Bias::Left);
375                        let was_empty = range.is_empty();
376                        let was_reversed = selection.reversed;
377                        (
378                            map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
379                            end_offset - start_offset,
380                            was_empty,
381                            was_reversed,
382                        )
383                    })
384                    .collect();
385
386                let mut edits = Vec::new();
387                for selection in &selections {
388                    let mut range = selection.range();
389
390                    // For empty selections, extend to replace one character
391                    if range.is_empty() {
392                        range.end = movement::saturating_right(&map, range.start);
393                    }
394
395                    let byte_range = range.start.to_offset(&map, Bias::Left)
396                        ..range.end.to_offset(&map, Bias::Left);
397
398                    if !byte_range.is_empty() {
399                        let replacement_text = text.repeat(byte_range.len());
400                        edits.push((byte_range, replacement_text));
401                    }
402                }
403
404                editor.edit(edits, cx);
405
406                // Restore selections based on original info
407                let snapshot = editor.buffer().read(cx).snapshot(cx);
408                let ranges: Vec<_> = selection_info
409                    .into_iter()
410                    .map(|(start_anchor, original_len, was_empty, was_reversed)| {
411                        let start_point = start_anchor.to_point(&snapshot);
412                        if was_empty {
413                            // For cursor-only, collapse to start
414                            start_point..start_point
415                        } else {
416                            // For selections, span the replaced text
417                            let replacement_len = text.len() * original_len;
418                            let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
419                            let end_point = snapshot.offset_to_point(end_offset);
420                            if was_reversed {
421                                end_point..start_point
422                            } else {
423                                start_point..end_point
424                            }
425                        }
426                    })
427                    .collect();
428
429                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
430                    s.select_ranges(ranges);
431                });
432            });
433        });
434        self.switch_mode(Mode::HelixNormal, true, window, cx);
435    }
436
437    pub fn helix_goto_last_modification(
438        &mut self,
439        _: &HelixGotoLastModification,
440        window: &mut Window,
441        cx: &mut Context<Self>,
442    ) {
443        self.jump(".".into(), false, false, 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        cx.enable_helix();
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        cx.enable_helix();
519
520        // test delete a selection
521        cx.set_state(
522            indoc! {"
523            The qu«ick ˇ»brown
524            fox jumps over
525            the lazy dog."},
526            Mode::HelixNormal,
527        );
528
529        cx.simulate_keystrokes("d");
530
531        cx.assert_state(
532            indoc! {"
533            The quˇbrown
534            fox jumps over
535            the lazy dog."},
536            Mode::HelixNormal,
537        );
538
539        // test deleting a single character
540        cx.simulate_keystrokes("d");
541
542        cx.assert_state(
543            indoc! {"
544            The quˇrown
545            fox jumps over
546            the lazy dog."},
547            Mode::HelixNormal,
548        );
549    }
550
551    #[gpui::test]
552    async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
553        let mut cx = VimTestContext::new(cx, true).await;
554
555        cx.set_state(
556            indoc! {"
557            The quick brownˇ
558            fox jumps over
559            the lazy dog."},
560            Mode::HelixNormal,
561        );
562
563        cx.simulate_keystrokes("d");
564
565        cx.assert_state(
566            indoc! {"
567            The quick brownˇfox jumps over
568            the lazy dog."},
569            Mode::HelixNormal,
570        );
571    }
572
573    // #[gpui::test]
574    // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
575    //     let mut cx = VimTestContext::new(cx, true).await;
576
577    //     cx.set_state(
578    //         indoc! {"
579    //         The quick brown
580    //         fox jumps over
581    //         the lazy dog.ˇ"},
582    //         Mode::HelixNormal,
583    //     );
584
585    //     cx.simulate_keystrokes("d");
586
587    //     cx.assert_state(
588    //         indoc! {"
589    //         The quick brown
590    //         fox jumps over
591    //         the lazy dog.ˇ"},
592    //         Mode::HelixNormal,
593    //     );
594    // }
595
596    #[gpui::test]
597    async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
598        let mut cx = VimTestContext::new(cx, true).await;
599        cx.enable_helix();
600
601        cx.set_state(
602            indoc! {"
603            The quˇick brown
604            fox jumps over
605            the lazy dog."},
606            Mode::HelixNormal,
607        );
608
609        cx.simulate_keystrokes("f z");
610
611        cx.assert_state(
612            indoc! {"
613                The qu«ick brown
614                fox jumps over
615                the lazˇ»y dog."},
616            Mode::HelixNormal,
617        );
618
619        cx.simulate_keystrokes("F e F e");
620
621        cx.assert_state(
622            indoc! {"
623                The quick brown
624                fox jumps ov«ˇer
625                the» lazy dog."},
626            Mode::HelixNormal,
627        );
628
629        cx.simulate_keystrokes("e 2 F e");
630
631        cx.assert_state(
632            indoc! {"
633                Th«ˇe quick brown
634                fox jumps over»
635                the lazy dog."},
636            Mode::HelixNormal,
637        );
638
639        cx.simulate_keystrokes("t r t r");
640
641        cx.assert_state(
642            indoc! {"
643                The quick «brown
644                fox jumps oveˇ»r
645                the lazy dog."},
646            Mode::HelixNormal,
647        );
648    }
649
650    #[gpui::test]
651    async fn test_newline_char(cx: &mut gpui::TestAppContext) {
652        let mut cx = VimTestContext::new(cx, true).await;
653        cx.enable_helix();
654
655        cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
656
657        cx.simulate_keystroke("w");
658
659        cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
660
661        cx.set_state("aa«\nˇ»", Mode::HelixNormal);
662
663        cx.simulate_keystroke("b");
664
665        cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
666    }
667
668    #[gpui::test]
669    async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
670        let mut cx = VimTestContext::new(cx, true).await;
671        cx.enable_helix();
672        cx.set_state(
673            indoc! {"
674            «The ˇ»quick brown
675            fox jumps over
676            the lazy dog."},
677            Mode::HelixNormal,
678        );
679
680        cx.simulate_keystrokes("i");
681
682        cx.assert_state(
683            indoc! {"
684            ˇThe quick brown
685            fox jumps over
686            the lazy dog."},
687            Mode::Insert,
688        );
689    }
690
691    #[gpui::test]
692    async fn test_append(cx: &mut gpui::TestAppContext) {
693        let mut cx = VimTestContext::new(cx, true).await;
694        cx.enable_helix();
695        // test from the end of the selection
696        cx.set_state(
697            indoc! {"
698            «Theˇ» quick brown
699            fox jumps over
700            the lazy dog."},
701            Mode::HelixNormal,
702        );
703
704        cx.simulate_keystrokes("a");
705
706        cx.assert_state(
707            indoc! {"
708            Theˇ quick brown
709            fox jumps over
710            the lazy dog."},
711            Mode::Insert,
712        );
713
714        // test from the beginning of the selection
715        cx.set_state(
716            indoc! {"
717            «ˇThe» quick brown
718            fox jumps over
719            the lazy dog."},
720            Mode::HelixNormal,
721        );
722
723        cx.simulate_keystrokes("a");
724
725        cx.assert_state(
726            indoc! {"
727            Theˇ quick brown
728            fox jumps over
729            the lazy dog."},
730            Mode::Insert,
731        );
732    }
733
734    #[gpui::test]
735    async fn test_replace(cx: &mut gpui::TestAppContext) {
736        let mut cx = VimTestContext::new(cx, true).await;
737        cx.enable_helix();
738
739        // No selection (single character)
740        cx.set_state("ˇaa", Mode::HelixNormal);
741
742        cx.simulate_keystrokes("r x");
743
744        cx.assert_state("ˇxa", Mode::HelixNormal);
745
746        // Cursor at the beginning
747        cx.set_state("«ˇaa»", Mode::HelixNormal);
748
749        cx.simulate_keystrokes("r x");
750
751        cx.assert_state("«ˇxx»", Mode::HelixNormal);
752
753        // Cursor at the end
754        cx.set_state("«aaˇ»", Mode::HelixNormal);
755
756        cx.simulate_keystrokes("r x");
757
758        cx.assert_state("«xxˇ»", Mode::HelixNormal);
759    }
760
761    #[gpui::test]
762    async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
763        let mut cx = VimTestContext::new(cx, true).await;
764        cx.enable_helix();
765
766        // Test yanking current character with no selection
767        cx.set_state("hello ˇworld", Mode::HelixNormal);
768        cx.simulate_keystrokes("y");
769
770        // Test cursor remains at the same position after yanking single character
771        cx.assert_state("hello ˇworld", Mode::HelixNormal);
772        cx.shared_clipboard().assert_eq("w");
773
774        // Move cursor and yank another character
775        cx.simulate_keystrokes("l");
776        cx.simulate_keystrokes("y");
777        cx.shared_clipboard().assert_eq("o");
778
779        // Test yanking with existing selection
780        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
781        cx.simulate_keystrokes("y");
782        cx.shared_clipboard().assert_eq("worl");
783        cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
784    }
785    #[gpui::test]
786    async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
787        let mut cx = VimTestContext::new(cx, true).await;
788        cx.enable_helix();
789
790        // First copy some text to clipboard
791        cx.set_state("«hello worldˇ»", Mode::HelixNormal);
792        cx.simulate_keystrokes("y");
793
794        // Test paste with shift-r on single cursor
795        cx.set_state("foo ˇbar", Mode::HelixNormal);
796        cx.simulate_keystrokes("shift-r");
797
798        cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
799
800        // Test paste with shift-r on selection
801        cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
802        cx.simulate_keystrokes("shift-r");
803
804        cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
805    }
806
807    #[gpui::test]
808    async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
809        let mut cx = VimTestContext::new(cx, true).await;
810        cx.enable_helix();
811
812        // Make a modification at a specific location
813        cx.set_state("ˇhello", Mode::HelixNormal);
814        assert_eq!(cx.mode(), Mode::HelixNormal);
815        cx.simulate_keystrokes("i");
816        assert_eq!(cx.mode(), Mode::Insert);
817        cx.simulate_keystrokes("escape");
818        assert_eq!(cx.mode(), Mode::HelixNormal);
819    }
820
821    #[gpui::test]
822    async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
823        let mut cx = VimTestContext::new(cx, true).await;
824        cx.enable_helix();
825
826        // Make a modification at a specific location
827        cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
828        cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
829        cx.simulate_keystrokes("i");
830        cx.simulate_keystrokes("escape");
831        cx.simulate_keystrokes("i");
832        cx.simulate_keystrokes("m o d i f i e d space");
833        cx.simulate_keystrokes("escape");
834
835        // TODO: this fails, because state is no longer helix
836        cx.assert_state(
837            "line one\nline modified ˇtwo\nline three",
838            Mode::HelixNormal,
839        );
840
841        // Move cursor away from the modification
842        cx.simulate_keystrokes("up");
843
844        // Use "g ." to go back to last modification
845        cx.simulate_keystrokes("g .");
846
847        // Verify we're back at the modification location and still in HelixNormal mode
848        cx.assert_state(
849            "line one\nline modifiedˇ two\nline three",
850            Mode::HelixNormal,
851        );
852    }
853}