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