visual.rs

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