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