helix.rs

   1mod boundary;
   2mod object;
   3mod select;
   4
   5use editor::display_map::DisplaySnapshot;
   6use editor::{
   7    DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,
   8};
   9use gpui::{Action, actions};
  10use gpui::{Context, Window};
  11use language::{CharClassifier, CharKind, Point};
  12use text::{Bias, SelectionGoal};
  13
  14use crate::motion;
  15use crate::{
  16    Vim,
  17    motion::{Motion, right},
  18    state::Mode,
  19};
  20
  21actions!(
  22    vim,
  23    [
  24        /// Switches to normal mode after the cursor (Helix-style).
  25        HelixNormalAfter,
  26        /// Yanks the current selection or character if no selection.
  27        HelixYank,
  28        /// Inserts at the beginning of the selection.
  29        HelixInsert,
  30        /// Appends at the end of the selection.
  31        HelixAppend,
  32        /// Goes to the location of the last modification.
  33        HelixGotoLastModification,
  34        /// Select entire line or multiple lines, extending downwards.
  35        HelixSelectLine,
  36    ]
  37);
  38
  39pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
  40    Vim::action(editor, cx, Vim::helix_normal_after);
  41    Vim::action(editor, cx, Vim::helix_select_lines);
  42    Vim::action(editor, cx, Vim::helix_insert);
  43    Vim::action(editor, cx, Vim::helix_append);
  44    Vim::action(editor, cx, Vim::helix_yank);
  45    Vim::action(editor, cx, Vim::helix_goto_last_modification);
  46}
  47
  48impl Vim {
  49    pub fn helix_normal_after(
  50        &mut self,
  51        action: &HelixNormalAfter,
  52        window: &mut Window,
  53        cx: &mut Context<Self>,
  54    ) {
  55        if self.active_operator().is_some() {
  56            self.operator_stack.clear();
  57            self.sync_vim_settings(window, cx);
  58            return;
  59        }
  60        self.stop_recording_immediately(action.boxed_clone(), cx);
  61        self.switch_mode(Mode::HelixNormal, false, window, cx);
  62    }
  63
  64    pub fn helix_normal_motion(
  65        &mut self,
  66        motion: Motion,
  67        times: Option<usize>,
  68        window: &mut Window,
  69        cx: &mut Context<Self>,
  70    ) {
  71        self.helix_move_cursor(motion, times, window, cx);
  72    }
  73
  74    /// Updates all selections based on where the cursors are.
  75    fn helix_new_selections(
  76        &mut self,
  77        window: &mut Window,
  78        cx: &mut Context<Self>,
  79        mut change: impl FnMut(
  80            // the start of the cursor
  81            DisplayPoint,
  82            &DisplaySnapshot,
  83        ) -> Option<(DisplayPoint, DisplayPoint)>,
  84    ) {
  85        self.update_editor(cx, |_, editor, cx| {
  86            editor.change_selections(Default::default(), window, cx, |s| {
  87                s.move_with(|map, selection| {
  88                    let cursor_start = if selection.reversed || selection.is_empty() {
  89                        selection.head()
  90                    } else {
  91                        movement::left(map, selection.head())
  92                    };
  93                    let Some((head, tail)) = change(cursor_start, map) else {
  94                        return;
  95                    };
  96
  97                    selection.set_head_tail(head, tail, SelectionGoal::None);
  98                });
  99            });
 100        });
 101    }
 102
 103    fn helix_find_range_forward(
 104        &mut self,
 105        times: Option<usize>,
 106        window: &mut Window,
 107        cx: &mut Context<Self>,
 108        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
 109    ) {
 110        let times = times.unwrap_or(1);
 111        self.helix_new_selections(window, cx, |cursor, map| {
 112            let mut head = movement::right(map, cursor);
 113            let mut tail = cursor;
 114            let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
 115            if head == map.max_point() {
 116                return None;
 117            }
 118            for _ in 0..times {
 119                let (maybe_next_tail, next_head) =
 120                    movement::find_boundary_trail(map, head, |left, right| {
 121                        is_boundary(left, right, &classifier)
 122                    });
 123
 124                if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
 125                    break;
 126                }
 127
 128                head = next_head;
 129                if let Some(next_tail) = maybe_next_tail {
 130                    tail = next_tail;
 131                }
 132            }
 133            Some((head, tail))
 134        });
 135    }
 136
 137    fn helix_find_range_backward(
 138        &mut self,
 139        times: Option<usize>,
 140        window: &mut Window,
 141        cx: &mut Context<Self>,
 142        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
 143    ) {
 144        let times = times.unwrap_or(1);
 145        self.helix_new_selections(window, cx, |cursor, map| {
 146            let mut head = cursor;
 147            // The original cursor was one character wide,
 148            // but the search starts from the left side of it,
 149            // so to include that space the selection must end one character to the right.
 150            let mut tail = movement::right(map, cursor);
 151            let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
 152            if head == DisplayPoint::zero() {
 153                return None;
 154            }
 155            for _ in 0..times {
 156                let (maybe_next_tail, next_head) =
 157                    movement::find_preceding_boundary_trail(map, head, |left, right| {
 158                        is_boundary(left, right, &classifier)
 159                    });
 160
 161                if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
 162                    break;
 163                }
 164
 165                head = next_head;
 166                if let Some(next_tail) = maybe_next_tail {
 167                    tail = next_tail;
 168                }
 169            }
 170            Some((head, tail))
 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                    (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
 217                })
 218            }
 219            Motion::NextWordEnd { ignore_punctuation } => {
 220                self.helix_find_range_forward(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::PreviousWordStart { 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 && left_kind != CharKind::Whitespace) || at_newline
 235                })
 236            }
 237            Motion::PreviousWordEnd { ignore_punctuation } => {
 238                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
 239                    let left_kind = classifier.kind_with(left, ignore_punctuation);
 240                    let right_kind = classifier.kind_with(right, ignore_punctuation);
 241                    let at_newline = (left == '\n') ^ (right == '\n');
 242
 243                    (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
 244                })
 245            }
 246            Motion::FindForward {
 247                before,
 248                char,
 249                mode,
 250                smartcase,
 251            } => {
 252                self.helix_new_selections(window, cx, |cursor, map| {
 253                    let start = cursor;
 254                    let mut last_boundary = start;
 255                    for _ in 0..times.unwrap_or(1) {
 256                        last_boundary = movement::find_boundary(
 257                            map,
 258                            movement::right(map, last_boundary),
 259                            mode,
 260                            |left, right| {
 261                                let current_char = if before { right } else { left };
 262                                motion::is_character_match(char, current_char, smartcase)
 263                            },
 264                        );
 265                    }
 266                    Some((last_boundary, start))
 267                });
 268            }
 269            Motion::FindBackward {
 270                after,
 271                char,
 272                mode,
 273                smartcase,
 274            } => {
 275                self.helix_new_selections(window, cx, |cursor, map| {
 276                    let start = cursor;
 277                    let mut last_boundary = start;
 278                    for _ in 0..times.unwrap_or(1) {
 279                        last_boundary = movement::find_preceding_boundary_display_point(
 280                            map,
 281                            last_boundary,
 282                            mode,
 283                            |left, right| {
 284                                let current_char = if after { left } else { right };
 285                                motion::is_character_match(char, current_char, smartcase)
 286                            },
 287                        );
 288                    }
 289                    // The original cursor was one character wide,
 290                    // but the search started from the left side of it,
 291                    // so to include that space the selection must end one character to the right.
 292                    Some((last_boundary, movement::right(map, start)))
 293                });
 294            }
 295            _ => self.helix_move_and_collapse(motion, times, window, cx),
 296        }
 297    }
 298
 299    pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
 300        self.update_editor(cx, |vim, editor, cx| {
 301            let has_selection = editor
 302                .selections
 303                .all_adjusted(cx)
 304                .iter()
 305                .any(|selection| !selection.is_empty());
 306
 307            if !has_selection {
 308                // If no selection, expand to current character (like 'v' does)
 309                editor.change_selections(Default::default(), window, cx, |s| {
 310                    s.move_with(|map, selection| {
 311                        let head = selection.head();
 312                        let new_head = movement::saturating_right(map, head);
 313                        selection.set_tail(head, SelectionGoal::None);
 314                        selection.set_head(new_head, SelectionGoal::None);
 315                    });
 316                });
 317                vim.yank_selections_content(
 318                    editor,
 319                    crate::motion::MotionKind::Exclusive,
 320                    window,
 321                    cx,
 322                );
 323                editor.change_selections(Default::default(), window, cx, |s| {
 324                    s.move_with(|_map, selection| {
 325                        selection.collapse_to(selection.start, SelectionGoal::None);
 326                    });
 327                });
 328            } else {
 329                // Yank the selection(s)
 330                vim.yank_selections_content(
 331                    editor,
 332                    crate::motion::MotionKind::Exclusive,
 333                    window,
 334                    cx,
 335                );
 336            }
 337        });
 338    }
 339
 340    fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
 341        self.start_recording(cx);
 342        self.update_editor(cx, |_, editor, cx| {
 343            editor.change_selections(Default::default(), window, cx, |s| {
 344                s.move_with(|_map, selection| {
 345                    // In helix normal mode, move cursor to start of selection and collapse
 346                    if !selection.is_empty() {
 347                        selection.collapse_to(selection.start, SelectionGoal::None);
 348                    }
 349                });
 350            });
 351        });
 352        self.switch_mode(Mode::Insert, false, window, cx);
 353    }
 354
 355    fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
 356        self.start_recording(cx);
 357        self.switch_mode(Mode::Insert, false, window, cx);
 358        self.update_editor(cx, |_, editor, cx| {
 359            editor.change_selections(Default::default(), window, cx, |s| {
 360                s.move_with(|map, selection| {
 361                    let point = if selection.is_empty() {
 362                        right(map, selection.head(), 1)
 363                    } else {
 364                        selection.end
 365                    };
 366                    selection.collapse_to(point, SelectionGoal::None);
 367                });
 368            });
 369        });
 370    }
 371
 372    pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
 373        self.update_editor(cx, |_, editor, cx| {
 374            editor.transact(window, cx, |editor, window, cx| {
 375                let (map, selections) = editor.selections.all_display(cx);
 376
 377                // Store selection info for positioning after edit
 378                let selection_info: Vec<_> = selections
 379                    .iter()
 380                    .map(|selection| {
 381                        let range = selection.range();
 382                        let start_offset = range.start.to_offset(&map, Bias::Left);
 383                        let end_offset = range.end.to_offset(&map, Bias::Left);
 384                        let was_empty = range.is_empty();
 385                        let was_reversed = selection.reversed;
 386                        (
 387                            map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
 388                            end_offset - start_offset,
 389                            was_empty,
 390                            was_reversed,
 391                        )
 392                    })
 393                    .collect();
 394
 395                let mut edits = Vec::new();
 396                for selection in &selections {
 397                    let mut range = selection.range();
 398
 399                    // For empty selections, extend to replace one character
 400                    if range.is_empty() {
 401                        range.end = movement::saturating_right(&map, range.start);
 402                    }
 403
 404                    let byte_range = range.start.to_offset(&map, Bias::Left)
 405                        ..range.end.to_offset(&map, Bias::Left);
 406
 407                    if !byte_range.is_empty() {
 408                        let replacement_text = text.repeat(byte_range.len());
 409                        edits.push((byte_range, replacement_text));
 410                    }
 411                }
 412
 413                editor.edit(edits, cx);
 414
 415                // Restore selections based on original info
 416                let snapshot = editor.buffer().read(cx).snapshot(cx);
 417                let ranges: Vec<_> = selection_info
 418                    .into_iter()
 419                    .map(|(start_anchor, original_len, was_empty, was_reversed)| {
 420                        let start_point = start_anchor.to_point(&snapshot);
 421                        if was_empty {
 422                            // For cursor-only, collapse to start
 423                            start_point..start_point
 424                        } else {
 425                            // For selections, span the replaced text
 426                            let replacement_len = text.len() * original_len;
 427                            let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
 428                            let end_point = snapshot.offset_to_point(end_offset);
 429                            if was_reversed {
 430                                end_point..start_point
 431                            } else {
 432                                start_point..end_point
 433                            }
 434                        }
 435                    })
 436                    .collect();
 437
 438                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
 439                    s.select_ranges(ranges);
 440                });
 441            });
 442        });
 443        self.switch_mode(Mode::HelixNormal, true, window, cx);
 444    }
 445
 446    pub fn helix_goto_last_modification(
 447        &mut self,
 448        _: &HelixGotoLastModification,
 449        window: &mut Window,
 450        cx: &mut Context<Self>,
 451    ) {
 452        self.jump(".".into(), false, false, window, cx);
 453    }
 454
 455    pub fn helix_select_lines(
 456        &mut self,
 457        _: &HelixSelectLine,
 458        window: &mut Window,
 459        cx: &mut Context<Self>,
 460    ) {
 461        let count = Vim::take_count(cx).unwrap_or(1);
 462        self.update_editor(cx, |_, editor, cx| {
 463            editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
 464            let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
 465            let mut selections = editor.selections.all::<Point>(cx);
 466            let max_point = display_map.buffer_snapshot.max_point();
 467            let buffer_snapshot = &display_map.buffer_snapshot;
 468
 469            for selection in &mut selections {
 470                // Start always goes to column 0 of the first selected line
 471                let start_row = selection.start.row;
 472                let current_end_row = selection.end.row;
 473
 474                // Check if cursor is on empty line by checking first character
 475                let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
 476                let first_char = buffer_snapshot.chars_at(line_start_offset).next();
 477                let extra_line = if first_char == Some('\n') { 1 } else { 0 };
 478
 479                let end_row = current_end_row + count as u32 + extra_line;
 480
 481                selection.start = Point::new(start_row, 0);
 482                selection.end = if end_row > max_point.row {
 483                    max_point
 484                } else {
 485                    Point::new(end_row, 0)
 486                };
 487                selection.reversed = false;
 488            }
 489
 490            editor.change_selections(Default::default(), window, cx, |s| {
 491                s.select(selections);
 492            });
 493        });
 494    }
 495}
 496
 497#[cfg(test)]
 498mod test {
 499    use indoc::indoc;
 500
 501    use crate::{state::Mode, test::VimTestContext};
 502
 503    #[gpui::test]
 504    async fn test_word_motions(cx: &mut gpui::TestAppContext) {
 505        let mut cx = VimTestContext::new(cx, true).await;
 506        cx.enable_helix();
 507        // «
 508        // ˇ
 509        // »
 510        cx.set_state(
 511            indoc! {"
 512            Th«e quiˇ»ck brown
 513            fox jumps over
 514            the lazy dog."},
 515            Mode::HelixNormal,
 516        );
 517
 518        cx.simulate_keystrokes("w");
 519
 520        cx.assert_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("w");
 529
 530        cx.assert_state(
 531            indoc! {"
 532            The quick «brownˇ»
 533            fox jumps over
 534            the lazy dog."},
 535            Mode::HelixNormal,
 536        );
 537
 538        cx.simulate_keystrokes("2 b");
 539
 540        cx.assert_state(
 541            indoc! {"
 542            The «ˇquick »brown
 543            fox jumps over
 544            the lazy dog."},
 545            Mode::HelixNormal,
 546        );
 547
 548        cx.simulate_keystrokes("down e up");
 549
 550        cx.assert_state(
 551            indoc! {"
 552            The quicˇk brown
 553            fox jumps over
 554            the lazy dog."},
 555            Mode::HelixNormal,
 556        );
 557
 558        cx.set_state("aa\n  «ˇbb»", Mode::HelixNormal);
 559
 560        cx.simulate_keystroke("b");
 561
 562        cx.assert_state("aa\n«ˇ  »bb", Mode::HelixNormal);
 563    }
 564
 565    #[gpui::test]
 566    async fn test_delete(cx: &mut gpui::TestAppContext) {
 567        let mut cx = VimTestContext::new(cx, true).await;
 568        cx.enable_helix();
 569
 570        // test delete a selection
 571        cx.set_state(
 572            indoc! {"
 573            The qu«ick ˇ»brown
 574            fox jumps over
 575            the lazy dog."},
 576            Mode::HelixNormal,
 577        );
 578
 579        cx.simulate_keystrokes("d");
 580
 581        cx.assert_state(
 582            indoc! {"
 583            The quˇbrown
 584            fox jumps over
 585            the lazy dog."},
 586            Mode::HelixNormal,
 587        );
 588
 589        // test deleting a single character
 590        cx.simulate_keystrokes("d");
 591
 592        cx.assert_state(
 593            indoc! {"
 594            The quˇrown
 595            fox jumps over
 596            the lazy dog."},
 597            Mode::HelixNormal,
 598        );
 599    }
 600
 601    #[gpui::test]
 602    async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
 603        let mut cx = VimTestContext::new(cx, true).await;
 604
 605        cx.set_state(
 606            indoc! {"
 607            The quick brownˇ
 608            fox jumps over
 609            the lazy dog."},
 610            Mode::HelixNormal,
 611        );
 612
 613        cx.simulate_keystrokes("d");
 614
 615        cx.assert_state(
 616            indoc! {"
 617            The quick brownˇfox jumps over
 618            the lazy dog."},
 619            Mode::HelixNormal,
 620        );
 621    }
 622
 623    // #[gpui::test]
 624    // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
 625    //     let mut cx = VimTestContext::new(cx, true).await;
 626
 627    //     cx.set_state(
 628    //         indoc! {"
 629    //         The quick brown
 630    //         fox jumps over
 631    //         the lazy dog.ˇ"},
 632    //         Mode::HelixNormal,
 633    //     );
 634
 635    //     cx.simulate_keystrokes("d");
 636
 637    //     cx.assert_state(
 638    //         indoc! {"
 639    //         The quick brown
 640    //         fox jumps over
 641    //         the lazy dog.ˇ"},
 642    //         Mode::HelixNormal,
 643    //     );
 644    // }
 645
 646    #[gpui::test]
 647    async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
 648        let mut cx = VimTestContext::new(cx, true).await;
 649        cx.enable_helix();
 650
 651        cx.set_state(
 652            indoc! {"
 653            The quˇick brown
 654            fox jumps over
 655            the lazy dog."},
 656            Mode::HelixNormal,
 657        );
 658
 659        cx.simulate_keystrokes("f z");
 660
 661        cx.assert_state(
 662            indoc! {"
 663                The qu«ick brown
 664                fox jumps over
 665                the lazˇ»y dog."},
 666            Mode::HelixNormal,
 667        );
 668
 669        cx.simulate_keystrokes("F e F e");
 670
 671        cx.assert_state(
 672            indoc! {"
 673                The quick brown
 674                fox jumps ov«ˇer
 675                the» lazy dog."},
 676            Mode::HelixNormal,
 677        );
 678
 679        cx.simulate_keystrokes("e 2 F e");
 680
 681        cx.assert_state(
 682            indoc! {"
 683                Th«ˇe quick brown
 684                fox jumps over»
 685                the lazy dog."},
 686            Mode::HelixNormal,
 687        );
 688
 689        cx.simulate_keystrokes("t r t r");
 690
 691        cx.assert_state(
 692            indoc! {"
 693                The quick «brown
 694                fox jumps oveˇ»r
 695                the lazy dog."},
 696            Mode::HelixNormal,
 697        );
 698    }
 699
 700    #[gpui::test]
 701    async fn test_newline_char(cx: &mut gpui::TestAppContext) {
 702        let mut cx = VimTestContext::new(cx, true).await;
 703        cx.enable_helix();
 704
 705        cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
 706
 707        cx.simulate_keystroke("w");
 708
 709        cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
 710
 711        cx.set_state("aa«\nˇ»", Mode::HelixNormal);
 712
 713        cx.simulate_keystroke("b");
 714
 715        cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
 716    }
 717
 718    #[gpui::test]
 719    async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
 720        let mut cx = VimTestContext::new(cx, true).await;
 721        cx.enable_helix();
 722        cx.set_state(
 723            indoc! {"
 724            «The ˇ»quick brown
 725            fox jumps over
 726            the lazy dog."},
 727            Mode::HelixNormal,
 728        );
 729
 730        cx.simulate_keystrokes("i");
 731
 732        cx.assert_state(
 733            indoc! {"
 734            ˇThe quick brown
 735            fox jumps over
 736            the lazy dog."},
 737            Mode::Insert,
 738        );
 739    }
 740
 741    #[gpui::test]
 742    async fn test_append(cx: &mut gpui::TestAppContext) {
 743        let mut cx = VimTestContext::new(cx, true).await;
 744        cx.enable_helix();
 745        // test from the end of the selection
 746        cx.set_state(
 747            indoc! {"
 748            «Theˇ» quick brown
 749            fox jumps over
 750            the lazy dog."},
 751            Mode::HelixNormal,
 752        );
 753
 754        cx.simulate_keystrokes("a");
 755
 756        cx.assert_state(
 757            indoc! {"
 758            Theˇ quick brown
 759            fox jumps over
 760            the lazy dog."},
 761            Mode::Insert,
 762        );
 763
 764        // test from the beginning of the selection
 765        cx.set_state(
 766            indoc! {"
 767            «ˇThe» quick brown
 768            fox jumps over
 769            the lazy dog."},
 770            Mode::HelixNormal,
 771        );
 772
 773        cx.simulate_keystrokes("a");
 774
 775        cx.assert_state(
 776            indoc! {"
 777            Theˇ quick brown
 778            fox jumps over
 779            the lazy dog."},
 780            Mode::Insert,
 781        );
 782    }
 783
 784    #[gpui::test]
 785    async fn test_replace(cx: &mut gpui::TestAppContext) {
 786        let mut cx = VimTestContext::new(cx, true).await;
 787        cx.enable_helix();
 788
 789        // No selection (single character)
 790        cx.set_state("ˇaa", Mode::HelixNormal);
 791
 792        cx.simulate_keystrokes("r x");
 793
 794        cx.assert_state("ˇxa", Mode::HelixNormal);
 795
 796        // Cursor at the beginning
 797        cx.set_state("«ˇaa»", Mode::HelixNormal);
 798
 799        cx.simulate_keystrokes("r x");
 800
 801        cx.assert_state("«ˇxx»", Mode::HelixNormal);
 802
 803        // Cursor at the end
 804        cx.set_state("«aaˇ»", Mode::HelixNormal);
 805
 806        cx.simulate_keystrokes("r x");
 807
 808        cx.assert_state("«xxˇ»", Mode::HelixNormal);
 809    }
 810
 811    #[gpui::test]
 812    async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
 813        let mut cx = VimTestContext::new(cx, true).await;
 814        cx.enable_helix();
 815
 816        // Test yanking current character with no selection
 817        cx.set_state("hello ˇworld", Mode::HelixNormal);
 818        cx.simulate_keystrokes("y");
 819
 820        // Test cursor remains at the same position after yanking single character
 821        cx.assert_state("hello ˇworld", Mode::HelixNormal);
 822        cx.shared_clipboard().assert_eq("w");
 823
 824        // Move cursor and yank another character
 825        cx.simulate_keystrokes("l");
 826        cx.simulate_keystrokes("y");
 827        cx.shared_clipboard().assert_eq("o");
 828
 829        // Test yanking with existing selection
 830        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
 831        cx.simulate_keystrokes("y");
 832        cx.shared_clipboard().assert_eq("worl");
 833        cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
 834    }
 835    #[gpui::test]
 836    async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
 837        let mut cx = VimTestContext::new(cx, true).await;
 838        cx.enable_helix();
 839
 840        // First copy some text to clipboard
 841        cx.set_state("«hello worldˇ»", Mode::HelixNormal);
 842        cx.simulate_keystrokes("y");
 843
 844        // Test paste with shift-r on single cursor
 845        cx.set_state("foo ˇbar", Mode::HelixNormal);
 846        cx.simulate_keystrokes("shift-r");
 847
 848        cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
 849
 850        // Test paste with shift-r on selection
 851        cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
 852        cx.simulate_keystrokes("shift-r");
 853
 854        cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
 855    }
 856
 857    #[gpui::test]
 858    async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
 859        let mut cx = VimTestContext::new(cx, true).await;
 860        cx.enable_helix();
 861
 862        // Make a modification at a specific location
 863        cx.set_state("ˇhello", Mode::HelixNormal);
 864        assert_eq!(cx.mode(), Mode::HelixNormal);
 865        cx.simulate_keystrokes("i");
 866        assert_eq!(cx.mode(), Mode::Insert);
 867        cx.simulate_keystrokes("escape");
 868        assert_eq!(cx.mode(), Mode::HelixNormal);
 869    }
 870
 871    #[gpui::test]
 872    async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
 873        let mut cx = VimTestContext::new(cx, true).await;
 874        cx.enable_helix();
 875
 876        // Make a modification at a specific location
 877        cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
 878        cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
 879        cx.simulate_keystrokes("i");
 880        cx.simulate_keystrokes("escape");
 881        cx.simulate_keystrokes("i");
 882        cx.simulate_keystrokes("m o d i f i e d space");
 883        cx.simulate_keystrokes("escape");
 884
 885        // TODO: this fails, because state is no longer helix
 886        cx.assert_state(
 887            "line one\nline modified ˇtwo\nline three",
 888            Mode::HelixNormal,
 889        );
 890
 891        // Move cursor away from the modification
 892        cx.simulate_keystrokes("up");
 893
 894        // Use "g ." to go back to last modification
 895        cx.simulate_keystrokes("g .");
 896
 897        // Verify we're back at the modification location and still in HelixNormal mode
 898        cx.assert_state(
 899            "line one\nline modifiedˇ two\nline three",
 900            Mode::HelixNormal,
 901        );
 902    }
 903
 904    #[gpui::test]
 905    async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
 906        let mut cx = VimTestContext::new(cx, true).await;
 907        cx.set_state(
 908            "line one\nline ˇtwo\nline three\nline four",
 909            Mode::HelixNormal,
 910        );
 911        cx.simulate_keystrokes("2 x");
 912        cx.assert_state(
 913            "line one\n«line two\nline three\nˇ»line four",
 914            Mode::HelixNormal,
 915        );
 916
 917        // Test extending existing line selection
 918        cx.set_state(
 919            indoc! {"
 920            li«ˇne one
 921            li»ne two
 922            line three
 923            line four"},
 924            Mode::HelixNormal,
 925        );
 926        cx.simulate_keystrokes("x");
 927        cx.assert_state(
 928            indoc! {"
 929            «line one
 930            line two
 931            ˇ»line three
 932            line four"},
 933            Mode::HelixNormal,
 934        );
 935
 936        // Pressing x in empty line, select next line (because helix considers cursor a selection)
 937        cx.set_state(
 938            indoc! {"
 939            line one
 940            ˇ
 941            line three
 942            line four"},
 943            Mode::HelixNormal,
 944        );
 945        cx.simulate_keystrokes("x");
 946        cx.assert_state(
 947            indoc! {"
 948            line one
 949            «
 950            line three
 951            ˇ»line four"},
 952            Mode::HelixNormal,
 953        );
 954
 955        // Empty line with count selects extra + count lines
 956        cx.set_state(
 957            indoc! {"
 958            line one
 959            ˇ
 960            line three
 961            line four
 962            line five"},
 963            Mode::HelixNormal,
 964        );
 965        cx.simulate_keystrokes("2 x");
 966        cx.assert_state(
 967            indoc! {"
 968            line one
 969            «
 970            line three
 971            line four
 972            ˇ»line five"},
 973            Mode::HelixNormal,
 974        );
 975
 976        // Compare empty vs non-empty line behavior
 977        cx.set_state(
 978            indoc! {"
 979            ˇnon-empty line
 980            line two
 981            line three"},
 982            Mode::HelixNormal,
 983        );
 984        cx.simulate_keystrokes("x");
 985        cx.assert_state(
 986            indoc! {"
 987            «non-empty line
 988            ˇ»line two
 989            line three"},
 990            Mode::HelixNormal,
 991        );
 992
 993        // Same test but with empty line - should select one extra
 994        cx.set_state(
 995            indoc! {"
 996            ˇ
 997            line two
 998            line three"},
 999            Mode::HelixNormal,
1000        );
1001        cx.simulate_keystrokes("x");
1002        cx.assert_state(
1003            indoc! {"
1004            «
1005            line two
1006            ˇ»line three"},
1007            Mode::HelixNormal,
1008        );
1009
1010        // Test selecting multiple lines with count
1011        cx.set_state(
1012            indoc! {"
1013            ˇline one
1014            line two
1015            line threeˇ
1016            line four
1017            line five"},
1018            Mode::HelixNormal,
1019        );
1020        cx.simulate_keystrokes("x");
1021        cx.assert_state(
1022            indoc! {"
1023            «line one
1024            ˇ»line two
1025            «line three
1026            ˇ»line four
1027            line five"},
1028            Mode::HelixNormal,
1029        );
1030        cx.simulate_keystrokes("x");
1031        cx.assert_state(
1032            indoc! {"
1033            «line one
1034            line two
1035            line three
1036            line four
1037            ˇ»line five"},
1038            Mode::HelixNormal,
1039        );
1040    }
1041}