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