visual.rs

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