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