visual.rs

  1use std::{cmp, sync::Arc};
  2
  3use collections::HashMap;
  4use editor::{
  5    display_map::{DisplaySnapshot, ToDisplayPoint},
  6    movement,
  7    scroll::autoscroll::Autoscroll,
  8    Bias, DisplayPoint, Editor,
  9};
 10use gpui::{actions, AppContext, ViewContext, WindowContext};
 11use language::{Selection, SelectionGoal};
 12use workspace::Workspace;
 13
 14use crate::{
 15    motion::Motion,
 16    object::Object,
 17    state::{Mode, Operator},
 18    utils::copy_selections_content,
 19    Vim,
 20};
 21
 22actions!(
 23    vim,
 24    [
 25        ToggleVisual,
 26        ToggleVisualLine,
 27        ToggleVisualBlock,
 28        VisualDelete,
 29        VisualYank,
 30        OtherEnd,
 31    ]
 32);
 33
 34pub fn init(cx: &mut AppContext) {
 35    cx.add_action(|_, _: &ToggleVisual, cx: &mut ViewContext<Workspace>| {
 36        toggle_mode(Mode::Visual, cx)
 37    });
 38    cx.add_action(|_, _: &ToggleVisualLine, cx: &mut ViewContext<Workspace>| {
 39        toggle_mode(Mode::VisualLine, cx)
 40    });
 41    cx.add_action(
 42        |_, _: &ToggleVisualBlock, cx: &mut ViewContext<Workspace>| {
 43            toggle_mode(Mode::VisualBlock, cx)
 44        },
 45    );
 46    cx.add_action(other_end);
 47    cx.add_action(delete);
 48    cx.add_action(yank);
 49}
 50
 51pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
 52    Vim::update(cx, |vim, cx| {
 53        vim.update_active_editor(cx, |editor, cx| {
 54            if vim.state().mode == Mode::VisualBlock && !matches!(motion, Motion::EndOfLine) {
 55                let is_up_or_down = matches!(motion, Motion::Up | Motion::Down);
 56                visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
 57                    motion.move_point(map, point, goal, times)
 58                })
 59            } else {
 60                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 61                    s.move_with(|map, selection| {
 62                        let was_reversed = selection.reversed;
 63                        let mut current_head = selection.head();
 64
 65                        // our motions assume the current character is after the cursor,
 66                        // but in (forward) visual mode the current character is just
 67                        // before the end of the selection.
 68
 69                        // If the file ends with a newline (which is common) we don't do this.
 70                        // so that if you go to the end of such a file you can use "up" to go
 71                        // to the previous line and have it work somewhat as expected.
 72                        if !selection.reversed
 73                            && !selection.is_empty()
 74                            && !(selection.end.column() == 0 && selection.end == map.max_point())
 75                        {
 76                            current_head = movement::left(map, selection.end)
 77                        }
 78
 79                        let Some((new_head, goal)) =
 80                        motion.move_point(map, current_head, selection.goal, times) else { return };
 81
 82                        selection.set_head(new_head, goal);
 83
 84                        // ensure the current character is included in the selection.
 85                        if !selection.reversed {
 86                            let next_point = if vim.state().mode == Mode::VisualBlock {
 87                                movement::saturating_right(map, selection.end)
 88                            } else {
 89                                movement::right(map, selection.end)
 90                            };
 91
 92                            if !(next_point.column() == 0 && next_point == map.max_point()) {
 93                                selection.end = next_point;
 94                            }
 95                        }
 96
 97                        // vim always ensures the anchor character stays selected.
 98                        // if our selection has reversed, we need to move the opposite end
 99                        // to ensure the anchor is still selected.
100                        if was_reversed && !selection.reversed {
101                            selection.start = movement::left(map, selection.start);
102                        } else if !was_reversed && selection.reversed {
103                            selection.end = movement::right(map, selection.end);
104                        }
105                    })
106                });
107            }
108        });
109    });
110}
111
112pub fn visual_block_motion(
113    preserve_goal: bool,
114    editor: &mut Editor,
115    cx: &mut ViewContext<Editor>,
116    mut move_selection: impl FnMut(
117        &DisplaySnapshot,
118        DisplayPoint,
119        SelectionGoal,
120    ) -> Option<(DisplayPoint, SelectionGoal)>,
121) {
122    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
123        let map = &s.display_map();
124        let mut head = s.newest_anchor().head().to_display_point(map);
125        let mut tail = s.oldest_anchor().tail().to_display_point(map);
126        let mut goal = s.newest_anchor().goal;
127
128        let was_reversed = tail.column() > head.column();
129
130        if !was_reversed && !preserve_goal {
131            head = movement::saturating_left(map, head);
132        }
133
134        let Some((new_head, _)) = move_selection(&map, head, goal) else {
135            return
136        };
137        head = new_head;
138
139        let is_reversed = tail.column() > head.column();
140        if was_reversed && !is_reversed {
141            tail = movement::left(map, tail)
142        } else if !was_reversed && is_reversed {
143            tail = movement::right(map, tail)
144        }
145        if !is_reversed && !preserve_goal {
146            head = movement::saturating_right(map, head)
147        }
148
149        let (start, end) = match goal {
150            SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end),
151            SelectionGoal::Column(start) if preserve_goal => (start, start + 1),
152            _ => (tail.column(), head.column()),
153        };
154        goal = SelectionGoal::ColumnRange { start, end };
155
156        let columns = if is_reversed {
157            head.column()..tail.column()
158        } else if head.column() == tail.column() {
159            head.column()..(head.column() + 1)
160        } else {
161            tail.column()..head.column()
162        };
163
164        let mut selections = Vec::new();
165        let mut row = tail.row();
166
167        loop {
168            let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left);
169            let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left);
170            if columns.start <= map.line_len(row) {
171                let selection = Selection {
172                    id: s.new_selection_id(),
173                    start: start.to_point(map),
174                    end: end.to_point(map),
175                    reversed: is_reversed,
176                    goal: goal.clone(),
177                };
178
179                selections.push(selection);
180            }
181            if row == head.row() {
182                break;
183            }
184            if tail.row() > head.row() {
185                row -= 1
186            } else {
187                row += 1
188            }
189        }
190
191        s.select(selections);
192    })
193}
194
195pub fn visual_object(object: Object, cx: &mut WindowContext) {
196    Vim::update(cx, |vim, cx| {
197        if let Some(Operator::Object { around }) = vim.active_operator() {
198            vim.pop_operator(cx);
199            let current_mode = vim.state().mode;
200            let target_mode = object.target_visual_mode(current_mode);
201            if target_mode != current_mode {
202                vim.switch_mode(target_mode, true, cx);
203            }
204
205            vim.update_active_editor(cx, |editor, cx| {
206                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
207                    s.move_with(|map, selection| {
208                        let mut head = selection.head();
209
210                        // all our motions assume that the current character is
211                        // after the cursor; however in the case of a visual selection
212                        // the current character is before the cursor.
213                        if !selection.reversed {
214                            head = movement::left(map, head);
215                        }
216
217                        if let Some(range) = object.range(map, head, around) {
218                            if !range.is_empty() {
219                                let expand_both_ways =
220                                    if object.always_expands_both_ways() || selection.is_empty() {
221                                        true
222                                        // contains only one character
223                                    } else if let Some((_, start)) =
224                                        map.reverse_chars_at(selection.end).next()
225                                    {
226                                        selection.start == start
227                                    } else {
228                                        false
229                                    };
230
231                                if expand_both_ways {
232                                    selection.start = cmp::min(selection.start, range.start);
233                                    selection.end = cmp::max(selection.end, range.end);
234                                } else if selection.reversed {
235                                    selection.start = range.start;
236                                } else {
237                                    selection.end = range.end;
238                                }
239                            }
240                        }
241                    });
242                });
243            });
244        }
245    });
246}
247
248fn toggle_mode(mode: Mode, cx: &mut ViewContext<Workspace>) {
249    Vim::update(cx, |vim, cx| {
250        if vim.state().mode == mode {
251            vim.switch_mode(Mode::Normal, false, cx);
252        } else {
253            vim.switch_mode(mode, false, cx);
254        }
255    })
256}
257
258pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace>) {
259    Vim::update(cx, |vim, cx| {
260        vim.update_active_editor(cx, |editor, cx| {
261            editor.change_selections(None, cx, |s| {
262                s.move_with(|_, selection| {
263                    selection.reversed = !selection.reversed;
264                })
265            })
266        })
267    });
268}
269
270pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
271    Vim::update(cx, |vim, cx| {
272        vim.update_active_editor(cx, |editor, cx| {
273            let mut original_columns: HashMap<_, _> = Default::default();
274            let line_mode = editor.selections.line_mode;
275
276            editor.transact(cx, |editor, cx| {
277                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
278                    s.move_with(|map, selection| {
279                        if line_mode {
280                            let mut position = selection.head();
281                            if !selection.reversed {
282                                position = movement::left(map, position);
283                            }
284                            original_columns.insert(selection.id, position.to_point(map).column);
285                        }
286                        selection.goal = SelectionGoal::None;
287                    });
288                });
289                copy_selections_content(editor, line_mode, cx);
290                editor.insert("", cx);
291
292                // Fixup cursor position after the deletion
293                editor.set_clip_at_line_ends(true, cx);
294                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
295                    s.move_with(|map, selection| {
296                        let mut cursor = selection.head().to_point(map);
297
298                        if let Some(column) = original_columns.get(&selection.id) {
299                            cursor.column = *column
300                        }
301                        let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
302                        selection.collapse_to(cursor, selection.goal)
303                    });
304                    if vim.state().mode == Mode::VisualBlock {
305                        s.select_anchors(vec![s.first_anchor()])
306                    }
307                });
308            })
309        });
310        vim.switch_mode(Mode::Normal, true, cx);
311    });
312}
313
314pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
315    Vim::update(cx, |vim, cx| {
316        vim.update_active_editor(cx, |editor, cx| {
317            let line_mode = editor.selections.line_mode;
318            copy_selections_content(editor, line_mode, cx);
319            editor.change_selections(None, cx, |s| {
320                s.move_with(|_, selection| {
321                    selection.collapse_to(selection.start, SelectionGoal::None)
322                });
323                if vim.state().mode == Mode::VisualBlock {
324                    s.select_anchors(vec![s.first_anchor()])
325                }
326            });
327        });
328        vim.switch_mode(Mode::Normal, true, cx);
329    });
330}
331
332pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
333    Vim::update(cx, |vim, cx| {
334        vim.update_active_editor(cx, |editor, cx| {
335            editor.transact(cx, |editor, cx| {
336                let (display_map, selections) = editor.selections.all_adjusted_display(cx);
337
338                // Selections are biased right at the start. So we need to store
339                // anchors that are biased left so that we can restore the selections
340                // after the change
341                let stable_anchors = editor
342                    .selections
343                    .disjoint_anchors()
344                    .into_iter()
345                    .map(|selection| {
346                        let start = selection.start.bias_left(&display_map.buffer_snapshot);
347                        start..start
348                    })
349                    .collect::<Vec<_>>();
350
351                let mut edits = Vec::new();
352                for selection in selections.iter() {
353                    let selection = selection.clone();
354                    for row_range in
355                        movement::split_display_range_by_lines(&display_map, selection.range())
356                    {
357                        let range = row_range.start.to_offset(&display_map, Bias::Right)
358                            ..row_range.end.to_offset(&display_map, Bias::Right);
359                        let text = text.repeat(range.len());
360                        edits.push((range, text));
361                    }
362                }
363
364                editor.buffer().update(cx, |buffer, cx| {
365                    buffer.edit(edits, None, cx);
366                });
367                editor.change_selections(None, cx, |s| s.select_ranges(stable_anchors));
368            });
369        });
370        vim.switch_mode(Mode::Normal, false, cx);
371    });
372}
373
374#[cfg(test)]
375mod test {
376    use indoc::indoc;
377    use workspace::item::Item;
378
379    use crate::{
380        state::Mode,
381        test::{NeovimBackedTestContext, VimTestContext},
382    };
383
384    #[gpui::test]
385    async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
386        let mut cx = NeovimBackedTestContext::new(cx).await;
387
388        cx.set_shared_state(indoc! {
389            "The ˇquick brown
390            fox jumps over
391            the lazy dog"
392        })
393        .await;
394        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
395
396        // entering visual mode should select the character
397        // under cursor
398        cx.simulate_shared_keystrokes(["v"]).await;
399        cx.assert_shared_state(indoc! { "The «qˇ»uick brown
400            fox jumps over
401            the lazy dog"})
402            .await;
403        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
404
405        // forwards motions should extend the selection
406        cx.simulate_shared_keystrokes(["w", "j"]).await;
407        cx.assert_shared_state(indoc! { "The «quick brown
408            fox jumps oˇ»ver
409            the lazy dog"})
410            .await;
411
412        cx.simulate_shared_keystrokes(["escape"]).await;
413        assert_eq!(Mode::Normal, cx.neovim_mode().await);
414        cx.assert_shared_state(indoc! { "The quick brown
415            fox jumps ˇover
416            the lazy dog"})
417            .await;
418
419        // motions work backwards
420        cx.simulate_shared_keystrokes(["v", "k", "b"]).await;
421        cx.assert_shared_state(indoc! { "The «ˇquick brown
422            fox jumps o»ver
423            the lazy dog"})
424            .await;
425
426        // works on empty lines
427        cx.set_shared_state(indoc! {"
428            a
429            ˇ
430            b
431            "})
432            .await;
433        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
434        cx.simulate_shared_keystrokes(["v"]).await;
435        cx.assert_shared_state(indoc! {"
436            a
437            «
438            ˇ»b
439        "})
440            .await;
441        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
442
443        // toggles off again
444        cx.simulate_shared_keystrokes(["v"]).await;
445        cx.assert_shared_state(indoc! {"
446            a
447            ˇ
448            b
449            "})
450            .await;
451
452        // works at the end of a document
453        cx.set_shared_state(indoc! {"
454            a
455            b
456            ˇ"})
457            .await;
458
459        cx.simulate_shared_keystrokes(["v"]).await;
460        cx.assert_shared_state(indoc! {"
461            a
462            b
463            ˇ"})
464            .await;
465        assert_eq!(cx.mode(), cx.neovim_mode().await);
466    }
467
468    #[gpui::test]
469    async fn test_enter_visual_line_mode(cx: &mut gpui::TestAppContext) {
470        let mut cx = NeovimBackedTestContext::new(cx).await;
471
472        cx.set_shared_state(indoc! {
473            "The ˇquick brown
474            fox jumps over
475            the lazy dog"
476        })
477        .await;
478        cx.simulate_shared_keystrokes(["shift-v"]).await;
479        cx.assert_shared_state(indoc! { "The «qˇ»uick brown
480            fox jumps over
481            the lazy dog"})
482            .await;
483        assert_eq!(cx.mode(), cx.neovim_mode().await);
484        cx.simulate_shared_keystrokes(["x"]).await;
485        cx.assert_shared_state(indoc! { "fox ˇjumps over
486        the lazy dog"})
487            .await;
488
489        // it should work on empty lines
490        cx.set_shared_state(indoc! {"
491            a
492            ˇ
493            b"})
494            .await;
495        cx.simulate_shared_keystrokes(["shift-v"]).await;
496        cx.assert_shared_state(indoc! { "
497            a
498            «
499            ˇ»b"})
500            .await;
501        cx.simulate_shared_keystrokes(["x"]).await;
502        cx.assert_shared_state(indoc! { "
503            a
504            ˇb"})
505            .await;
506
507        // it should work at the end of the document
508        cx.set_shared_state(indoc! {"
509            a
510            b
511            ˇ"})
512            .await;
513        let cursor = cx.update_editor(|editor, cx| editor.pixel_position_of_cursor(cx));
514        cx.simulate_shared_keystrokes(["shift-v"]).await;
515        cx.assert_shared_state(indoc! {"
516            a
517            b
518            ˇ"})
519            .await;
520        assert_eq!(cx.mode(), cx.neovim_mode().await);
521        cx.update_editor(|editor, cx| assert_eq!(cursor, editor.pixel_position_of_cursor(cx)));
522        cx.simulate_shared_keystrokes(["x"]).await;
523        cx.assert_shared_state(indoc! {"
524            a
525            ˇb"})
526            .await;
527    }
528
529    #[gpui::test]
530    async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
531        let mut cx = NeovimBackedTestContext::new(cx).await;
532
533        cx.assert_binding_matches(["v", "w"], "The quick ˇbrown")
534            .await;
535
536        cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown")
537            .await;
538        cx.assert_binding_matches(
539            ["v", "w", "j", "x"],
540            indoc! {"
541                The ˇquick brown
542                fox jumps over
543                the lazy dog"},
544        )
545        .await;
546        // Test pasting code copied on delete
547        cx.simulate_shared_keystrokes(["j", "p"]).await;
548        cx.assert_state_matches().await;
549
550        let mut cx = cx.binding(["v", "w", "j", "x"]);
551        cx.assert_all(indoc! {"
552                The ˇquick brown
553                fox jumps over
554                the ˇlazy dog"})
555            .await;
556        let mut cx = cx.binding(["v", "b", "k", "x"]);
557        cx.assert_all(indoc! {"
558                The ˇquick brown
559                fox jumps ˇover
560                the ˇlazy dog"})
561            .await;
562    }
563
564    #[gpui::test]
565    async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) {
566        let mut cx = NeovimBackedTestContext::new(cx).await;
567
568        cx.set_shared_state(indoc! {"
569                The quˇick brown
570                fox jumps over
571                the lazy dog"})
572            .await;
573        cx.simulate_shared_keystrokes(["shift-v", "x"]).await;
574        cx.assert_state_matches().await;
575
576        // Test pasting code copied on delete
577        cx.simulate_shared_keystroke("p").await;
578        cx.assert_state_matches().await;
579
580        cx.set_shared_state(indoc! {"
581                The quick brown
582                fox jumps over
583                the laˇzy dog"})
584            .await;
585        cx.simulate_shared_keystrokes(["shift-v", "x"]).await;
586        cx.assert_state_matches().await;
587        cx.assert_shared_clipboard("the lazy dog\n").await;
588
589        for marked_text in cx.each_marked_position(indoc! {"
590                        The quˇick brown
591                        fox jumps over
592                        the lazy dog"})
593        {
594            cx.set_shared_state(&marked_text).await;
595            cx.simulate_shared_keystrokes(["shift-v", "j", "x"]).await;
596            cx.assert_state_matches().await;
597            // Test pasting code copied on delete
598            cx.simulate_shared_keystroke("p").await;
599            cx.assert_state_matches().await;
600        }
601
602        cx.set_shared_state(indoc! {"
603            The ˇlong line
604            should not
605            crash
606            "})
607            .await;
608        cx.simulate_shared_keystrokes(["shift-v", "$", "x"]).await;
609        cx.assert_state_matches().await;
610    }
611
612    #[gpui::test]
613    async fn test_visual_yank(cx: &mut gpui::TestAppContext) {
614        let mut cx = NeovimBackedTestContext::new(cx).await;
615
616        cx.set_shared_state("The quick ˇbrown").await;
617        cx.simulate_shared_keystrokes(["v", "w", "y"]).await;
618        cx.assert_shared_state("The quick ˇbrown").await;
619        cx.assert_shared_clipboard("brown").await;
620
621        cx.set_shared_state(indoc! {"
622                The ˇquick brown
623                fox jumps over
624                the lazy dog"})
625            .await;
626        cx.simulate_shared_keystrokes(["v", "w", "j", "y"]).await;
627        cx.assert_shared_state(indoc! {"
628                    The ˇquick brown
629                    fox jumps over
630                    the lazy dog"})
631            .await;
632        cx.assert_shared_clipboard(indoc! {"
633                quick brown
634                fox jumps o"})
635            .await;
636
637        cx.set_shared_state(indoc! {"
638                    The quick brown
639                    fox jumps over
640                    the ˇlazy dog"})
641            .await;
642        cx.simulate_shared_keystrokes(["v", "w", "j", "y"]).await;
643        cx.assert_shared_state(indoc! {"
644                    The quick brown
645                    fox jumps over
646                    the ˇlazy dog"})
647            .await;
648        cx.assert_shared_clipboard("lazy d").await;
649        cx.simulate_shared_keystrokes(["shift-v", "y"]).await;
650        cx.assert_shared_clipboard("the lazy dog\n").await;
651
652        let mut cx = cx.binding(["v", "b", "k", "y"]);
653        cx.set_shared_state(indoc! {"
654                    The ˇquick brown
655                    fox jumps over
656                    the lazy dog"})
657            .await;
658        cx.simulate_shared_keystrokes(["v", "b", "k", "y"]).await;
659        cx.assert_shared_state(indoc! {"
660                    ˇThe quick brown
661                    fox jumps over
662                    the lazy dog"})
663            .await;
664        cx.assert_clipboard_content(Some("The q"));
665    }
666
667    #[gpui::test]
668    async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) {
669        let mut cx = NeovimBackedTestContext::new(cx).await;
670
671        cx.set_shared_state(indoc! {
672            "The ˇquick brown
673             fox jumps over
674             the lazy dog"
675        })
676        .await;
677        cx.simulate_shared_keystrokes(["ctrl-v"]).await;
678        cx.assert_shared_state(indoc! {
679            "The «qˇ»uick brown
680            fox jumps over
681            the lazy dog"
682        })
683        .await;
684        cx.simulate_shared_keystrokes(["2", "down"]).await;
685        cx.assert_shared_state(indoc! {
686            "The «qˇ»uick brown
687            fox «jˇ»umps over
688            the «lˇ»azy dog"
689        })
690        .await;
691        cx.simulate_shared_keystrokes(["e"]).await;
692        cx.assert_shared_state(indoc! {
693            "The «quicˇ»k brown
694            fox «jumpˇ»s over
695            the «lazyˇ» dog"
696        })
697        .await;
698        cx.simulate_shared_keystrokes(["^"]).await;
699        cx.assert_shared_state(indoc! {
700            "«ˇThe q»uick brown
701            «ˇfox j»umps over
702            «ˇthe l»azy dog"
703        })
704        .await;
705        cx.simulate_shared_keystrokes(["$"]).await;
706        cx.assert_shared_state(indoc! {
707            "The «quick brownˇ»
708            fox «jumps overˇ»
709            the «lazy dogˇ»"
710        })
711        .await;
712        cx.simulate_shared_keystrokes(["shift-f", " "]).await;
713        cx.assert_shared_state(indoc! {
714            "The «quickˇ» brown
715            fox «jumpsˇ» over
716            the «lazy ˇ»dog"
717        })
718        .await;
719
720        // toggling through visual mode works as expected
721        cx.simulate_shared_keystrokes(["v"]).await;
722        cx.assert_shared_state(indoc! {
723            "The «quick brown
724            fox jumps over
725            the lazy ˇ»dog"
726        })
727        .await;
728        cx.simulate_shared_keystrokes(["ctrl-v"]).await;
729        cx.assert_shared_state(indoc! {
730            "The «quickˇ» brown
731            fox «jumpsˇ» over
732            the «lazy ˇ»dog"
733        })
734        .await;
735
736        cx.set_shared_state(indoc! {
737            "The ˇquick
738             brown
739             fox
740             jumps over the
741
742             lazy dog
743            "
744        })
745        .await;
746        cx.simulate_shared_keystrokes(["ctrl-v", "down", "down"])
747            .await;
748        cx.assert_shared_state(indoc! {
749            "The«ˇ q»uick
750            bro«ˇwn»
751            foxˇ
752            jumps over the
753
754            lazy dog
755            "
756        })
757        .await;
758        cx.simulate_shared_keystrokes(["down"]).await;
759        cx.assert_shared_state(indoc! {
760            "The «qˇ»uick
761            brow«nˇ»
762            fox
763            jump«sˇ» over the
764
765            lazy dog
766            "
767        })
768        .await;
769        cx.simulate_shared_keystroke("left").await;
770        cx.assert_shared_state(indoc! {
771            "The«ˇ q»uick
772            bro«ˇwn»
773            foxˇ
774            jum«ˇps» over the
775
776            lazy dog
777            "
778        })
779        .await;
780        cx.simulate_shared_keystrokes(["s", "o", "escape"]).await;
781        cx.assert_shared_state(indoc! {
782            "Theˇouick
783            broo
784            foxo
785            jumo over the
786
787            lazy dog
788            "
789        })
790        .await;
791    }
792
793    #[gpui::test]
794    async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {
795        let mut cx = NeovimBackedTestContext::new(cx).await;
796
797        cx.set_shared_state(indoc! {
798            "ˇThe quick brown
799            fox jumps over
800            the lazy dog
801            "
802        })
803        .await;
804        cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
805        cx.assert_shared_state(indoc! {
806            "«Tˇ»he quick brown
807            «fˇ»ox jumps over
808            «tˇ»he lazy dog
809            ˇ"
810        })
811        .await;
812
813        cx.simulate_shared_keystrokes(["shift-i", "k", "escape"])
814            .await;
815        cx.assert_shared_state(indoc! {
816            "ˇkThe quick brown
817            kfox jumps over
818            kthe lazy dog
819            k"
820        })
821        .await;
822
823        cx.set_shared_state(indoc! {
824            "ˇThe quick brown
825            fox jumps over
826            the lazy dog
827            "
828        })
829        .await;
830        cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
831        cx.assert_shared_state(indoc! {
832            "«Tˇ»he quick brown
833            «fˇ»ox jumps over
834            «tˇ»he lazy dog
835            ˇ"
836        })
837        .await;
838        cx.simulate_shared_keystrokes(["c", "k", "escape"]).await;
839        cx.assert_shared_state(indoc! {
840            "ˇkhe quick brown
841            kox jumps over
842            khe lazy dog
843            k"
844        })
845        .await;
846    }
847
848    #[gpui::test]
849    async fn test_visual_object(cx: &mut gpui::TestAppContext) {
850        let mut cx = NeovimBackedTestContext::new(cx).await;
851
852        cx.set_shared_state("hello (in [parˇens] o)").await;
853        cx.simulate_shared_keystrokes(["ctrl-v", "l"]).await;
854        cx.simulate_shared_keystrokes(["a", "]"]).await;
855        cx.assert_shared_state("hello (in «[parens]ˇ» o)").await;
856        assert_eq!(cx.mode(), Mode::Visual);
857        cx.simulate_shared_keystrokes(["i", "("]).await;
858        cx.assert_shared_state("hello («in [parens] oˇ»)").await;
859
860        cx.set_shared_state("hello in a wˇord again.").await;
861        cx.simulate_shared_keystrokes(["ctrl-v", "l", "i", "w"])
862            .await;
863        cx.assert_shared_state("hello in a w«ordˇ» again.").await;
864        assert_eq!(cx.mode(), Mode::VisualBlock);
865        cx.simulate_shared_keystrokes(["o", "a", "s"]).await;
866        cx.assert_shared_state("«ˇhello in a word» again.").await;
867        assert_eq!(cx.mode(), Mode::Visual);
868    }
869
870    #[gpui::test]
871    async fn test_mode_across_command(cx: &mut gpui::TestAppContext) {
872        let mut cx = VimTestContext::new(cx, true).await;
873
874        cx.set_state("aˇbc", Mode::Normal);
875        cx.simulate_keystrokes(["ctrl-v"]);
876        assert_eq!(cx.mode(), Mode::VisualBlock);
877        cx.simulate_keystrokes(["cmd-shift-p", "escape"]);
878        assert_eq!(cx.mode(), Mode::VisualBlock);
879    }
880}