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