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