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