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