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