1use std::{borrow::Cow, sync::Arc};
2
3use collections::HashMap;
4use editor::{
5 display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection,
6};
7use gpui::{actions, AppContext, ViewContext, WindowContext};
8use language::{AutoindentMode, SelectionGoal};
9use workspace::Workspace;
10
11use crate::{
12 motion::Motion,
13 object::Object,
14 state::{Mode, Operator},
15 utils::copy_selections_content,
16 Vim,
17};
18
19actions!(
20 vim,
21 [
22 ToggleVisual,
23 ToggleVisualLine,
24 VisualDelete,
25 VisualYank,
26 VisualPaste,
27 OtherEnd,
28 ]
29);
30
31pub fn init(cx: &mut AppContext) {
32 cx.add_action(toggle_visual);
33 cx.add_action(toggle_visual_line);
34 cx.add_action(other_end);
35 cx.add_action(delete);
36 cx.add_action(yank);
37 cx.add_action(paste);
38}
39
40pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
41 Vim::update(cx, |vim, cx| {
42 vim.update_active_editor(cx, |editor, cx| {
43 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
44 s.move_with(|map, selection| {
45 let was_reversed = selection.reversed;
46
47 let mut current_head = selection.head();
48
49 // our motions assume the current character is after the cursor,
50 // but in (forward) visual mode the current character is just
51 // before the end of the selection.
52
53 // If the file ends with a newline (which is common) we don't do this.
54 // so that if you go to the end of such a file you can use "up" to go
55 // to the previous line and have it work somewhat as expected.
56 if !selection.reversed
57 && !selection.is_empty()
58 && !(selection.end.column() == 0 && selection.end == map.max_point())
59 {
60 current_head = movement::left(map, selection.end)
61 }
62
63 let Some((new_head, goal)) =
64 motion.move_point(map, current_head, selection.goal, times) else { return };
65
66 selection.set_head(new_head, goal);
67
68 // ensure the current character is included in the selection.
69 if !selection.reversed {
70 let next_point = movement::right(map, selection.end);
71 if !(next_point.column() == 0 && next_point == map.max_point()) {
72 selection.end = movement::right(map, selection.end)
73 }
74 }
75
76 // vim always ensures the anchor character stays selected.
77 // if our selection has reversed, we need to move the opposite end
78 // to ensure the anchor is still selected.
79 if was_reversed && !selection.reversed {
80 selection.start = movement::left(map, selection.start);
81 } else if !was_reversed && selection.reversed {
82 selection.end = movement::right(map, selection.end);
83 }
84 });
85 });
86 });
87 });
88}
89
90pub fn visual_object(object: Object, cx: &mut WindowContext) {
91 Vim::update(cx, |vim, cx| {
92 if let Some(Operator::Object { around }) = vim.active_operator() {
93 vim.pop_operator(cx);
94
95 vim.update_active_editor(cx, |editor, cx| {
96 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
97 s.move_with(|map, selection| {
98 let mut head = selection.head();
99
100 // all our motions assume that the current character is
101 // after the cursor; however in the case of a visual selection
102 // the current character is before the cursor.
103 if !selection.reversed {
104 head = movement::left(map, head);
105 }
106
107 if let Some(range) = object.range(map, head, around) {
108 if !range.is_empty() {
109 let expand_both_ways = if selection.is_empty() {
110 true
111 // contains only one character
112 } else if let Some((_, start)) =
113 map.reverse_chars_at(selection.end).next()
114 {
115 selection.start == start
116 } else {
117 false
118 };
119
120 if expand_both_ways {
121 selection.start = range.start;
122 selection.end = range.end;
123 } else if selection.reversed {
124 selection.start = range.start;
125 } else {
126 selection.end = range.end;
127 }
128 }
129 }
130 });
131 });
132 });
133 }
134 });
135}
136
137pub fn toggle_visual(_: &mut Workspace, _: &ToggleVisual, cx: &mut ViewContext<Workspace>) {
138 Vim::update(cx, |vim, cx| match vim.state.mode {
139 Mode::Normal | Mode::Insert | Mode::Visual { line: true } => {
140 vim.switch_mode(Mode::Visual { line: false }, false, cx);
141 }
142 Mode::Visual { line: false } => {
143 vim.switch_mode(Mode::Normal, false, cx);
144 }
145 })
146}
147
148pub fn toggle_visual_line(
149 _: &mut Workspace,
150 _: &ToggleVisualLine,
151 cx: &mut ViewContext<Workspace>,
152) {
153 Vim::update(cx, |vim, cx| match vim.state.mode {
154 Mode::Normal | Mode::Insert | Mode::Visual { line: false } => {
155 vim.switch_mode(Mode::Visual { line: true }, false, cx);
156 }
157 Mode::Visual { line: true } => {
158 vim.switch_mode(Mode::Normal, false, cx);
159 }
160 })
161}
162
163pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace>) {
164 Vim::update(cx, |vim, cx| {
165 vim.update_active_editor(cx, |editor, cx| {
166 editor.change_selections(None, cx, |s| {
167 s.move_with(|_, selection| {
168 selection.reversed = !selection.reversed;
169 })
170 })
171 })
172 });
173}
174
175pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
176 Vim::update(cx, |vim, cx| {
177 vim.update_active_editor(cx, |editor, cx| {
178 let mut original_columns: HashMap<_, _> = Default::default();
179 let line_mode = editor.selections.line_mode;
180
181 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
182 s.move_with(|map, selection| {
183 if line_mode {
184 let mut position = selection.head();
185 if !selection.reversed {
186 position = movement::left(map, position);
187 }
188 original_columns.insert(selection.id, position.to_point(map).column);
189 }
190 selection.goal = SelectionGoal::None;
191 });
192 });
193 copy_selections_content(editor, line_mode, cx);
194 editor.insert("", cx);
195
196 // Fixup cursor position after the deletion
197 editor.set_clip_at_line_ends(true, cx);
198 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
199 s.move_with(|map, selection| {
200 let mut cursor = selection.head().to_point(map);
201
202 if let Some(column) = original_columns.get(&selection.id) {
203 cursor.column = *column
204 }
205 let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
206 selection.collapse_to(cursor, selection.goal)
207 });
208 });
209 });
210 vim.switch_mode(Mode::Normal, true, cx);
211 });
212}
213
214pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
215 Vim::update(cx, |vim, cx| {
216 vim.update_active_editor(cx, |editor, cx| {
217 let line_mode = editor.selections.line_mode;
218 copy_selections_content(editor, line_mode, cx);
219 editor.change_selections(None, cx, |s| {
220 s.move_with(|_, selection| {
221 selection.collapse_to(selection.start, SelectionGoal::None)
222 });
223 });
224 });
225 vim.switch_mode(Mode::Normal, true, cx);
226 });
227}
228
229pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>) {
230 Vim::update(cx, |vim, cx| {
231 vim.update_active_editor(cx, |editor, cx| {
232 editor.transact(cx, |editor, cx| {
233 if let Some(item) = cx.read_from_clipboard() {
234 copy_selections_content(editor, editor.selections.line_mode, cx);
235 let mut clipboard_text = Cow::Borrowed(item.text());
236 if let Some(mut clipboard_selections) =
237 item.metadata::<Vec<ClipboardSelection>>()
238 {
239 let (display_map, selections) = editor.selections.all_adjusted_display(cx);
240 let all_selections_were_entire_line =
241 clipboard_selections.iter().all(|s| s.is_entire_line);
242 if clipboard_selections.len() != selections.len() {
243 let mut newline_separated_text = String::new();
244 let mut clipboard_selections =
245 clipboard_selections.drain(..).peekable();
246 let mut ix = 0;
247 while let Some(clipboard_selection) = clipboard_selections.next() {
248 newline_separated_text
249 .push_str(&clipboard_text[ix..ix + clipboard_selection.len]);
250 ix += clipboard_selection.len;
251 if clipboard_selections.peek().is_some() {
252 newline_separated_text.push('\n');
253 }
254 }
255 clipboard_text = Cow::Owned(newline_separated_text);
256 }
257
258 let mut new_selections = Vec::new();
259 editor.buffer().update(cx, |buffer, cx| {
260 let snapshot = buffer.snapshot(cx);
261 let mut start_offset = 0;
262 let mut edits = Vec::new();
263 for (ix, selection) in selections.iter().enumerate() {
264 let to_insert;
265 let linewise;
266 if let Some(clipboard_selection) = clipboard_selections.get(ix) {
267 let end_offset = start_offset + clipboard_selection.len;
268 to_insert = &clipboard_text[start_offset..end_offset];
269 linewise = clipboard_selection.is_entire_line;
270 start_offset = end_offset;
271 } else {
272 to_insert = clipboard_text.as_str();
273 linewise = all_selections_were_entire_line;
274 }
275
276 let mut selection = selection.clone();
277 if !selection.reversed {
278 let adjusted = selection.end;
279 // If the selection is empty, move both the start and end forward one
280 // character
281 if selection.is_empty() {
282 selection.start = adjusted;
283 selection.end = adjusted;
284 } else {
285 selection.end = adjusted;
286 }
287 }
288
289 let range = selection.map(|p| p.to_point(&display_map)).range();
290
291 let new_position = if linewise {
292 edits.push((range.start..range.start, "\n"));
293 let mut new_position = range.start;
294 new_position.column = 0;
295 new_position.row += 1;
296 new_position
297 } else {
298 range.start
299 };
300
301 new_selections.push(selection.map(|_| new_position));
302
303 if linewise && to_insert.ends_with('\n') {
304 edits.push((
305 range.clone(),
306 &to_insert[0..to_insert.len().saturating_sub(1)],
307 ))
308 } else {
309 edits.push((range.clone(), to_insert));
310 }
311
312 if linewise {
313 edits.push((range.end..range.end, "\n"));
314 }
315 }
316 drop(snapshot);
317 buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
318 });
319
320 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
321 s.select(new_selections)
322 });
323 } else {
324 editor.insert(&clipboard_text, cx);
325 }
326 }
327 });
328 });
329 vim.switch_mode(Mode::Normal, true, cx);
330 });
331}
332
333pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
334 Vim::update(cx, |vim, cx| {
335 vim.update_active_editor(cx, |editor, cx| {
336 editor.transact(cx, |editor, cx| {
337 let (display_map, selections) = editor.selections.all_adjusted_display(cx);
338
339 // Selections are biased right at the start. So we need to store
340 // anchors that are biased left so that we can restore the selections
341 // after the change
342 let stable_anchors = editor
343 .selections
344 .disjoint_anchors()
345 .into_iter()
346 .map(|selection| {
347 let start = selection.start.bias_left(&display_map.buffer_snapshot);
348 start..start
349 })
350 .collect::<Vec<_>>();
351
352 let mut edits = Vec::new();
353 for selection in selections.iter() {
354 let selection = selection.clone();
355 for row_range in
356 movement::split_display_range_by_lines(&display_map, selection.range())
357 {
358 let range = row_range.start.to_offset(&display_map, Bias::Right)
359 ..row_range.end.to_offset(&display_map, Bias::Right);
360 let text = text.repeat(range.len());
361 edits.push((range, text));
362 }
363 }
364
365 editor.buffer().update(cx, |buffer, cx| {
366 buffer.edit(edits, None, cx);
367 });
368 editor.change_selections(None, cx, |s| s.select_ranges(stable_anchors));
369 });
370 });
371 vim.switch_mode(Mode::Normal, false, cx);
372 });
373}
374
375#[cfg(test)]
376mod test {
377 use indoc::indoc;
378 use workspace::item::Item;
379
380 use crate::{
381 state::Mode,
382 test::{NeovimBackedTestContext, VimTestContext},
383 };
384
385 #[gpui::test]
386 async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
387 let mut cx = NeovimBackedTestContext::new(cx).await;
388
389 cx.set_shared_state(indoc! {
390 "The ˇquick brown
391 fox jumps over
392 the lazy dog"
393 })
394 .await;
395 let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
396
397 // entering visual mode should select the character
398 // under cursor
399 cx.simulate_shared_keystrokes(["v"]).await;
400 cx.assert_shared_state(indoc! { "The «qˇ»uick brown
401 fox jumps over
402 the lazy dog"})
403 .await;
404 cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
405
406 // forwards motions should extend the selection
407 cx.simulate_shared_keystrokes(["w", "j"]).await;
408 cx.assert_shared_state(indoc! { "The «quick brown
409 fox jumps oˇ»ver
410 the lazy dog"})
411 .await;
412
413 cx.simulate_shared_keystrokes(["escape"]).await;
414 assert_eq!(Mode::Normal, cx.neovim_mode().await);
415 cx.assert_shared_state(indoc! { "The quick brown
416 fox jumps ˇover
417 the lazy dog"})
418 .await;
419
420 // motions work backwards
421 cx.simulate_shared_keystrokes(["v", "k", "b"]).await;
422 cx.assert_shared_state(indoc! { "The «ˇquick brown
423 fox jumps o»ver
424 the lazy dog"})
425 .await;
426
427 // works on empty lines
428 cx.set_shared_state(indoc! {"
429 a
430 ˇ
431 b
432 "})
433 .await;
434 let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
435 cx.simulate_shared_keystrokes(["v"]).await;
436 cx.assert_shared_state(indoc! {"
437 a
438 «
439 ˇ»b
440 "})
441 .await;
442 cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
443
444 // toggles off again
445 cx.simulate_shared_keystrokes(["v"]).await;
446 cx.assert_shared_state(indoc! {"
447 a
448 ˇ
449 b
450 "})
451 .await;
452
453 // works at the end of a document
454 cx.set_shared_state(indoc! {"
455 a
456 b
457 ˇ"})
458 .await;
459
460 cx.simulate_shared_keystrokes(["v"]).await;
461 cx.assert_shared_state(indoc! {"
462 a
463 b
464 ˇ"})
465 .await;
466 assert_eq!(cx.mode(), cx.neovim_mode().await);
467 }
468
469 #[gpui::test]
470 async fn test_enter_visual_line_mode(cx: &mut gpui::TestAppContext) {
471 let mut cx = NeovimBackedTestContext::new(cx).await;
472
473 cx.set_shared_state(indoc! {
474 "The ˇquick brown
475 fox jumps over
476 the lazy dog"
477 })
478 .await;
479 cx.simulate_shared_keystrokes(["shift-v"]).await;
480 cx.assert_shared_state(indoc! { "The «qˇ»uick brown
481 fox jumps over
482 the lazy dog"})
483 .await;
484 assert_eq!(cx.mode(), cx.neovim_mode().await);
485 cx.simulate_shared_keystrokes(["x"]).await;
486 cx.assert_shared_state(indoc! { "fox ˇjumps over
487 the lazy dog"})
488 .await;
489
490 // it should work on empty lines
491 cx.set_shared_state(indoc! {"
492 a
493 ˇ
494 b"})
495 .await;
496 cx.simulate_shared_keystrokes(["shift-v"]).await;
497 cx.assert_shared_state(indoc! { "
498 a
499 «
500 ˇ»b"})
501 .await;
502 cx.simulate_shared_keystrokes(["x"]).await;
503 cx.assert_shared_state(indoc! { "
504 a
505 ˇb"})
506 .await;
507
508 // it should work at the end of the document
509 cx.set_shared_state(indoc! {"
510 a
511 b
512 ˇ"})
513 .await;
514 let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
515 cx.simulate_shared_keystrokes(["shift-v"]).await;
516 cx.assert_shared_state(indoc! {"
517 a
518 b
519 ˇ"})
520 .await;
521 assert_eq!(cx.mode(), cx.neovim_mode().await);
522 cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
523 cx.simulate_shared_keystrokes(["x"]).await;
524 cx.assert_shared_state(indoc! {"
525 a
526 ˇb"})
527 .await;
528 }
529
530 #[gpui::test]
531 async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
532 let mut cx = NeovimBackedTestContext::new(cx).await;
533
534 cx.assert_binding_matches(["v", "w"], "The quick ˇbrown")
535 .await;
536
537 cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown")
538 .await;
539 cx.assert_binding_matches(
540 ["v", "w", "j", "x"],
541 indoc! {"
542 The ˇquick brown
543 fox jumps over
544 the lazy dog"},
545 )
546 .await;
547 // Test pasting code copied on delete
548 cx.simulate_shared_keystrokes(["j", "p"]).await;
549 cx.assert_state_matches().await;
550
551 let mut cx = cx.binding(["v", "w", "j", "x"]);
552 cx.assert_all(indoc! {"
553 The ˇquick brown
554 fox jumps over
555 the ˇlazy dog"})
556 .await;
557 let mut cx = cx.binding(["v", "b", "k", "x"]);
558 cx.assert_all(indoc! {"
559 The ˇquick brown
560 fox jumps ˇover
561 the ˇlazy dog"})
562 .await;
563 }
564
565 #[gpui::test]
566 async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) {
567 let mut cx = NeovimBackedTestContext::new(cx)
568 .await
569 .binding(["shift-v", "x"]);
570 cx.assert(indoc! {"
571 The quˇick brown
572 fox jumps over
573 the lazy dog"})
574 .await;
575 // Test pasting code copied on delete
576 cx.simulate_shared_keystroke("p").await;
577 cx.assert_state_matches().await;
578
579 cx.assert_all(indoc! {"
580 The quick brown
581 fox juˇmps over
582 the laˇzy dog"})
583 .await;
584 let mut cx = cx.binding(["shift-v", "j", "x"]);
585 cx.assert(indoc! {"
586 The quˇick brown
587 fox jumps over
588 the lazy dog"})
589 .await;
590 // Test pasting code copied on delete
591 cx.simulate_shared_keystroke("p").await;
592 cx.assert_state_matches().await;
593
594 cx.assert_all(indoc! {"
595 The quick brown
596 fox juˇmps over
597 the laˇzy dog"})
598 .await;
599
600 cx.set_shared_state(indoc! {"
601 The ˇlong line
602 should not
603 crash
604 "})
605 .await;
606 cx.simulate_shared_keystrokes(["shift-v", "$", "x"]).await;
607 cx.assert_state_matches().await;
608 }
609
610 #[gpui::test]
611 async fn test_visual_yank(cx: &mut gpui::TestAppContext) {
612 let cx = VimTestContext::new(cx, true).await;
613 let mut cx = cx.binding(["v", "w", "y"]);
614 cx.assert("The quick ˇbrown", "The quick ˇbrown");
615 cx.assert_clipboard_content(Some("brown"));
616 let mut cx = cx.binding(["v", "w", "j", "y"]);
617 cx.assert(
618 indoc! {"
619 The ˇquick brown
620 fox jumps over
621 the lazy dog"},
622 indoc! {"
623 The ˇquick brown
624 fox jumps over
625 the lazy dog"},
626 );
627 cx.assert_clipboard_content(Some(indoc! {"
628 quick brown
629 fox jumps o"}));
630 cx.assert(
631 indoc! {"
632 The quick brown
633 fox jumps over
634 the ˇlazy dog"},
635 indoc! {"
636 The quick brown
637 fox jumps over
638 the ˇlazy dog"},
639 );
640 cx.assert_clipboard_content(Some("lazy d"));
641 cx.assert(
642 indoc! {"
643 The quick brown
644 fox jumps ˇover
645 the lazy dog"},
646 indoc! {"
647 The quick brown
648 fox jumps ˇover
649 the lazy dog"},
650 );
651 cx.assert_clipboard_content(Some(indoc! {"
652 over
653 t"}));
654 let mut cx = cx.binding(["v", "b", "k", "y"]);
655 cx.assert(
656 indoc! {"
657 The ˇquick brown
658 fox jumps over
659 the lazy dog"},
660 indoc! {"
661 ˇThe quick brown
662 fox jumps over
663 the lazy dog"},
664 );
665 cx.assert_clipboard_content(Some("The q"));
666 cx.assert(
667 indoc! {"
668 The quick brown
669 fox jumps over
670 the ˇlazy dog"},
671 indoc! {"
672 The quick brown
673 ˇfox jumps over
674 the lazy dog"},
675 );
676 cx.assert_clipboard_content(Some(indoc! {"
677 fox jumps over
678 the l"}));
679 cx.assert(
680 indoc! {"
681 The quick brown
682 fox jumps ˇover
683 the lazy dog"},
684 indoc! {"
685 The ˇquick brown
686 fox jumps over
687 the lazy dog"},
688 );
689 cx.assert_clipboard_content(Some(indoc! {"
690 quick brown
691 fox jumps o"}));
692 }
693
694 #[gpui::test]
695 async fn test_visual_paste(cx: &mut gpui::TestAppContext) {
696 let mut cx = VimTestContext::new(cx, true).await;
697 cx.set_state(
698 indoc! {"
699 The quick brown
700 fox «jumpsˇ» over
701 the lazy dog"},
702 Mode::Visual { line: false },
703 );
704 cx.simulate_keystroke("y");
705 cx.set_state(
706 indoc! {"
707 The quick brown
708 fox jumpˇs over
709 the lazy dog"},
710 Mode::Normal,
711 );
712 cx.simulate_keystroke("p");
713 cx.assert_state(
714 indoc! {"
715 The quick brown
716 fox jumpsjumpˇs over
717 the lazy dog"},
718 Mode::Normal,
719 );
720
721 cx.set_state(
722 indoc! {"
723 The quick brown
724 fox ju«mˇ»ps over
725 the lazy dog"},
726 Mode::Visual { line: true },
727 );
728 cx.simulate_keystroke("d");
729 cx.assert_state(
730 indoc! {"
731 The quick brown
732 the laˇzy dog"},
733 Mode::Normal,
734 );
735 cx.set_state(
736 indoc! {"
737 The quick brown
738 the «lazyˇ» dog"},
739 Mode::Visual { line: false },
740 );
741 cx.simulate_keystroke("p");
742 cx.assert_state(
743 &indoc! {"
744 The quick brown
745 the_
746 ˇfox jumps over
747 dog"}
748 .replace("_", " "), // Hack for trailing whitespace
749 Mode::Normal,
750 );
751 }
752}