visual.rs

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