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