helix.rs

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