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