1use crate::{
2 Vim,
3 motion::{self, Motion, MotionKind},
4 object::Object,
5 state::Mode,
6};
7use editor::{
8 Bias, DisplayPoint,
9 display_map::{DisplaySnapshot, ToDisplayPoint},
10 movement::TextLayoutDetails,
11};
12use gpui::{Context, Window};
13use language::Selection;
14
15impl Vim {
16 pub fn change_motion(
17 &mut self,
18 motion: Motion,
19 times: Option<usize>,
20 forced_motion: bool,
21 window: &mut Window,
22 cx: &mut Context<Self>,
23 ) {
24 // Some motions ignore failure when switching to normal mode
25 let mut motion_kind = if matches!(
26 motion,
27 Motion::Left
28 | Motion::Right
29 | Motion::EndOfLine { .. }
30 | Motion::WrappingLeft
31 | Motion::StartOfLine { .. }
32 ) {
33 Some(MotionKind::Exclusive)
34 } else {
35 None
36 };
37 self.update_editor(cx, |vim, editor, cx| {
38 let text_layout_details = editor.text_layout_details(window);
39 editor.transact(window, cx, |editor, window, cx| {
40 // We are swapping to insert mode anyway. Just set the line end clipping behavior now
41 editor.set_clip_at_line_ends(false, cx);
42 editor.change_selections(Default::default(), window, cx, |s| {
43 s.move_with(|map, selection| {
44 let kind = match motion {
45 Motion::NextWordStart { ignore_punctuation }
46 | Motion::NextSubwordStart { ignore_punctuation } => {
47 expand_changed_word_selection(
48 map,
49 selection,
50 times,
51 ignore_punctuation,
52 &text_layout_details,
53 motion == Motion::NextSubwordStart { ignore_punctuation },
54 !matches!(motion, Motion::NextWordStart { .. }),
55 )
56 }
57 _ => {
58 let kind = motion.expand_selection(
59 map,
60 selection,
61 times,
62 &text_layout_details,
63 forced_motion,
64 );
65 if matches!(
66 motion,
67 Motion::CurrentLine | Motion::Down { .. } | Motion::Up { .. }
68 ) {
69 let mut start_offset =
70 selection.start.to_offset(map, Bias::Left);
71 let classifier = map
72 .buffer_snapshot()
73 .char_classifier_at(selection.start.to_point(map));
74 for (ch, offset) in map.buffer_chars_at(start_offset) {
75 if ch == '\n' || !classifier.is_whitespace(ch) {
76 break;
77 }
78 start_offset = offset + ch.len_utf8();
79 }
80 selection.start = start_offset.to_display_point(map);
81 }
82 kind
83 }
84 };
85 if let Some(kind) = kind {
86 motion_kind.get_or_insert(kind);
87 }
88 });
89 });
90 if let Some(kind) = motion_kind {
91 vim.copy_selections_content(editor, kind, window, cx);
92 editor.insert("", window, cx);
93 editor.refresh_edit_prediction(true, false, window, cx);
94 }
95 });
96 });
97
98 if motion_kind.is_some() {
99 self.switch_mode(Mode::Insert, false, window, cx)
100 } else {
101 self.switch_mode(Mode::Normal, false, window, cx)
102 }
103 }
104
105 pub fn change_object(
106 &mut self,
107 object: Object,
108 around: bool,
109 times: Option<usize>,
110 window: &mut Window,
111 cx: &mut Context<Self>,
112 ) {
113 let mut objects_found = false;
114 self.update_editor(cx, |vim, editor, cx| {
115 // We are swapping to insert mode anyway. Just set the line end clipping behavior now
116 editor.set_clip_at_line_ends(false, cx);
117 editor.transact(window, cx, |editor, window, cx| {
118 editor.change_selections(Default::default(), window, cx, |s| {
119 s.move_with(|map, selection| {
120 objects_found |= object.expand_selection(map, selection, around, times);
121 });
122 });
123 if objects_found {
124 let kind = match object.target_visual_mode(vim.mode, around) {
125 Mode::VisualLine => MotionKind::Linewise,
126 _ => MotionKind::Exclusive,
127 };
128 vim.copy_selections_content(editor, kind, window, cx);
129 editor.insert("", window, cx);
130 editor.refresh_edit_prediction(true, false, window, cx);
131 }
132 });
133 });
134
135 if objects_found {
136 self.switch_mode(Mode::Insert, false, window, cx);
137 } else {
138 self.switch_mode(Mode::Normal, false, window, cx);
139 }
140 }
141}
142
143// From the docs https://vimdoc.sourceforge.net/htmldoc/motion.html
144// Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is
145// on a non-blank. This is because "cw" is interpreted as change-word, and a
146// word does not include the following white space. {Vi: "cw" when on a blank
147// followed by other blanks changes only the first blank; this is probably a
148// bug, because "dw" deletes all the blanks}
149fn expand_changed_word_selection(
150 map: &DisplaySnapshot,
151 selection: &mut Selection<DisplayPoint>,
152 times: Option<usize>,
153 ignore_punctuation: bool,
154 text_layout_details: &TextLayoutDetails,
155 use_subword: bool,
156 always_advance: bool,
157) -> Option<MotionKind> {
158 let is_in_word = || {
159 let classifier = map
160 .buffer_snapshot()
161 .char_classifier_at(selection.start.to_point(map));
162
163 map.buffer_chars_at(selection.head().to_offset(map, Bias::Left))
164 .next()
165 .map(|(c, _)| !classifier.is_whitespace(c))
166 .unwrap_or_default()
167 };
168 if (times.is_none() || times.unwrap() == 1) && is_in_word() {
169 let next_char = map
170 .buffer_chars_at(
171 motion::next_char(map, selection.end, false).to_offset(map, Bias::Left),
172 )
173 .next();
174 match next_char {
175 Some((' ', _)) => selection.end = motion::next_char(map, selection.end, false),
176 _ => {
177 if use_subword {
178 selection.end =
179 motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false);
180 } else {
181 selection.end = motion::next_word_end(
182 map,
183 selection.end,
184 ignore_punctuation,
185 1,
186 false,
187 always_advance,
188 );
189 }
190 selection.end = motion::next_char(map, selection.end, false);
191 }
192 }
193 Some(MotionKind::Inclusive)
194 } else {
195 let motion = if use_subword {
196 Motion::NextSubwordStart { ignore_punctuation }
197 } else {
198 Motion::NextWordStart { ignore_punctuation }
199 };
200 motion.expand_selection(map, selection, times, text_layout_details, false)
201 }
202}
203
204#[cfg(test)]
205mod test {
206 use indoc::indoc;
207
208 use crate::test::NeovimBackedTestContext;
209
210 #[gpui::test]
211 async fn test_change_h(cx: &mut gpui::TestAppContext) {
212 let mut cx = NeovimBackedTestContext::new(cx).await;
213 cx.simulate("c h", "Teˇst").await.assert_matches();
214 cx.simulate("c h", "Tˇest").await.assert_matches();
215 cx.simulate("c h", "ˇTest").await.assert_matches();
216 cx.simulate(
217 "c h",
218 indoc! {"
219 Test
220 ˇtest"},
221 )
222 .await
223 .assert_matches();
224 }
225
226 #[gpui::test]
227 async fn test_change_backspace(cx: &mut gpui::TestAppContext) {
228 let mut cx = NeovimBackedTestContext::new(cx).await;
229 cx.simulate("c backspace", "Teˇst").await.assert_matches();
230 cx.simulate("c backspace", "Tˇest").await.assert_matches();
231 cx.simulate("c backspace", "ˇTest").await.assert_matches();
232 cx.simulate(
233 "c backspace",
234 indoc! {"
235 Test
236 ˇtest"},
237 )
238 .await
239 .assert_matches();
240 }
241
242 #[gpui::test]
243 async fn test_change_l(cx: &mut gpui::TestAppContext) {
244 let mut cx = NeovimBackedTestContext::new(cx).await;
245 cx.simulate("c l", "Teˇst").await.assert_matches();
246 cx.simulate("c l", "Tesˇt").await.assert_matches();
247 }
248
249 #[gpui::test]
250 async fn test_change_w(cx: &mut gpui::TestAppContext) {
251 let mut cx = NeovimBackedTestContext::new(cx).await;
252 cx.simulate("c w", "Teˇst").await.assert_matches();
253 cx.simulate("c w", "Tˇest test").await.assert_matches();
254 cx.simulate("c w", "Testˇ test").await.assert_matches();
255 cx.simulate("c w", "Tesˇt test").await.assert_matches();
256 cx.simulate(
257 "c w",
258 indoc! {"
259 Test teˇst
260 test"},
261 )
262 .await
263 .assert_matches();
264 cx.simulate(
265 "c w",
266 indoc! {"
267 Test tesˇt
268 test"},
269 )
270 .await
271 .assert_matches();
272 cx.simulate(
273 "c w",
274 indoc! {"
275 Test test
276 ˇ
277 test"},
278 )
279 .await
280 .assert_matches();
281
282 cx.simulate("c shift-w", "Test teˇst-test test")
283 .await
284 .assert_matches();
285
286 // on last character of word, `cw` doesn't eat subsequent punctuation
287 // see https://github.com/zed-industries/zed/issues/35269
288 cx.simulate("c w", "tesˇt-test").await.assert_matches();
289 }
290
291 #[gpui::test]
292 async fn test_change_e(cx: &mut gpui::TestAppContext) {
293 let mut cx = NeovimBackedTestContext::new(cx).await;
294 cx.simulate("c e", "Teˇst Test").await.assert_matches();
295 cx.simulate("c e", "Tˇest test").await.assert_matches();
296 cx.simulate(
297 "c e",
298 indoc! {"
299 Test teˇst
300 test"},
301 )
302 .await
303 .assert_matches();
304 cx.simulate(
305 "c e",
306 indoc! {"
307 Test tesˇt
308 test"},
309 )
310 .await
311 .assert_matches();
312 cx.simulate(
313 "c e",
314 indoc! {"
315 Test test
316 ˇ
317 test"},
318 )
319 .await
320 .assert_matches();
321
322 cx.simulate("c shift-e", "Test teˇst-test test")
323 .await
324 .assert_matches();
325 }
326
327 #[gpui::test]
328 async fn test_change_b(cx: &mut gpui::TestAppContext) {
329 let mut cx = NeovimBackedTestContext::new(cx).await;
330 cx.simulate("c b", "Teˇst Test").await.assert_matches();
331 cx.simulate("c b", "Test ˇtest").await.assert_matches();
332 cx.simulate("c b", "Test1 test2 ˇtest3")
333 .await
334 .assert_matches();
335 cx.simulate(
336 "c b",
337 indoc! {"
338 Test test
339 ˇtest"},
340 )
341 .await
342 .assert_matches();
343 cx.simulate(
344 "c b",
345 indoc! {"
346 Test test
347 ˇ
348 test"},
349 )
350 .await
351 .assert_matches();
352
353 cx.simulate("c shift-b", "Test test-test ˇtest")
354 .await
355 .assert_matches();
356 }
357
358 #[gpui::test]
359 async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) {
360 let mut cx = NeovimBackedTestContext::new(cx).await;
361 cx.simulate(
362 "c $",
363 indoc! {"
364 The qˇuick
365 brown fox"},
366 )
367 .await
368 .assert_matches();
369 cx.simulate(
370 "c $",
371 indoc! {"
372 The quick
373 ˇ
374 brown fox"},
375 )
376 .await
377 .assert_matches();
378 }
379
380 #[gpui::test]
381 async fn test_change_0(cx: &mut gpui::TestAppContext) {
382 let mut cx = NeovimBackedTestContext::new(cx).await;
383
384 cx.simulate(
385 "c 0",
386 indoc! {"
387 The qˇuick
388 brown fox"},
389 )
390 .await
391 .assert_matches();
392 cx.simulate(
393 "c 0",
394 indoc! {"
395 The quick
396 ˇ
397 brown fox"},
398 )
399 .await
400 .assert_matches();
401 }
402
403 #[gpui::test]
404 async fn test_change_k(cx: &mut gpui::TestAppContext) {
405 let mut cx = NeovimBackedTestContext::new(cx).await;
406
407 cx.simulate(
408 "c k",
409 indoc! {"
410 The quick
411 brown ˇfox
412 jumps over"},
413 )
414 .await
415 .assert_matches();
416 cx.simulate(
417 "c k",
418 indoc! {"
419 The quick
420 brown fox
421 jumps ˇover"},
422 )
423 .await
424 .assert_matches();
425 cx.simulate(
426 "c k",
427 indoc! {"
428 The qˇuick
429 brown fox
430 jumps over"},
431 )
432 .await
433 .assert_matches();
434 cx.simulate(
435 "c k",
436 indoc! {"
437 ˇ
438 brown fox
439 jumps over"},
440 )
441 .await
442 .assert_matches();
443 cx.simulate(
444 "c k",
445 indoc! {"
446 The quick
447 brown fox
448 ˇjumps over"},
449 )
450 .await
451 .assert_matches();
452 }
453
454 #[gpui::test]
455 async fn test_change_j(cx: &mut gpui::TestAppContext) {
456 let mut cx = NeovimBackedTestContext::new(cx).await;
457 cx.simulate(
458 "c j",
459 indoc! {"
460 The quick
461 brown ˇfox
462 jumps over"},
463 )
464 .await
465 .assert_matches();
466 cx.simulate(
467 "c j",
468 indoc! {"
469 The quick
470 brown fox
471 jumps ˇover"},
472 )
473 .await
474 .assert_matches();
475 cx.simulate(
476 "c j",
477 indoc! {"
478 The qˇuick
479 brown fox
480 jumps over"},
481 )
482 .await
483 .assert_matches();
484 cx.simulate(
485 "c j",
486 indoc! {"
487 The quick
488 brown fox
489 ˇ"},
490 )
491 .await
492 .assert_matches();
493 cx.simulate(
494 "c j",
495 indoc! {"
496 The quick
497 ˇbrown fox
498 jumps over"},
499 )
500 .await
501 .assert_matches();
502 }
503
504 #[gpui::test]
505 async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
506 let mut cx = NeovimBackedTestContext::new(cx).await;
507 cx.simulate(
508 "c shift-g",
509 indoc! {"
510 The quick
511 brownˇ fox
512 jumps over
513 the lazy"},
514 )
515 .await
516 .assert_matches();
517 cx.simulate(
518 "c shift-g",
519 indoc! {"
520 The quick
521 brownˇ fox
522 jumps over
523 the lazy"},
524 )
525 .await
526 .assert_matches();
527 cx.simulate(
528 "c shift-g",
529 indoc! {"
530 The quick
531 brown fox
532 jumps over
533 the lˇazy"},
534 )
535 .await
536 .assert_matches();
537 cx.simulate(
538 "c shift-g",
539 indoc! {"
540 The quick
541 brown fox
542 jumps over
543 ˇ"},
544 )
545 .await
546 .assert_matches();
547 }
548
549 #[gpui::test]
550 async fn test_change_cc(cx: &mut gpui::TestAppContext) {
551 let mut cx = NeovimBackedTestContext::new(cx).await;
552 cx.simulate(
553 "c c",
554 indoc! {"
555 The quick
556 brownˇ fox
557 jumps over
558 the lazy"},
559 )
560 .await
561 .assert_matches();
562
563 cx.simulate(
564 "c c",
565 indoc! {"
566 ˇThe quick
567 brown fox
568 jumps over
569 the lazy"},
570 )
571 .await
572 .assert_matches();
573
574 cx.simulate(
575 "c c",
576 indoc! {"
577 The quick
578 broˇwn fox
579 jumps over
580 the lazy"},
581 )
582 .await
583 .assert_matches();
584 }
585
586 #[gpui::test]
587 async fn test_change_gg(cx: &mut gpui::TestAppContext) {
588 let mut cx = NeovimBackedTestContext::new(cx).await;
589 cx.simulate(
590 "c g g",
591 indoc! {"
592 The quick
593 brownˇ fox
594 jumps over
595 the lazy"},
596 )
597 .await
598 .assert_matches();
599 cx.simulate(
600 "c g g",
601 indoc! {"
602 The quick
603 brown fox
604 jumps over
605 the lˇazy"},
606 )
607 .await
608 .assert_matches();
609 cx.simulate(
610 "c g g",
611 indoc! {"
612 The qˇuick
613 brown fox
614 jumps over
615 the lazy"},
616 )
617 .await
618 .assert_matches();
619 cx.simulate(
620 "c g g",
621 indoc! {"
622 ˇ
623 brown fox
624 jumps over
625 the lazy"},
626 )
627 .await
628 .assert_matches();
629 }
630
631 #[gpui::test]
632 async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
633 let mut cx = NeovimBackedTestContext::new(cx).await;
634
635 for count in 1..=5 {
636 cx.simulate_at_each_offset(
637 &format!("c {count} j"),
638 indoc! {"
639 ˇThe quˇickˇ browˇn
640 ˇ
641 ˇfox ˇjumpsˇ-ˇoˇver
642 ˇthe lazy dog
643 "},
644 )
645 .await
646 .assert_matches();
647 }
648 }
649
650 #[gpui::test]
651 async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
652 let mut cx = NeovimBackedTestContext::new(cx).await;
653
654 for count in 1..=5 {
655 cx.simulate_at_each_offset(
656 &format!("c {count} l"),
657 indoc! {"
658 ˇThe quˇickˇ browˇn
659 ˇ
660 ˇfox ˇjumpsˇ-ˇoˇver
661 ˇthe lazy dog
662 "},
663 )
664 .await
665 .assert_matches();
666 }
667 }
668
669 #[gpui::test]
670 async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
671 let mut cx = NeovimBackedTestContext::new(cx).await;
672
673 for count in 1..=5 {
674 cx.simulate_at_each_offset(
675 &format!("c {count} b"),
676 indoc! {"
677 ˇThe quˇickˇ browˇn
678 ˇ
679 ˇfox ˇjumpsˇ-ˇoˇver
680 ˇthe lazy dog
681 "},
682 )
683 .await
684 .assert_matches()
685 }
686 }
687
688 #[gpui::test]
689 async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
690 let mut cx = NeovimBackedTestContext::new(cx).await;
691
692 for count in 1..=5 {
693 cx.simulate_at_each_offset(
694 &format!("c {count} e"),
695 indoc! {"
696 ˇThe quˇickˇ browˇn
697 ˇ
698 ˇfox ˇjumpsˇ-ˇoˇver
699 ˇthe lazy dog
700 "},
701 )
702 .await
703 .assert_matches();
704 }
705 }
706}