change.rs

  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}