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