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
 351    fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
 352        self.start_recording(cx);
 353        self.update_editor(cx, |_, editor, cx| {
 354            editor.change_selections(Default::default(), window, cx, |s| {
 355                s.move_with(|_map, selection| {
 356                    // In helix normal mode, move cursor to start of selection and collapse
 357                    if !selection.is_empty() {
 358                        selection.collapse_to(selection.start, SelectionGoal::None);
 359                    }
 360                });
 361            });
 362        });
 363        self.switch_mode(Mode::Insert, false, window, cx);
 364    }
 365
 366    fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
 367        self.start_recording(cx);
 368        self.switch_mode(Mode::Insert, false, window, cx);
 369        self.update_editor(cx, |_, editor, cx| {
 370            editor.change_selections(Default::default(), window, cx, |s| {
 371                s.move_with(|map, selection| {
 372                    let point = if selection.is_empty() {
 373                        right(map, selection.head(), 1)
 374                    } else {
 375                        selection.end
 376                    };
 377                    selection.collapse_to(point, SelectionGoal::None);
 378                });
 379            });
 380        });
 381    }
 382
 383    pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
 384        self.update_editor(cx, |_, editor, cx| {
 385            editor.transact(window, cx, |editor, window, cx| {
 386                let (map, selections) = editor.selections.all_display(cx);
 387
 388                // Store selection info for positioning after edit
 389                let selection_info: Vec<_> = selections
 390                    .iter()
 391                    .map(|selection| {
 392                        let range = selection.range();
 393                        let start_offset = range.start.to_offset(&map, Bias::Left);
 394                        let end_offset = range.end.to_offset(&map, Bias::Left);
 395                        let was_empty = range.is_empty();
 396                        let was_reversed = selection.reversed;
 397                        (
 398                            map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
 399                            end_offset - start_offset,
 400                            was_empty,
 401                            was_reversed,
 402                        )
 403                    })
 404                    .collect();
 405
 406                let mut edits = Vec::new();
 407                for selection in &selections {
 408                    let mut range = selection.range();
 409
 410                    // For empty selections, extend to replace one character
 411                    if range.is_empty() {
 412                        range.end = movement::saturating_right(&map, range.start);
 413                    }
 414
 415                    let byte_range = range.start.to_offset(&map, Bias::Left)
 416                        ..range.end.to_offset(&map, Bias::Left);
 417
 418                    if !byte_range.is_empty() {
 419                        let replacement_text = text.repeat(byte_range.len());
 420                        edits.push((byte_range, replacement_text));
 421                    }
 422                }
 423
 424                editor.edit(edits, cx);
 425
 426                // Restore selections based on original info
 427                let snapshot = editor.buffer().read(cx).snapshot(cx);
 428                let ranges: Vec<_> = selection_info
 429                    .into_iter()
 430                    .map(|(start_anchor, original_len, was_empty, was_reversed)| {
 431                        let start_point = start_anchor.to_point(&snapshot);
 432                        if was_empty {
 433                            // For cursor-only, collapse to start
 434                            start_point..start_point
 435                        } else {
 436                            // For selections, span the replaced text
 437                            let replacement_len = text.len() * original_len;
 438                            let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
 439                            let end_point = snapshot.offset_to_point(end_offset);
 440                            if was_reversed {
 441                                end_point..start_point
 442                            } else {
 443                                start_point..end_point
 444                            }
 445                        }
 446                    })
 447                    .collect();
 448
 449                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
 450                    s.select_ranges(ranges);
 451                });
 452            });
 453        });
 454        self.switch_mode(Mode::HelixNormal, true, window, cx);
 455    }
 456
 457    pub fn helix_goto_last_modification(
 458        &mut self,
 459        _: &HelixGotoLastModification,
 460        window: &mut Window,
 461        cx: &mut Context<Self>,
 462    ) {
 463        self.jump(".".into(), false, false, window, cx);
 464    }
 465
 466    pub fn helix_select_lines(
 467        &mut self,
 468        _: &HelixSelectLine,
 469        window: &mut Window,
 470        cx: &mut Context<Self>,
 471    ) {
 472        let count = Vim::take_count(cx).unwrap_or(1);
 473        self.update_editor(cx, |_, editor, cx| {
 474            editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
 475            let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
 476            let mut selections = editor.selections.all::<Point>(cx);
 477            let max_point = display_map.buffer_snapshot.max_point();
 478            let buffer_snapshot = &display_map.buffer_snapshot;
 479
 480            for selection in &mut selections {
 481                // Start always goes to column 0 of the first selected line
 482                let start_row = selection.start.row;
 483                let current_end_row = selection.end.row;
 484
 485                // Check if cursor is on empty line by checking first character
 486                let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
 487                let first_char = buffer_snapshot.chars_at(line_start_offset).next();
 488                let extra_line = if first_char == Some('\n') { 1 } else { 0 };
 489
 490                let end_row = current_end_row + count as u32 + extra_line;
 491
 492                selection.start = Point::new(start_row, 0);
 493                selection.end = if end_row > max_point.row {
 494                    max_point
 495                } else {
 496                    Point::new(end_row, 0)
 497                };
 498                selection.reversed = false;
 499            }
 500
 501            editor.change_selections(Default::default(), window, cx, |s| {
 502                s.select(selections);
 503            });
 504        });
 505    }
 506}
 507
 508#[cfg(test)]
 509mod test {
 510    use indoc::indoc;
 511
 512    use crate::{state::Mode, test::VimTestContext};
 513
 514    #[gpui::test]
 515    async fn test_word_motions(cx: &mut gpui::TestAppContext) {
 516        let mut cx = VimTestContext::new(cx, true).await;
 517        cx.enable_helix();
 518        // «
 519        // ˇ
 520        // »
 521        cx.set_state(
 522            indoc! {"
 523            Th«e quiˇ»ck brown
 524            fox jumps over
 525            the lazy dog."},
 526            Mode::HelixNormal,
 527        );
 528
 529        cx.simulate_keystrokes("w");
 530
 531        cx.assert_state(
 532            indoc! {"
 533            The qu«ick ˇ»brown
 534            fox jumps over
 535            the lazy dog."},
 536            Mode::HelixNormal,
 537        );
 538
 539        cx.simulate_keystrokes("w");
 540
 541        cx.assert_state(
 542            indoc! {"
 543            The quick «brownˇ»
 544            fox jumps over
 545            the lazy dog."},
 546            Mode::HelixNormal,
 547        );
 548
 549        cx.simulate_keystrokes("2 b");
 550
 551        cx.assert_state(
 552            indoc! {"
 553            The «ˇquick »brown
 554            fox jumps over
 555            the lazy dog."},
 556            Mode::HelixNormal,
 557        );
 558
 559        cx.simulate_keystrokes("down e up");
 560
 561        cx.assert_state(
 562            indoc! {"
 563            The quicˇk brown
 564            fox jumps over
 565            the lazy dog."},
 566            Mode::HelixNormal,
 567        );
 568
 569        cx.set_state("aa\n  «ˇbb»", Mode::HelixNormal);
 570
 571        cx.simulate_keystroke("b");
 572
 573        cx.assert_state("aa\n«ˇ  »bb", Mode::HelixNormal);
 574    }
 575
 576    #[gpui::test]
 577    async fn test_delete(cx: &mut gpui::TestAppContext) {
 578        let mut cx = VimTestContext::new(cx, true).await;
 579        cx.enable_helix();
 580
 581        // test delete a selection
 582        cx.set_state(
 583            indoc! {"
 584            The qu«ick ˇ»brown
 585            fox jumps over
 586            the lazy dog."},
 587            Mode::HelixNormal,
 588        );
 589
 590        cx.simulate_keystrokes("d");
 591
 592        cx.assert_state(
 593            indoc! {"
 594            The quˇbrown
 595            fox jumps over
 596            the lazy dog."},
 597            Mode::HelixNormal,
 598        );
 599
 600        // test deleting a single character
 601        cx.simulate_keystrokes("d");
 602
 603        cx.assert_state(
 604            indoc! {"
 605            The quˇrown
 606            fox jumps over
 607            the lazy dog."},
 608            Mode::HelixNormal,
 609        );
 610    }
 611
 612    #[gpui::test]
 613    async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
 614        let mut cx = VimTestContext::new(cx, true).await;
 615
 616        cx.set_state(
 617            indoc! {"
 618            The quick brownˇ
 619            fox jumps over
 620            the lazy dog."},
 621            Mode::HelixNormal,
 622        );
 623
 624        cx.simulate_keystrokes("d");
 625
 626        cx.assert_state(
 627            indoc! {"
 628            The quick brownˇfox jumps over
 629            the lazy dog."},
 630            Mode::HelixNormal,
 631        );
 632    }
 633
 634    // #[gpui::test]
 635    // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
 636    //     let mut cx = VimTestContext::new(cx, true).await;
 637
 638    //     cx.set_state(
 639    //         indoc! {"
 640    //         The quick brown
 641    //         fox jumps over
 642    //         the lazy dog.ˇ"},
 643    //         Mode::HelixNormal,
 644    //     );
 645
 646    //     cx.simulate_keystrokes("d");
 647
 648    //     cx.assert_state(
 649    //         indoc! {"
 650    //         The quick brown
 651    //         fox jumps over
 652    //         the lazy dog.ˇ"},
 653    //         Mode::HelixNormal,
 654    //     );
 655    // }
 656
 657    #[gpui::test]
 658    async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
 659        let mut cx = VimTestContext::new(cx, true).await;
 660        cx.enable_helix();
 661
 662        cx.set_state(
 663            indoc! {"
 664            The quˇick brown
 665            fox jumps over
 666            the lazy dog."},
 667            Mode::HelixNormal,
 668        );
 669
 670        cx.simulate_keystrokes("f z");
 671
 672        cx.assert_state(
 673            indoc! {"
 674                The qu«ick brown
 675                fox jumps over
 676                the lazˇ»y dog."},
 677            Mode::HelixNormal,
 678        );
 679
 680        cx.simulate_keystrokes("F e F e");
 681
 682        cx.assert_state(
 683            indoc! {"
 684                The quick brown
 685                fox jumps ov«ˇer
 686                the» lazy dog."},
 687            Mode::HelixNormal,
 688        );
 689
 690        cx.simulate_keystrokes("e 2 F e");
 691
 692        cx.assert_state(
 693            indoc! {"
 694                Th«ˇe quick brown
 695                fox jumps over»
 696                the lazy dog."},
 697            Mode::HelixNormal,
 698        );
 699
 700        cx.simulate_keystrokes("t r t r");
 701
 702        cx.assert_state(
 703            indoc! {"
 704                The quick «brown
 705                fox jumps oveˇ»r
 706                the lazy dog."},
 707            Mode::HelixNormal,
 708        );
 709    }
 710
 711    #[gpui::test]
 712    async fn test_newline_char(cx: &mut gpui::TestAppContext) {
 713        let mut cx = VimTestContext::new(cx, true).await;
 714        cx.enable_helix();
 715
 716        cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
 717
 718        cx.simulate_keystroke("w");
 719
 720        cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
 721
 722        cx.set_state("aa«\nˇ»", Mode::HelixNormal);
 723
 724        cx.simulate_keystroke("b");
 725
 726        cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
 727    }
 728
 729    #[gpui::test]
 730    async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
 731        let mut cx = VimTestContext::new(cx, true).await;
 732        cx.enable_helix();
 733        cx.set_state(
 734            indoc! {"
 735            «The ˇ»quick brown
 736            fox jumps over
 737            the lazy dog."},
 738            Mode::HelixNormal,
 739        );
 740
 741        cx.simulate_keystrokes("i");
 742
 743        cx.assert_state(
 744            indoc! {"
 745            ˇThe quick brown
 746            fox jumps over
 747            the lazy dog."},
 748            Mode::Insert,
 749        );
 750    }
 751
 752    #[gpui::test]
 753    async fn test_append(cx: &mut gpui::TestAppContext) {
 754        let mut cx = VimTestContext::new(cx, true).await;
 755        cx.enable_helix();
 756        // test from the end of the selection
 757        cx.set_state(
 758            indoc! {"
 759            «Theˇ» quick brown
 760            fox jumps over
 761            the lazy dog."},
 762            Mode::HelixNormal,
 763        );
 764
 765        cx.simulate_keystrokes("a");
 766
 767        cx.assert_state(
 768            indoc! {"
 769            Theˇ quick brown
 770            fox jumps over
 771            the lazy dog."},
 772            Mode::Insert,
 773        );
 774
 775        // test from the beginning of the selection
 776        cx.set_state(
 777            indoc! {"
 778            «ˇThe» quick brown
 779            fox jumps over
 780            the lazy dog."},
 781            Mode::HelixNormal,
 782        );
 783
 784        cx.simulate_keystrokes("a");
 785
 786        cx.assert_state(
 787            indoc! {"
 788            Theˇ quick brown
 789            fox jumps over
 790            the lazy dog."},
 791            Mode::Insert,
 792        );
 793    }
 794
 795    #[gpui::test]
 796    async fn test_replace(cx: &mut gpui::TestAppContext) {
 797        let mut cx = VimTestContext::new(cx, true).await;
 798        cx.enable_helix();
 799
 800        // No selection (single character)
 801        cx.set_state("ˇaa", Mode::HelixNormal);
 802
 803        cx.simulate_keystrokes("r x");
 804
 805        cx.assert_state("ˇxa", Mode::HelixNormal);
 806
 807        // Cursor at the beginning
 808        cx.set_state("«ˇaa»", Mode::HelixNormal);
 809
 810        cx.simulate_keystrokes("r x");
 811
 812        cx.assert_state("«ˇxx»", Mode::HelixNormal);
 813
 814        // Cursor at the end
 815        cx.set_state("«aaˇ»", Mode::HelixNormal);
 816
 817        cx.simulate_keystrokes("r x");
 818
 819        cx.assert_state("«xxˇ»", Mode::HelixNormal);
 820    }
 821
 822    #[gpui::test]
 823    async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
 824        let mut cx = VimTestContext::new(cx, true).await;
 825        cx.enable_helix();
 826
 827        // Test yanking current character with no selection
 828        cx.set_state("hello ˇworld", Mode::HelixNormal);
 829        cx.simulate_keystrokes("y");
 830
 831        // Test cursor remains at the same position after yanking single character
 832        cx.assert_state("hello ˇworld", Mode::HelixNormal);
 833        cx.shared_clipboard().assert_eq("w");
 834
 835        // Move cursor and yank another character
 836        cx.simulate_keystrokes("l");
 837        cx.simulate_keystrokes("y");
 838        cx.shared_clipboard().assert_eq("o");
 839
 840        // Test yanking with existing selection
 841        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
 842        cx.simulate_keystrokes("y");
 843        cx.shared_clipboard().assert_eq("worl");
 844        cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
 845    }
 846    #[gpui::test]
 847    async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
 848        let mut cx = VimTestContext::new(cx, true).await;
 849        cx.enable_helix();
 850
 851        // First copy some text to clipboard
 852        cx.set_state("«hello worldˇ»", Mode::HelixNormal);
 853        cx.simulate_keystrokes("y");
 854
 855        // Test paste with shift-r on single cursor
 856        cx.set_state("foo ˇbar", Mode::HelixNormal);
 857        cx.simulate_keystrokes("shift-r");
 858
 859        cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
 860
 861        // Test paste with shift-r on selection
 862        cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
 863        cx.simulate_keystrokes("shift-r");
 864
 865        cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
 866    }
 867
 868    #[gpui::test]
 869    async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
 870        let mut cx = VimTestContext::new(cx, true).await;
 871
 872        assert_eq!(cx.mode(), Mode::Normal);
 873        cx.enable_helix();
 874
 875        cx.simulate_keystrokes("v");
 876        assert_eq!(cx.mode(), Mode::HelixSelect);
 877        cx.simulate_keystrokes("escape");
 878        assert_eq!(cx.mode(), Mode::HelixNormal);
 879    }
 880
 881    #[gpui::test]
 882    async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
 883        let mut cx = VimTestContext::new(cx, true).await;
 884        cx.enable_helix();
 885
 886        // Make a modification at a specific location
 887        cx.set_state("ˇhello", Mode::HelixNormal);
 888        assert_eq!(cx.mode(), Mode::HelixNormal);
 889        cx.simulate_keystrokes("i");
 890        assert_eq!(cx.mode(), Mode::Insert);
 891        cx.simulate_keystrokes("escape");
 892        assert_eq!(cx.mode(), Mode::HelixNormal);
 893    }
 894
 895    #[gpui::test]
 896    async fn test_goto_last_modification(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("line one\nline ˇtwo\nline three", Mode::HelixNormal);
 902        cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
 903        cx.simulate_keystrokes("i");
 904        cx.simulate_keystrokes("escape");
 905        cx.simulate_keystrokes("i");
 906        cx.simulate_keystrokes("m o d i f i e d space");
 907        cx.simulate_keystrokes("escape");
 908
 909        // TODO: this fails, because state is no longer helix
 910        cx.assert_state(
 911            "line one\nline modified ˇtwo\nline three",
 912            Mode::HelixNormal,
 913        );
 914
 915        // Move cursor away from the modification
 916        cx.simulate_keystrokes("up");
 917
 918        // Use "g ." to go back to last modification
 919        cx.simulate_keystrokes("g .");
 920
 921        // Verify we're back at the modification location and still in HelixNormal mode
 922        cx.assert_state(
 923            "line one\nline modifiedˇ two\nline three",
 924            Mode::HelixNormal,
 925        );
 926    }
 927
 928    #[gpui::test]
 929    async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
 930        let mut cx = VimTestContext::new(cx, true).await;
 931        cx.set_state(
 932            "line one\nline ˇtwo\nline three\nline four",
 933            Mode::HelixNormal,
 934        );
 935        cx.simulate_keystrokes("2 x");
 936        cx.assert_state(
 937            "line one\n«line two\nline three\nˇ»line four",
 938            Mode::HelixNormal,
 939        );
 940
 941        // Test extending existing line selection
 942        cx.set_state(
 943            indoc! {"
 944            li«ˇne one
 945            li»ne two
 946            line three
 947            line four"},
 948            Mode::HelixNormal,
 949        );
 950        cx.simulate_keystrokes("x");
 951        cx.assert_state(
 952            indoc! {"
 953            «line one
 954            line two
 955            ˇ»line three
 956            line four"},
 957            Mode::HelixNormal,
 958        );
 959
 960        // Pressing x in empty line, select next line (because helix considers cursor a selection)
 961        cx.set_state(
 962            indoc! {"
 963            line one
 964            ˇ
 965            line three
 966            line four"},
 967            Mode::HelixNormal,
 968        );
 969        cx.simulate_keystrokes("x");
 970        cx.assert_state(
 971            indoc! {"
 972            line one
 973            «
 974            line three
 975            ˇ»line four"},
 976            Mode::HelixNormal,
 977        );
 978
 979        // Empty line with count selects extra + count lines
 980        cx.set_state(
 981            indoc! {"
 982            line one
 983            ˇ
 984            line three
 985            line four
 986            line five"},
 987            Mode::HelixNormal,
 988        );
 989        cx.simulate_keystrokes("2 x");
 990        cx.assert_state(
 991            indoc! {"
 992            line one
 993            «
 994            line three
 995            line four
 996            ˇ»line five"},
 997            Mode::HelixNormal,
 998        );
 999
1000        // Compare empty vs non-empty line behavior
1001        cx.set_state(
1002            indoc! {"
1003            ˇnon-empty line
1004            line two
1005            line three"},
1006            Mode::HelixNormal,
1007        );
1008        cx.simulate_keystrokes("x");
1009        cx.assert_state(
1010            indoc! {"
1011            «non-empty line
1012            ˇ»line two
1013            line three"},
1014            Mode::HelixNormal,
1015        );
1016
1017        // Same test but with empty line - should select one extra
1018        cx.set_state(
1019            indoc! {"
1020            ˇ
1021            line two
1022            line three"},
1023            Mode::HelixNormal,
1024        );
1025        cx.simulate_keystrokes("x");
1026        cx.assert_state(
1027            indoc! {"
1028            «
1029            line two
1030            ˇ»line three"},
1031            Mode::HelixNormal,
1032        );
1033
1034        // Test selecting multiple lines with count
1035        cx.set_state(
1036            indoc! {"
1037            ˇline one
1038            line two
1039            line threeˇ
1040            line four
1041            line five"},
1042            Mode::HelixNormal,
1043        );
1044        cx.simulate_keystrokes("x");
1045        cx.assert_state(
1046            indoc! {"
1047            «line one
1048            ˇ»line two
1049            «line three
1050            ˇ»line four
1051            line five"},
1052            Mode::HelixNormal,
1053        );
1054        cx.simulate_keystrokes("x");
1055        cx.assert_state(
1056            indoc! {"
1057            «line one
1058            line two
1059            line three
1060            line four
1061            ˇ»line five"},
1062            Mode::HelixNormal,
1063        );
1064    }
1065
1066    #[gpui::test]
1067    async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
1068        let mut cx = VimTestContext::new(cx, true).await;
1069
1070        assert_eq!(cx.mode(), Mode::Normal);
1071        cx.enable_helix();
1072
1073        cx.set_state("ˇhello", Mode::HelixNormal);
1074        cx.simulate_keystrokes("l v l l");
1075        cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
1076    }
1077
1078    #[gpui::test]
1079    async fn test_helix_select_mode_motion_multiple_cursors(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        // Start with multiple cursors (no selections)
1086        cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
1087
1088        // Enter select mode and move right twice
1089        cx.simulate_keystrokes("v l l");
1090
1091        // Each cursor should independently create and extend its own selection
1092        cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
1093    }
1094
1095    #[gpui::test]
1096    async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
1097        let mut cx = VimTestContext::new(cx, true).await;
1098
1099        cx.set_state("ˇone two", Mode::Normal);
1100        cx.simulate_keystrokes("v w");
1101        cx.assert_state("«one tˇ»wo", Mode::Visual);
1102
1103        // In Vim, this selects "t". In helix selections stops just before "t"
1104
1105        cx.enable_helix();
1106        cx.set_state("ˇone two", Mode::HelixNormal);
1107        cx.simulate_keystrokes("v w");
1108        cx.assert_state("«one ˇ»two", Mode::HelixSelect);
1109    }
1110}