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