helix.rs

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