visual.rs

   1use std::sync::Arc;
   2
   3use collections::HashMap;
   4use editor::{
   5    display_map::{DisplaySnapshot, ToDisplayPoint},
   6    movement,
   7    scroll::Autoscroll,
   8    Bias, DisplayPoint, Editor, ToOffset,
   9};
  10use gpui::{actions, ViewContext, WindowContext};
  11use language::{Point, Selection, SelectionGoal};
  12use multi_buffer::MultiBufferRow;
  13use search::BufferSearchBar;
  14use util::ResultExt;
  15use workspace::{searchable::Direction, Workspace};
  16
  17use crate::{
  18    motion::{start_of_line, Motion},
  19    normal::yank::{copy_selections_content, yank_selections_content},
  20    normal::{mark::create_visual_marks, substitute::substitute},
  21    object::Object,
  22    state::{Mode, Operator},
  23    Vim,
  24};
  25
  26actions!(
  27    vim,
  28    [
  29        ToggleVisual,
  30        ToggleVisualLine,
  31        ToggleVisualBlock,
  32        VisualDelete,
  33        VisualDeleteLine,
  34        VisualYank,
  35        OtherEnd,
  36        SelectNext,
  37        SelectPrevious,
  38        SelectNextMatch,
  39        SelectPreviousMatch,
  40        RestoreVisualSelection,
  41    ]
  42);
  43
  44pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  45    workspace.register_action(|_, _: &ToggleVisual, cx: &mut ViewContext<Workspace>| {
  46        toggle_mode(Mode::Visual, cx)
  47    });
  48    workspace.register_action(|_, _: &ToggleVisualLine, cx: &mut ViewContext<Workspace>| {
  49        toggle_mode(Mode::VisualLine, cx)
  50    });
  51    workspace.register_action(
  52        |_, _: &ToggleVisualBlock, cx: &mut ViewContext<Workspace>| {
  53            toggle_mode(Mode::VisualBlock, cx)
  54        },
  55    );
  56    workspace.register_action(other_end);
  57    workspace.register_action(|_, _: &VisualDelete, cx| {
  58        Vim::update(cx, |vim, cx| {
  59            vim.record_current_action(cx);
  60            delete(vim, false, cx);
  61        });
  62    });
  63    workspace.register_action(|_, _: &VisualDeleteLine, cx| {
  64        Vim::update(cx, |vim, cx| {
  65            vim.record_current_action(cx);
  66            delete(vim, true, cx);
  67        });
  68    });
  69    workspace.register_action(|_, _: &VisualYank, cx| {
  70        Vim::update(cx, |vim, cx| {
  71            yank(vim, cx);
  72        });
  73    });
  74
  75    workspace.register_action(select_next);
  76    workspace.register_action(select_previous);
  77    workspace.register_action(|workspace, _: &SelectNextMatch, cx| {
  78        Vim::update(cx, |vim, cx| {
  79            select_match(workspace, vim, Direction::Next, cx);
  80        });
  81    });
  82    workspace.register_action(|workspace, _: &SelectPreviousMatch, cx| {
  83        Vim::update(cx, |vim, cx| {
  84            select_match(workspace, vim, Direction::Prev, cx);
  85        });
  86    });
  87
  88    workspace.register_action(|_, _: &RestoreVisualSelection, cx| {
  89        Vim::update(cx, |vim, cx| {
  90            let Some((stored_mode, reversed)) =
  91                vim.update_state(|state| state.stored_visual_mode.take())
  92            else {
  93                return;
  94            };
  95            let Some((start, end)) = vim.state().marks.get("<").zip(vim.state().marks.get(">"))
  96            else {
  97                return;
  98            };
  99            let ranges = start
 100                .into_iter()
 101                .zip(end)
 102                .zip(reversed)
 103                .map(|((start, end), reversed)| (*start, *end, reversed))
 104                .collect::<Vec<_>>();
 105
 106            if vim.state().mode.is_visual() {
 107                create_visual_marks(vim, vim.state().mode, cx);
 108            }
 109
 110            vim.update_active_editor(cx, |_, editor, cx| {
 111                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 112                    let map = s.display_map();
 113                    let ranges = ranges
 114                        .into_iter()
 115                        .map(|(start, end, reversed)| {
 116                            let new_end =
 117                                movement::saturating_right(&map, end.to_display_point(&map));
 118                            Selection {
 119                                id: s.new_selection_id(),
 120                                start: start.to_offset(&map.buffer_snapshot),
 121                                end: new_end.to_offset(&map, Bias::Left),
 122                                reversed,
 123                                goal: SelectionGoal::None,
 124                            }
 125                        })
 126                        .collect();
 127                    s.select(ranges);
 128                })
 129            });
 130            vim.switch_mode(stored_mode, true, cx)
 131        });
 132    });
 133}
 134
 135pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
 136    Vim::update(cx, |vim, cx| {
 137        vim.update_active_editor(cx, |vim, editor, cx| {
 138            let text_layout_details = editor.text_layout_details(cx);
 139            if vim.state().mode == Mode::VisualBlock
 140                && !matches!(
 141                    motion,
 142                    Motion::EndOfLine {
 143                        display_lines: false
 144                    }
 145                )
 146            {
 147                let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. });
 148                visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
 149                    motion.move_point(map, point, goal, times, &text_layout_details)
 150                })
 151            } else {
 152                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 153                    s.move_with(|map, selection| {
 154                        let was_reversed = selection.reversed;
 155                        let mut current_head = selection.head();
 156
 157                        // our motions assume the current character is after the cursor,
 158                        // but in (forward) visual mode the current character is just
 159                        // before the end of the selection.
 160
 161                        // If the file ends with a newline (which is common) we don't do this.
 162                        // so that if you go to the end of such a file you can use "up" to go
 163                        // to the previous line and have it work somewhat as expected.
 164                        #[allow(clippy::nonminimal_bool)]
 165                        if !selection.reversed
 166                            && !selection.is_empty()
 167                            && !(selection.end.column() == 0 && selection.end == map.max_point())
 168                        {
 169                            current_head = movement::left(map, selection.end)
 170                        }
 171
 172                        let Some((new_head, goal)) = motion.move_point(
 173                            map,
 174                            current_head,
 175                            selection.goal,
 176                            times,
 177                            &text_layout_details,
 178                        ) else {
 179                            return;
 180                        };
 181
 182                        selection.set_head(new_head, goal);
 183
 184                        // ensure the current character is included in the selection.
 185                        if !selection.reversed {
 186                            let next_point = if vim.state().mode == Mode::VisualBlock {
 187                                movement::saturating_right(map, selection.end)
 188                            } else {
 189                                movement::right(map, selection.end)
 190                            };
 191
 192                            if !(next_point.column() == 0 && next_point == map.max_point()) {
 193                                selection.end = next_point;
 194                            }
 195                        }
 196
 197                        // vim always ensures the anchor character stays selected.
 198                        // if our selection has reversed, we need to move the opposite end
 199                        // to ensure the anchor is still selected.
 200                        if was_reversed && !selection.reversed {
 201                            selection.start = movement::left(map, selection.start);
 202                        } else if !was_reversed && selection.reversed {
 203                            selection.end = movement::right(map, selection.end);
 204                        }
 205                    })
 206                });
 207            }
 208        });
 209    });
 210}
 211
 212pub fn visual_block_motion(
 213    preserve_goal: bool,
 214    editor: &mut Editor,
 215    cx: &mut ViewContext<Editor>,
 216    mut move_selection: impl FnMut(
 217        &DisplaySnapshot,
 218        DisplayPoint,
 219        SelectionGoal,
 220    ) -> Option<(DisplayPoint, SelectionGoal)>,
 221) {
 222    let text_layout_details = editor.text_layout_details(cx);
 223    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 224        let map = &s.display_map();
 225        let mut head = s.newest_anchor().head().to_display_point(map);
 226        let mut tail = s.oldest_anchor().tail().to_display_point(map);
 227
 228        let mut head_x = map.x_for_display_point(head, &text_layout_details);
 229        let mut tail_x = map.x_for_display_point(tail, &text_layout_details);
 230
 231        let (start, end) = match s.newest_anchor().goal {
 232            SelectionGoal::HorizontalRange { start, end } if preserve_goal => (start, end),
 233            SelectionGoal::HorizontalPosition(start) if preserve_goal => (start, start),
 234            _ => (tail_x.0, head_x.0),
 235        };
 236        let mut goal = SelectionGoal::HorizontalRange { start, end };
 237
 238        let was_reversed = tail_x > head_x;
 239        if !was_reversed && !preserve_goal {
 240            head = movement::saturating_left(map, head);
 241        }
 242
 243        let Some((new_head, _)) = move_selection(&map, head, goal) else {
 244            return;
 245        };
 246        head = new_head;
 247        head_x = map.x_for_display_point(head, &text_layout_details);
 248
 249        let is_reversed = tail_x > head_x;
 250        if was_reversed && !is_reversed {
 251            tail = movement::saturating_left(map, tail);
 252            tail_x = map.x_for_display_point(tail, &text_layout_details);
 253        } else if !was_reversed && is_reversed {
 254            tail = movement::saturating_right(map, tail);
 255            tail_x = map.x_for_display_point(tail, &text_layout_details);
 256        }
 257        if !is_reversed && !preserve_goal {
 258            head = movement::saturating_right(map, head);
 259            head_x = map.x_for_display_point(head, &text_layout_details);
 260        }
 261
 262        let positions = if is_reversed {
 263            head_x..tail_x
 264        } else {
 265            tail_x..head_x
 266        };
 267
 268        if !preserve_goal {
 269            goal = SelectionGoal::HorizontalRange {
 270                start: positions.start.0,
 271                end: positions.end.0,
 272            };
 273        }
 274
 275        let mut selections = Vec::new();
 276        let mut row = tail.row();
 277
 278        loop {
 279            let laid_out_line = map.layout_row(row, &text_layout_details);
 280            let start = DisplayPoint::new(
 281                row,
 282                laid_out_line.closest_index_for_x(positions.start) as u32,
 283            );
 284            let mut end =
 285                DisplayPoint::new(row, laid_out_line.closest_index_for_x(positions.end) as u32);
 286            if end <= start {
 287                if start.column() == map.line_len(start.row()) {
 288                    end = start;
 289                } else {
 290                    end = movement::saturating_right(map, start);
 291                }
 292            }
 293
 294            if positions.start <= laid_out_line.width {
 295                let selection = Selection {
 296                    id: s.new_selection_id(),
 297                    start: start.to_point(map),
 298                    end: end.to_point(map),
 299                    reversed: is_reversed,
 300                    goal,
 301                };
 302
 303                selections.push(selection);
 304            }
 305            if row == head.row() {
 306                break;
 307            }
 308            if tail.row() > head.row() {
 309                row.0 -= 1
 310            } else {
 311                row.0 += 1
 312            }
 313        }
 314
 315        s.select(selections);
 316    })
 317}
 318
 319pub fn visual_object(object: Object, cx: &mut WindowContext) {
 320    Vim::update(cx, |vim, cx| {
 321        if let Some(Operator::Object { around }) = vim.active_operator() {
 322            vim.pop_operator(cx);
 323            let current_mode = vim.state().mode;
 324            let target_mode = object.target_visual_mode(current_mode);
 325            if target_mode != current_mode {
 326                vim.switch_mode(target_mode, true, cx);
 327            }
 328
 329            vim.update_active_editor(cx, |_, editor, cx| {
 330                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 331                    s.move_with(|map, selection| {
 332                        let mut mut_selection = selection.clone();
 333
 334                        // all our motions assume that the current character is
 335                        // after the cursor; however in the case of a visual selection
 336                        // the current character is before the cursor.
 337                        // But this will affect the judgment of the html tag
 338                        // so the html tag needs to skip this logic.
 339                        if !selection.reversed && object != Object::Tag {
 340                            mut_selection.set_head(
 341                                movement::left(map, mut_selection.head()),
 342                                mut_selection.goal,
 343                            );
 344                        }
 345
 346                        if let Some(range) = object.range(map, mut_selection, around) {
 347                            if !range.is_empty() {
 348                                let expand_both_ways = object.always_expands_both_ways()
 349                                    || selection.is_empty()
 350                                    || movement::right(map, selection.start) == selection.end;
 351
 352                                if expand_both_ways {
 353                                    selection.start = range.start;
 354                                    selection.end = range.end;
 355                                } else if selection.reversed {
 356                                    selection.start = range.start;
 357                                } else {
 358                                    selection.end = range.end;
 359                                }
 360                            }
 361
 362                            // In the visual selection result of a paragraph object, the cursor is
 363                            // placed at the start of the last line. And in the visual mode, the
 364                            // selection end is located after the end character. So, adjustment of
 365                            // selection end is needed.
 366                            //
 367                            // We don't do this adjustment for a one-line blank paragraph since the
 368                            // trailing newline is included in its selection from the beginning.
 369                            if object == Object::Paragraph && range.start != range.end {
 370                                let row_of_selection_end_line = selection.end.to_point(map).row;
 371                                let new_selection_end = if map
 372                                    .buffer_snapshot
 373                                    .line_len(MultiBufferRow(row_of_selection_end_line))
 374                                    == 0
 375                                {
 376                                    Point::new(row_of_selection_end_line + 1, 0)
 377                                } else {
 378                                    Point::new(row_of_selection_end_line, 1)
 379                                };
 380                                selection.end = new_selection_end.to_display_point(map);
 381                            }
 382                        }
 383                    });
 384                });
 385            });
 386        }
 387    });
 388}
 389
 390fn toggle_mode(mode: Mode, cx: &mut ViewContext<Workspace>) {
 391    Vim::update(cx, |vim, cx| {
 392        if vim.state().mode == mode {
 393            vim.switch_mode(Mode::Normal, false, cx);
 394        } else {
 395            vim.switch_mode(mode, false, cx);
 396        }
 397    })
 398}
 399
 400pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace>) {
 401    Vim::update(cx, |vim, cx| {
 402        vim.update_active_editor(cx, |_, editor, cx| {
 403            editor.change_selections(None, cx, |s| {
 404                s.move_with(|_, selection| {
 405                    selection.reversed = !selection.reversed;
 406                })
 407            })
 408        })
 409    });
 410}
 411
 412pub fn delete(vim: &mut Vim, line_mode: bool, cx: &mut WindowContext) {
 413    vim.store_visual_marks(cx);
 414    vim.update_active_editor(cx, |vim, editor, cx| {
 415        let mut original_columns: HashMap<_, _> = Default::default();
 416        let line_mode = line_mode || editor.selections.line_mode;
 417
 418        editor.transact(cx, |editor, cx| {
 419            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 420                s.move_with(|map, selection| {
 421                    if line_mode {
 422                        let mut position = selection.head();
 423                        if !selection.reversed {
 424                            position = movement::left(map, position);
 425                        }
 426                        original_columns.insert(selection.id, position.to_point(map).column);
 427                        if vim.state().mode == Mode::VisualBlock {
 428                            *selection.end.column_mut() = map.line_len(selection.end.row())
 429                        } else if vim.state().mode != Mode::VisualLine {
 430                            selection.start = DisplayPoint::new(selection.start.row(), 0);
 431                            if selection.end.row() == map.max_point().row() {
 432                                selection.end = map.max_point()
 433                            } else {
 434                                *selection.end.row_mut() += 1;
 435                                *selection.end.column_mut() = 0;
 436                            }
 437                        }
 438                    }
 439                    selection.goal = SelectionGoal::None;
 440                });
 441            });
 442            copy_selections_content(vim, editor, line_mode, cx);
 443            editor.insert("", cx);
 444
 445            // Fixup cursor position after the deletion
 446            editor.set_clip_at_line_ends(true, cx);
 447            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 448                s.move_with(|map, selection| {
 449                    let mut cursor = selection.head().to_point(map);
 450
 451                    if let Some(column) = original_columns.get(&selection.id) {
 452                        cursor.column = *column
 453                    }
 454                    let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
 455                    selection.collapse_to(cursor, selection.goal)
 456                });
 457                if vim.state().mode == Mode::VisualBlock {
 458                    s.select_anchors(vec![s.first_anchor()])
 459                }
 460            });
 461        })
 462    });
 463    vim.switch_mode(Mode::Normal, true, cx);
 464}
 465
 466pub fn yank(vim: &mut Vim, cx: &mut WindowContext) {
 467    vim.store_visual_marks(cx);
 468    vim.update_active_editor(cx, |vim, editor, cx| {
 469        let line_mode = editor.selections.line_mode;
 470        yank_selections_content(vim, editor, line_mode, cx);
 471        editor.change_selections(None, cx, |s| {
 472            s.move_with(|map, selection| {
 473                if line_mode {
 474                    selection.start = start_of_line(map, false, selection.start);
 475                };
 476                selection.collapse_to(selection.start, SelectionGoal::None)
 477            });
 478            if vim.state().mode == Mode::VisualBlock {
 479                s.select_anchors(vec![s.first_anchor()])
 480            }
 481        });
 482    });
 483    vim.switch_mode(Mode::Normal, true, cx);
 484}
 485
 486pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
 487    Vim::update(cx, |vim, cx| {
 488        vim.stop_recording();
 489        vim.update_active_editor(cx, |_, editor, cx| {
 490            editor.transact(cx, |editor, cx| {
 491                let (display_map, selections) = editor.selections.all_adjusted_display(cx);
 492
 493                // Selections are biased right at the start. So we need to store
 494                // anchors that are biased left so that we can restore the selections
 495                // after the change
 496                let stable_anchors = editor
 497                    .selections
 498                    .disjoint_anchors()
 499                    .into_iter()
 500                    .map(|selection| {
 501                        let start = selection.start.bias_left(&display_map.buffer_snapshot);
 502                        start..start
 503                    })
 504                    .collect::<Vec<_>>();
 505
 506                let mut edits = Vec::new();
 507                for selection in selections.iter() {
 508                    let selection = selection.clone();
 509                    for row_range in
 510                        movement::split_display_range_by_lines(&display_map, selection.range())
 511                    {
 512                        let range = row_range.start.to_offset(&display_map, Bias::Right)
 513                            ..row_range.end.to_offset(&display_map, Bias::Right);
 514                        let text = text.repeat(range.len());
 515                        edits.push((range, text));
 516                    }
 517                }
 518
 519                editor.buffer().update(cx, |buffer, cx| {
 520                    buffer.edit(edits, None, cx);
 521                });
 522                editor.change_selections(None, cx, |s| s.select_ranges(stable_anchors));
 523            });
 524        });
 525        vim.switch_mode(Mode::Normal, false, cx);
 526    });
 527}
 528
 529pub fn select_next(_: &mut Workspace, _: &SelectNext, cx: &mut ViewContext<Workspace>) {
 530    Vim::update(cx, |vim, cx| {
 531        let count =
 532            vim.take_count(cx)
 533                .unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
 534        vim.update_active_editor(cx, |_, editor, cx| {
 535            editor.set_clip_at_line_ends(false, cx);
 536            for _ in 0..count {
 537                if editor
 538                    .select_next(&Default::default(), cx)
 539                    .log_err()
 540                    .is_none()
 541                {
 542                    break;
 543                }
 544            }
 545        })
 546    });
 547}
 548
 549pub fn select_previous(_: &mut Workspace, _: &SelectPrevious, cx: &mut ViewContext<Workspace>) {
 550    Vim::update(cx, |vim, cx| {
 551        let count =
 552            vim.take_count(cx)
 553                .unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
 554        vim.update_active_editor(cx, |_, editor, cx| {
 555            for _ in 0..count {
 556                if editor
 557                    .select_previous(&Default::default(), cx)
 558                    .log_err()
 559                    .is_none()
 560                {
 561                    break;
 562                }
 563            }
 564        })
 565    });
 566}
 567
 568pub fn select_match(
 569    workspace: &mut Workspace,
 570    vim: &mut Vim,
 571    direction: Direction,
 572    cx: &mut WindowContext,
 573) {
 574    let count = vim.take_count(cx).unwrap_or(1);
 575    let pane = workspace.active_pane().clone();
 576    let vim_is_normal = vim.state().mode == Mode::Normal;
 577    let mut start_selection = 0usize;
 578    let mut end_selection = 0usize;
 579
 580    vim.update_active_editor(cx, |_, editor, _| {
 581        editor.set_collapse_matches(false);
 582    });
 583    if vim_is_normal {
 584        pane.update(cx, |pane, cx| {
 585            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
 586                search_bar.update(cx, |search_bar, cx| {
 587                    if !search_bar.has_active_match() || !search_bar.show(cx) {
 588                        return;
 589                    }
 590                    // without update_match_index there is a bug when the cursor is before the first match
 591                    search_bar.update_match_index(cx);
 592                    search_bar.select_match(direction.opposite(), 1, cx);
 593                });
 594            }
 595        });
 596    }
 597    vim.update_active_editor(cx, |_, editor, cx| {
 598        let latest = editor.selections.newest::<usize>(cx);
 599        start_selection = latest.start;
 600        end_selection = latest.end;
 601    });
 602
 603    let mut match_exists = false;
 604    pane.update(cx, |pane, cx| {
 605        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
 606            search_bar.update(cx, |search_bar, cx| {
 607                search_bar.update_match_index(cx);
 608                search_bar.select_match(direction, count, cx);
 609                match_exists = search_bar.match_exists(cx);
 610            });
 611        }
 612    });
 613    if !match_exists {
 614        vim.clear_operator(cx);
 615        vim.stop_replaying(cx);
 616        return;
 617    }
 618    vim.update_active_editor(cx, |_, editor, cx| {
 619        let latest = editor.selections.newest::<usize>(cx);
 620        if vim_is_normal {
 621            start_selection = latest.start;
 622            end_selection = latest.end;
 623        } else {
 624            start_selection = start_selection.min(latest.start);
 625            end_selection = end_selection.max(latest.end);
 626        }
 627        if direction == Direction::Prev {
 628            std::mem::swap(&mut start_selection, &mut end_selection);
 629        }
 630        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 631            s.select_ranges([start_selection..end_selection]);
 632        });
 633        editor.set_collapse_matches(true);
 634    });
 635
 636    match vim.maybe_pop_operator() {
 637        Some(Operator::Change) => substitute(vim, None, false, cx),
 638        Some(Operator::Delete) => {
 639            vim.stop_recording();
 640            delete(vim, false, cx)
 641        }
 642        Some(Operator::Yank) => yank(vim, cx),
 643        _ => {} // Ignoring other operators
 644    }
 645}
 646
 647#[cfg(test)]
 648mod test {
 649    use indoc::indoc;
 650    use workspace::item::Item;
 651
 652    use crate::{
 653        state::Mode,
 654        test::{NeovimBackedTestContext, VimTestContext},
 655    };
 656
 657    #[gpui::test]
 658    async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
 659        let mut cx = NeovimBackedTestContext::new(cx).await;
 660
 661        cx.set_shared_state(indoc! {
 662            "The ˇquick brown
 663            fox jumps over
 664            the lazy dog"
 665        })
 666        .await;
 667        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
 668
 669        // entering visual mode should select the character
 670        // under cursor
 671        cx.simulate_shared_keystrokes("v").await;
 672        cx.shared_state()
 673            .await
 674            .assert_eq(indoc! { "The «qˇ»uick brown
 675            fox jumps over
 676            the lazy dog"});
 677        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
 678
 679        // forwards motions should extend the selection
 680        cx.simulate_shared_keystrokes("w j").await;
 681        cx.shared_state().await.assert_eq(indoc! { "The «quick brown
 682            fox jumps oˇ»ver
 683            the lazy dog"});
 684
 685        cx.simulate_shared_keystrokes("escape").await;
 686        cx.shared_state().await.assert_eq(indoc! { "The quick brown
 687            fox jumps ˇover
 688            the lazy dog"});
 689
 690        // motions work backwards
 691        cx.simulate_shared_keystrokes("v k b").await;
 692        cx.shared_state()
 693            .await
 694            .assert_eq(indoc! { "The «ˇquick brown
 695            fox jumps o»ver
 696            the lazy dog"});
 697
 698        // works on empty lines
 699        cx.set_shared_state(indoc! {"
 700            a
 701            ˇ
 702            b
 703            "})
 704            .await;
 705        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
 706        cx.simulate_shared_keystrokes("v").await;
 707        cx.shared_state().await.assert_eq(indoc! {"
 708            a
 709            «
 710            ˇ»b
 711        "});
 712        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
 713
 714        // toggles off again
 715        cx.simulate_shared_keystrokes("v").await;
 716        cx.shared_state().await.assert_eq(indoc! {"
 717            a
 718            ˇ
 719            b
 720            "});
 721
 722        // works at the end of a document
 723        cx.set_shared_state(indoc! {"
 724            a
 725            b
 726            ˇ"})
 727            .await;
 728
 729        cx.simulate_shared_keystrokes("v").await;
 730        cx.shared_state().await.assert_eq(indoc! {"
 731            a
 732            b
 733            ˇ"});
 734    }
 735
 736    #[gpui::test]
 737    async fn test_enter_visual_line_mode(cx: &mut gpui::TestAppContext) {
 738        let mut cx = NeovimBackedTestContext::new(cx).await;
 739
 740        cx.set_shared_state(indoc! {
 741            "The ˇquick brown
 742            fox jumps over
 743            the lazy dog"
 744        })
 745        .await;
 746        cx.simulate_shared_keystrokes("shift-v").await;
 747        cx.shared_state()
 748            .await
 749            .assert_eq(indoc! { "The «qˇ»uick brown
 750            fox jumps over
 751            the lazy dog"});
 752        cx.simulate_shared_keystrokes("x").await;
 753        cx.shared_state().await.assert_eq(indoc! { "fox ˇjumps over
 754        the lazy dog"});
 755
 756        // it should work on empty lines
 757        cx.set_shared_state(indoc! {"
 758            a
 759            ˇ
 760            b"})
 761            .await;
 762        cx.simulate_shared_keystrokes("shift-v").await;
 763        cx.shared_state().await.assert_eq(indoc! {"
 764            a
 765            «
 766            ˇ»b"});
 767        cx.simulate_shared_keystrokes("x").await;
 768        cx.shared_state().await.assert_eq(indoc! {"
 769            a
 770            ˇb"});
 771
 772        // it should work at the end of the document
 773        cx.set_shared_state(indoc! {"
 774            a
 775            b
 776            ˇ"})
 777            .await;
 778        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
 779        cx.simulate_shared_keystrokes("shift-v").await;
 780        cx.shared_state().await.assert_eq(indoc! {"
 781            a
 782            b
 783            ˇ"});
 784        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
 785        cx.simulate_shared_keystrokes("x").await;
 786        cx.shared_state().await.assert_eq(indoc! {"
 787            a
 788            ˇb"});
 789    }
 790
 791    #[gpui::test]
 792    async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
 793        let mut cx = NeovimBackedTestContext::new(cx).await;
 794
 795        cx.simulate("v w", "The quick ˇbrown")
 796            .await
 797            .assert_matches();
 798
 799        cx.simulate("v w x", "The quick ˇbrown")
 800            .await
 801            .assert_matches();
 802        cx.simulate(
 803            "v w j x",
 804            indoc! {"
 805                The ˇquick brown
 806                fox jumps over
 807                the lazy dog"},
 808        )
 809        .await
 810        .assert_matches();
 811        // Test pasting code copied on delete
 812        cx.simulate_shared_keystrokes("j p").await;
 813        cx.shared_state().await.assert_matches();
 814
 815        cx.simulate_at_each_offset(
 816            "v w j x",
 817            indoc! {"
 818                The ˇquick brown
 819                fox jumps over
 820                the ˇlazy dog"},
 821        )
 822        .await
 823        .assert_matches();
 824        cx.simulate_at_each_offset(
 825            "v b k x",
 826            indoc! {"
 827                The ˇquick brown
 828                fox jumps ˇover
 829                the ˇlazy dog"},
 830        )
 831        .await
 832        .assert_matches();
 833    }
 834
 835    #[gpui::test]
 836    async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) {
 837        let mut cx = NeovimBackedTestContext::new(cx).await;
 838
 839        cx.set_shared_state(indoc! {"
 840                The quˇick brown
 841                fox jumps over
 842                the lazy dog"})
 843            .await;
 844        cx.simulate_shared_keystrokes("shift-v x").await;
 845        cx.shared_state().await.assert_matches();
 846
 847        // Test pasting code copied on delete
 848        cx.simulate_shared_keystrokes("p").await;
 849        cx.shared_state().await.assert_matches();
 850
 851        cx.set_shared_state(indoc! {"
 852                The quick brown
 853                fox jumps over
 854                the laˇzy dog"})
 855            .await;
 856        cx.simulate_shared_keystrokes("shift-v x").await;
 857        cx.shared_state().await.assert_matches();
 858        cx.shared_clipboard().await.assert_eq("the lazy dog\n");
 859
 860        cx.set_shared_state(indoc! {"
 861                                The quˇick brown
 862                                fox jumps over
 863                                the lazy dog"})
 864            .await;
 865        cx.simulate_shared_keystrokes("shift-v j x").await;
 866        cx.shared_state().await.assert_matches();
 867        // Test pasting code copied on delete
 868        cx.simulate_shared_keystrokes("p").await;
 869        cx.shared_state().await.assert_matches();
 870
 871        cx.set_shared_state(indoc! {"
 872            The ˇlong line
 873            should not
 874            crash
 875            "})
 876            .await;
 877        cx.simulate_shared_keystrokes("shift-v $ x").await;
 878        cx.shared_state().await.assert_matches();
 879    }
 880
 881    #[gpui::test]
 882    async fn test_visual_yank(cx: &mut gpui::TestAppContext) {
 883        let mut cx = NeovimBackedTestContext::new(cx).await;
 884
 885        cx.set_shared_state("The quick ˇbrown").await;
 886        cx.simulate_shared_keystrokes("v w y").await;
 887        cx.shared_state().await.assert_eq("The quick ˇbrown");
 888        cx.shared_clipboard().await.assert_eq("brown");
 889
 890        cx.set_shared_state(indoc! {"
 891                The ˇquick brown
 892                fox jumps over
 893                the lazy dog"})
 894            .await;
 895        cx.simulate_shared_keystrokes("v w j y").await;
 896        cx.shared_state().await.assert_eq(indoc! {"
 897                    The ˇquick brown
 898                    fox jumps over
 899                    the lazy dog"});
 900        cx.shared_clipboard().await.assert_eq(indoc! {"
 901                quick brown
 902                fox jumps o"});
 903
 904        cx.set_shared_state(indoc! {"
 905                    The quick brown
 906                    fox jumps over
 907                    the ˇlazy dog"})
 908            .await;
 909        cx.simulate_shared_keystrokes("v w j y").await;
 910        cx.shared_state().await.assert_eq(indoc! {"
 911                    The quick brown
 912                    fox jumps over
 913                    the ˇlazy dog"});
 914        cx.shared_clipboard().await.assert_eq("lazy d");
 915        cx.simulate_shared_keystrokes("shift-v y").await;
 916        cx.shared_clipboard().await.assert_eq("the lazy dog\n");
 917
 918        cx.set_shared_state(indoc! {"
 919                    The ˇquick brown
 920                    fox jumps over
 921                    the lazy dog"})
 922            .await;
 923        cx.simulate_shared_keystrokes("v b k y").await;
 924        cx.shared_state().await.assert_eq(indoc! {"
 925                    ˇThe quick brown
 926                    fox jumps over
 927                    the lazy dog"});
 928        assert_eq!(
 929            cx.read_from_clipboard()
 930                .map(|item| item.text().clone())
 931                .unwrap(),
 932            "The q"
 933        );
 934
 935        cx.set_shared_state(indoc! {"
 936                    The quick brown
 937                    fox ˇjumps over
 938                    the lazy dog"})
 939            .await;
 940        cx.simulate_shared_keystrokes("shift-v shift-g shift-y")
 941            .await;
 942        cx.shared_state().await.assert_eq(indoc! {"
 943                    The quick brown
 944                    ˇfox jumps over
 945                    the lazy dog"});
 946        cx.shared_clipboard()
 947            .await
 948            .assert_eq("fox jumps over\nthe lazy dog\n");
 949    }
 950
 951    #[gpui::test]
 952    async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) {
 953        let mut cx = NeovimBackedTestContext::new(cx).await;
 954
 955        cx.set_shared_state(indoc! {
 956            "The ˇquick brown
 957             fox jumps over
 958             the lazy dog"
 959        })
 960        .await;
 961        cx.simulate_shared_keystrokes("ctrl-v").await;
 962        cx.shared_state().await.assert_eq(indoc! {
 963            "The «qˇ»uick brown
 964            fox jumps over
 965            the lazy dog"
 966        });
 967        cx.simulate_shared_keystrokes("2 down").await;
 968        cx.shared_state().await.assert_eq(indoc! {
 969            "The «qˇ»uick brown
 970            fox «jˇ»umps over
 971            the «lˇ»azy dog"
 972        });
 973        cx.simulate_shared_keystrokes("e").await;
 974        cx.shared_state().await.assert_eq(indoc! {
 975            "The «quicˇ»k brown
 976            fox «jumpˇ»s over
 977            the «lazyˇ» dog"
 978        });
 979        cx.simulate_shared_keystrokes("^").await;
 980        cx.shared_state().await.assert_eq(indoc! {
 981            "«ˇThe q»uick brown
 982            «ˇfox j»umps over
 983            «ˇthe l»azy dog"
 984        });
 985        cx.simulate_shared_keystrokes("$").await;
 986        cx.shared_state().await.assert_eq(indoc! {
 987            "The «quick brownˇ»
 988            fox «jumps overˇ»
 989            the «lazy dogˇ»"
 990        });
 991        cx.simulate_shared_keystrokes("shift-f space").await;
 992        cx.shared_state().await.assert_eq(indoc! {
 993            "The «quickˇ» brown
 994            fox «jumpsˇ» over
 995            the «lazy ˇ»dog"
 996        });
 997
 998        // toggling through visual mode works as expected
 999        cx.simulate_shared_keystrokes("v").await;
1000        cx.shared_state().await.assert_eq(indoc! {
1001            "The «quick brown
1002            fox jumps over
1003            the lazy ˇ»dog"
1004        });
1005        cx.simulate_shared_keystrokes("ctrl-v").await;
1006        cx.shared_state().await.assert_eq(indoc! {
1007            "The «quickˇ» brown
1008            fox «jumpsˇ» over
1009            the «lazy ˇ»dog"
1010        });
1011
1012        cx.set_shared_state(indoc! {
1013            "The ˇquick
1014             brown
1015             fox
1016             jumps over the
1017
1018             lazy dog
1019            "
1020        })
1021        .await;
1022        cx.simulate_shared_keystrokes("ctrl-v down down").await;
1023        cx.shared_state().await.assert_eq(indoc! {
1024            "The«ˇ q»uick
1025            bro«ˇwn»
1026            foxˇ
1027            jumps over the
1028
1029            lazy dog
1030            "
1031        });
1032        cx.simulate_shared_keystrokes("down").await;
1033        cx.shared_state().await.assert_eq(indoc! {
1034            "The «qˇ»uick
1035            brow«nˇ»
1036            fox
1037            jump«sˇ» over the
1038
1039            lazy dog
1040            "
1041        });
1042        cx.simulate_shared_keystrokes("left").await;
1043        cx.shared_state().await.assert_eq(indoc! {
1044            "The«ˇ q»uick
1045            bro«ˇwn»
1046            foxˇ
1047            jum«ˇps» over the
1048
1049            lazy dog
1050            "
1051        });
1052        cx.simulate_shared_keystrokes("s o escape").await;
1053        cx.shared_state().await.assert_eq(indoc! {
1054            "Theˇouick
1055            broo
1056            foxo
1057            jumo over the
1058
1059            lazy dog
1060            "
1061        });
1062
1063        // https://github.com/zed-industries/zed/issues/6274
1064        cx.set_shared_state(indoc! {
1065            "Theˇ quick brown
1066
1067            fox jumps over
1068            the lazy dog
1069            "
1070        })
1071        .await;
1072        cx.simulate_shared_keystrokes("l ctrl-v j j").await;
1073        cx.shared_state().await.assert_eq(indoc! {
1074            "The «qˇ»uick brown
1075
1076            fox «jˇ»umps over
1077            the lazy dog
1078            "
1079        });
1080    }
1081
1082    #[gpui::test]
1083    async fn test_visual_block_issue_2123(cx: &mut gpui::TestAppContext) {
1084        let mut cx = NeovimBackedTestContext::new(cx).await;
1085
1086        cx.set_shared_state(indoc! {
1087            "The ˇquick brown
1088            fox jumps over
1089            the lazy dog
1090            "
1091        })
1092        .await;
1093        cx.simulate_shared_keystrokes("ctrl-v right down").await;
1094        cx.shared_state().await.assert_eq(indoc! {
1095            "The «quˇ»ick brown
1096            fox «juˇ»mps over
1097            the lazy dog
1098            "
1099        });
1100    }
1101
1102    #[gpui::test]
1103    async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {
1104        let mut cx = NeovimBackedTestContext::new(cx).await;
1105
1106        cx.set_shared_state(indoc! {
1107            "ˇThe quick brown
1108            fox jumps over
1109            the lazy dog
1110            "
1111        })
1112        .await;
1113        cx.simulate_shared_keystrokes("ctrl-v 9 down").await;
1114        cx.shared_state().await.assert_eq(indoc! {
1115            "«Tˇ»he quick brown
1116            «fˇ»ox jumps over
1117            «tˇ»he lazy dog
1118            ˇ"
1119        });
1120
1121        cx.simulate_shared_keystrokes("shift-i k escape").await;
1122        cx.shared_state().await.assert_eq(indoc! {
1123            "ˇkThe quick brown
1124            kfox jumps over
1125            kthe lazy dog
1126            k"
1127        });
1128
1129        cx.set_shared_state(indoc! {
1130            "ˇThe quick brown
1131            fox jumps over
1132            the lazy dog
1133            "
1134        })
1135        .await;
1136        cx.simulate_shared_keystrokes("ctrl-v 9 down").await;
1137        cx.shared_state().await.assert_eq(indoc! {
1138            "«Tˇ»he quick brown
1139            «fˇ»ox jumps over
1140            «tˇ»he lazy dog
1141            ˇ"
1142        });
1143        cx.simulate_shared_keystrokes("c k escape").await;
1144        cx.shared_state().await.assert_eq(indoc! {
1145            "ˇkhe quick brown
1146            kox jumps over
1147            khe lazy dog
1148            k"
1149        });
1150    }
1151
1152    #[gpui::test]
1153    async fn test_visual_object(cx: &mut gpui::TestAppContext) {
1154        let mut cx = NeovimBackedTestContext::new(cx).await;
1155
1156        cx.set_shared_state("hello (in [parˇens] o)").await;
1157        cx.simulate_shared_keystrokes("ctrl-v l").await;
1158        cx.simulate_shared_keystrokes("a ]").await;
1159        cx.shared_state()
1160            .await
1161            .assert_eq("hello (in «[parens]ˇ» o)");
1162        cx.simulate_shared_keystrokes("i (").await;
1163        cx.shared_state()
1164            .await
1165            .assert_eq("hello («in [parens] oˇ»)");
1166
1167        cx.set_shared_state("hello in a wˇord again.").await;
1168        cx.simulate_shared_keystrokes("ctrl-v l i w").await;
1169        cx.shared_state()
1170            .await
1171            .assert_eq("hello in a w«ordˇ» again.");
1172        assert_eq!(cx.mode(), Mode::VisualBlock);
1173        cx.simulate_shared_keystrokes("o a s").await;
1174        cx.shared_state()
1175            .await
1176            .assert_eq("«ˇhello in a word» again.");
1177    }
1178
1179    #[gpui::test]
1180    async fn test_mode_across_command(cx: &mut gpui::TestAppContext) {
1181        let mut cx = VimTestContext::new(cx, true).await;
1182
1183        cx.set_state("aˇbc", Mode::Normal);
1184        cx.simulate_keystrokes("ctrl-v");
1185        assert_eq!(cx.mode(), Mode::VisualBlock);
1186        cx.simulate_keystrokes("cmd-shift-p escape");
1187        assert_eq!(cx.mode(), Mode::VisualBlock);
1188    }
1189
1190    #[gpui::test]
1191    async fn test_gn(cx: &mut gpui::TestAppContext) {
1192        let mut cx = NeovimBackedTestContext::new(cx).await;
1193
1194        cx.set_shared_state("aaˇ aa aa aa aa").await;
1195        cx.simulate_shared_keystrokes("/ a a enter").await;
1196        cx.shared_state().await.assert_eq("aa ˇaa aa aa aa");
1197        cx.simulate_shared_keystrokes("g n").await;
1198        cx.shared_state().await.assert_eq("aa «aaˇ» aa aa aa");
1199        cx.simulate_shared_keystrokes("g n").await;
1200        cx.shared_state().await.assert_eq("aa «aa aaˇ» aa aa");
1201        cx.simulate_shared_keystrokes("escape d g n").await;
1202        cx.shared_state().await.assert_eq("aa aa ˇ aa aa");
1203
1204        cx.set_shared_state("aaˇ aa aa aa aa").await;
1205        cx.simulate_shared_keystrokes("/ a a enter").await;
1206        cx.shared_state().await.assert_eq("aa ˇaa aa aa aa");
1207        cx.simulate_shared_keystrokes("3 g n").await;
1208        cx.shared_state().await.assert_eq("aa aa aa «aaˇ» aa");
1209
1210        cx.set_shared_state("aaˇ aa aa aa aa").await;
1211        cx.simulate_shared_keystrokes("/ a a enter").await;
1212        cx.shared_state().await.assert_eq("aa ˇaa aa aa aa");
1213        cx.simulate_shared_keystrokes("g shift-n").await;
1214        cx.shared_state().await.assert_eq("aa «ˇaa» aa aa aa");
1215        cx.simulate_shared_keystrokes("g shift-n").await;
1216        cx.shared_state().await.assert_eq("«ˇaa aa» aa aa aa");
1217    }
1218
1219    #[gpui::test]
1220    async fn test_gl(cx: &mut gpui::TestAppContext) {
1221        let mut cx = VimTestContext::new(cx, true).await;
1222
1223        cx.set_state("aaˇ aa\naa", Mode::Normal);
1224        cx.simulate_keystrokes("g l");
1225        cx.assert_state("«aaˇ» «aaˇ»\naa", Mode::Visual);
1226        cx.simulate_keystrokes("g >");
1227        cx.assert_state("«aaˇ» aa\n«aaˇ»", Mode::Visual);
1228    }
1229
1230    #[gpui::test]
1231    async fn test_dgn_repeat(cx: &mut gpui::TestAppContext) {
1232        let mut cx = NeovimBackedTestContext::new(cx).await;
1233
1234        cx.set_shared_state("aaˇ aa aa aa aa").await;
1235        cx.simulate_shared_keystrokes("/ a a enter").await;
1236        cx.shared_state().await.assert_eq("aa ˇaa aa aa aa");
1237        cx.simulate_shared_keystrokes("d g n").await;
1238
1239        cx.shared_state().await.assert_eq("aa ˇ aa aa aa");
1240        cx.simulate_shared_keystrokes(".").await;
1241        cx.shared_state().await.assert_eq("aa  ˇ aa aa");
1242        cx.simulate_shared_keystrokes(".").await;
1243        cx.shared_state().await.assert_eq("aa   ˇ aa");
1244    }
1245
1246    #[gpui::test]
1247    async fn test_cgn_repeat(cx: &mut gpui::TestAppContext) {
1248        let mut cx = NeovimBackedTestContext::new(cx).await;
1249
1250        cx.set_shared_state("aaˇ aa aa aa aa").await;
1251        cx.simulate_shared_keystrokes("/ a a enter").await;
1252        cx.shared_state().await.assert_eq("aa ˇaa aa aa aa");
1253        cx.simulate_shared_keystrokes("c g n x escape").await;
1254        cx.shared_state().await.assert_eq("aa ˇx aa aa aa");
1255        cx.simulate_shared_keystrokes(".").await;
1256        cx.shared_state().await.assert_eq("aa x ˇx aa aa");
1257    }
1258
1259    #[gpui::test]
1260    async fn test_cgn_nomatch(cx: &mut gpui::TestAppContext) {
1261        let mut cx = NeovimBackedTestContext::new(cx).await;
1262
1263        cx.set_shared_state("aaˇ aa aa aa aa").await;
1264        cx.simulate_shared_keystrokes("/ b b enter").await;
1265        cx.shared_state().await.assert_eq("aaˇ aa aa aa aa");
1266        cx.simulate_shared_keystrokes("c g n x escape").await;
1267        cx.shared_state().await.assert_eq("aaˇaa aa aa aa");
1268        cx.simulate_shared_keystrokes(".").await;
1269        cx.shared_state().await.assert_eq("aaˇa aa aa aa");
1270
1271        cx.set_shared_state("aaˇ bb aa aa aa").await;
1272        cx.simulate_shared_keystrokes("/ b b enter").await;
1273        cx.shared_state().await.assert_eq("aa ˇbb aa aa aa");
1274        cx.simulate_shared_keystrokes("c g n x escape").await;
1275        cx.shared_state().await.assert_eq("aa ˇx aa aa aa");
1276        cx.simulate_shared_keystrokes(".").await;
1277        cx.shared_state().await.assert_eq("aa ˇx aa aa aa");
1278    }
1279
1280    #[gpui::test]
1281    async fn test_visual_shift_d(cx: &mut gpui::TestAppContext) {
1282        let mut cx = NeovimBackedTestContext::new(cx).await;
1283
1284        cx.set_shared_state(indoc! {
1285            "The ˇquick brown
1286            fox jumps over
1287            the lazy dog
1288            "
1289        })
1290        .await;
1291        cx.simulate_shared_keystrokes("v down shift-d").await;
1292        cx.shared_state().await.assert_eq(indoc! {
1293            "the ˇlazy dog\n"
1294        });
1295
1296        cx.set_shared_state(indoc! {
1297            "The ˇquick brown
1298            fox jumps over
1299            the lazy dog
1300            "
1301        })
1302        .await;
1303        cx.simulate_shared_keystrokes("ctrl-v down shift-d").await;
1304        cx.shared_state().await.assert_eq(indoc! {
1305            "Theˇ•
1306            fox•
1307            the lazy dog
1308            "
1309        });
1310    }
1311
1312    #[gpui::test]
1313    async fn test_gv(cx: &mut gpui::TestAppContext) {
1314        let mut cx = NeovimBackedTestContext::new(cx).await;
1315
1316        cx.set_shared_state(indoc! {
1317            "The ˇquick brown"
1318        })
1319        .await;
1320        cx.simulate_shared_keystrokes("v i w escape g v").await;
1321        cx.shared_state().await.assert_eq(indoc! {
1322            "The «quickˇ» brown"
1323        });
1324
1325        cx.simulate_shared_keystrokes("o escape g v").await;
1326        cx.shared_state().await.assert_eq(indoc! {
1327            "The «ˇquick» brown"
1328        });
1329
1330        cx.simulate_shared_keystrokes("escape ^ ctrl-v l").await;
1331        cx.shared_state().await.assert_eq(indoc! {
1332            "«Thˇ»e quick brown"
1333        });
1334        cx.simulate_shared_keystrokes("g v").await;
1335        cx.shared_state().await.assert_eq(indoc! {
1336            "The «ˇquick» brown"
1337        });
1338        cx.simulate_shared_keystrokes("g v").await;
1339        cx.shared_state().await.assert_eq(indoc! {
1340            "«Thˇ»e quick brown"
1341        });
1342
1343        cx.set_state(
1344            indoc! {"
1345            fiˇsh one
1346            fish two
1347            fish red
1348            fish blue
1349        "},
1350            Mode::Normal,
1351        );
1352        cx.simulate_keystrokes("4 g l escape escape g v");
1353        cx.assert_state(
1354            indoc! {"
1355                «fishˇ» one
1356                «fishˇ» two
1357                «fishˇ» red
1358                «fishˇ» blue
1359            "},
1360            Mode::Visual,
1361        );
1362        cx.simulate_keystrokes("y g v");
1363        cx.assert_state(
1364            indoc! {"
1365                «fishˇ» one
1366                «fishˇ» two
1367                «fishˇ» red
1368                «fishˇ» blue
1369            "},
1370            Mode::Visual,
1371        );
1372    }
1373}