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