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 |= 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}