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