visual.rs

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