helix.rs

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