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