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