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