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