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