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