helix.rs

  1use editor::{DisplayPoint, Editor, movement};
  2use gpui::{Action, actions};
  3use gpui::{Context, Window};
  4use language::{CharClassifier, CharKind};
  5use text::SelectionGoal;
  6
  7use crate::{Vim, motion::Motion, state::Mode};
  8
  9actions!(
 10    vim,
 11    [
 12        /// Switches to normal mode after the cursor (Helix-style).
 13        HelixNormalAfter
 14    ]
 15);
 16
 17pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 18    Vim::action(editor, cx, Vim::helix_normal_after);
 19}
 20
 21impl Vim {
 22    pub fn helix_normal_after(
 23        &mut self,
 24        action: &HelixNormalAfter,
 25        window: &mut Window,
 26        cx: &mut Context<Self>,
 27    ) {
 28        if self.active_operator().is_some() {
 29            self.operator_stack.clear();
 30            self.sync_vim_settings(window, cx);
 31            return;
 32        }
 33        self.stop_recording_immediately(action.boxed_clone(), cx);
 34        self.switch_mode(Mode::HelixNormal, false, window, cx);
 35        return;
 36    }
 37
 38    pub fn helix_normal_motion(
 39        &mut self,
 40        motion: Motion,
 41        times: Option<usize>,
 42        window: &mut Window,
 43        cx: &mut Context<Self>,
 44    ) {
 45        self.helix_move_cursor(motion, times, window, cx);
 46    }
 47
 48    fn helix_find_range_forward(
 49        &mut self,
 50        times: Option<usize>,
 51        window: &mut Window,
 52        cx: &mut Context<Self>,
 53        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
 54    ) {
 55        self.update_editor(window, cx, |_, editor, window, cx| {
 56            editor.change_selections(Default::default(), window, cx, |s| {
 57                s.move_with(|map, selection| {
 58                    let times = times.unwrap_or(1);
 59                    let new_goal = SelectionGoal::None;
 60                    let mut head = selection.head();
 61                    let mut tail = selection.tail();
 62
 63                    if head == map.max_point() {
 64                        return;
 65                    }
 66
 67                    // collapse to block cursor
 68                    if tail < head {
 69                        tail = movement::left(map, head);
 70                    } else {
 71                        tail = head;
 72                        head = movement::right(map, head);
 73                    }
 74
 75                    // create a classifier
 76                    let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
 77
 78                    for _ in 0..times {
 79                        let (maybe_next_tail, next_head) =
 80                            movement::find_boundary_trail(map, head, |left, right| {
 81                                is_boundary(left, right, &classifier)
 82                            });
 83
 84                        if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
 85                            break;
 86                        }
 87
 88                        head = next_head;
 89                        if let Some(next_tail) = maybe_next_tail {
 90                            tail = next_tail;
 91                        }
 92                    }
 93
 94                    selection.set_tail(tail, new_goal);
 95                    selection.set_head(head, new_goal);
 96                });
 97            });
 98        });
 99    }
100
101    fn helix_find_range_backward(
102        &mut self,
103        times: Option<usize>,
104        window: &mut Window,
105        cx: &mut Context<Self>,
106        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
107    ) {
108        self.update_editor(window, cx, |_, editor, window, cx| {
109            editor.change_selections(Default::default(), window, cx, |s| {
110                s.move_with(|map, selection| {
111                    let times = times.unwrap_or(1);
112                    let new_goal = SelectionGoal::None;
113                    let mut head = selection.head();
114                    let mut tail = selection.tail();
115
116                    if head == DisplayPoint::zero() {
117                        return;
118                    }
119
120                    // collapse to block cursor
121                    if tail < head {
122                        tail = movement::left(map, head);
123                    } else {
124                        tail = head;
125                        head = movement::right(map, head);
126                    }
127
128                    selection.set_head(head, new_goal);
129                    selection.set_tail(tail, new_goal);
130                    // flip the selection
131                    selection.swap_head_tail();
132                    head = selection.head();
133                    tail = selection.tail();
134
135                    // create a classifier
136                    let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
137
138                    for _ in 0..times {
139                        let (maybe_next_tail, next_head) =
140                            movement::find_preceding_boundary_trail(map, head, |left, right| {
141                                is_boundary(left, right, &classifier)
142                            });
143
144                        if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
145                            break;
146                        }
147
148                        head = next_head;
149                        if let Some(next_tail) = maybe_next_tail {
150                            tail = next_tail;
151                        }
152                    }
153
154                    selection.set_tail(tail, new_goal);
155                    selection.set_head(head, new_goal);
156                });
157            })
158        });
159    }
160
161    pub fn helix_move_and_collapse(
162        &mut self,
163        motion: Motion,
164        times: Option<usize>,
165        window: &mut Window,
166        cx: &mut Context<Self>,
167    ) {
168        self.update_editor(window, cx, |_, editor, window, cx| {
169            let text_layout_details = editor.text_layout_details(window);
170            editor.change_selections(Default::default(), window, cx, |s| {
171                s.move_with(|map, selection| {
172                    let goal = selection.goal;
173                    let cursor = if selection.is_empty() || selection.reversed {
174                        selection.head()
175                    } else {
176                        movement::left(map, selection.head())
177                    };
178
179                    let (point, goal) = motion
180                        .move_point(map, cursor, selection.goal, times, &text_layout_details)
181                        .unwrap_or((cursor, goal));
182
183                    selection.collapse_to(point, goal)
184                })
185            });
186        });
187    }
188
189    pub fn helix_move_cursor(
190        &mut self,
191        motion: Motion,
192        times: Option<usize>,
193        window: &mut Window,
194        cx: &mut Context<Self>,
195    ) {
196        match motion {
197            Motion::NextWordStart { ignore_punctuation } => {
198                self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
199                    let left_kind = classifier.kind_with(left, ignore_punctuation);
200                    let right_kind = classifier.kind_with(right, ignore_punctuation);
201                    let at_newline = (left == '\n') ^ (right == '\n');
202
203                    let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
204                        || at_newline;
205
206                    found
207                })
208            }
209            Motion::NextWordEnd { ignore_punctuation } => {
210                self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
211                    let left_kind = classifier.kind_with(left, ignore_punctuation);
212                    let right_kind = classifier.kind_with(right, ignore_punctuation);
213                    let at_newline = (left == '\n') ^ (right == '\n');
214
215                    let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
216                        || at_newline;
217
218                    found
219                })
220            }
221            Motion::PreviousWordStart { ignore_punctuation } => {
222                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
223                    let left_kind = classifier.kind_with(left, ignore_punctuation);
224                    let right_kind = classifier.kind_with(right, ignore_punctuation);
225                    let at_newline = (left == '\n') ^ (right == '\n');
226
227                    let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
228                        || at_newline;
229
230                    found
231                })
232            }
233            Motion::PreviousWordEnd { ignore_punctuation } => {
234                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
235                    let left_kind = classifier.kind_with(left, ignore_punctuation);
236                    let right_kind = classifier.kind_with(right, ignore_punctuation);
237                    let at_newline = (left == '\n') ^ (right == '\n');
238
239                    let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
240                        || at_newline;
241
242                    found
243                })
244            }
245            Motion::FindForward { .. } => {
246                self.update_editor(window, cx, |_, editor, window, cx| {
247                    let text_layout_details = editor.text_layout_details(window);
248                    editor.change_selections(Default::default(), window, cx, |s| {
249                        s.move_with(|map, selection| {
250                            let goal = selection.goal;
251                            let cursor = if selection.is_empty() || selection.reversed {
252                                selection.head()
253                            } else {
254                                movement::left(map, selection.head())
255                            };
256
257                            let (point, goal) = motion
258                                .move_point(
259                                    map,
260                                    cursor,
261                                    selection.goal,
262                                    times,
263                                    &text_layout_details,
264                                )
265                                .unwrap_or((cursor, goal));
266                            selection.set_tail(selection.head(), goal);
267                            selection.set_head(movement::right(map, point), goal);
268                        })
269                    });
270                });
271            }
272            Motion::FindBackward { .. } => {
273                self.update_editor(window, cx, |_, editor, window, cx| {
274                    let text_layout_details = editor.text_layout_details(window);
275                    editor.change_selections(Default::default(), window, cx, |s| {
276                        s.move_with(|map, selection| {
277                            let goal = selection.goal;
278                            let cursor = if selection.is_empty() || selection.reversed {
279                                selection.head()
280                            } else {
281                                movement::left(map, selection.head())
282                            };
283
284                            let (point, goal) = motion
285                                .move_point(
286                                    map,
287                                    cursor,
288                                    selection.goal,
289                                    times,
290                                    &text_layout_details,
291                                )
292                                .unwrap_or((cursor, goal));
293                            selection.set_tail(selection.head(), goal);
294                            selection.set_head(point, goal);
295                        })
296                    });
297                });
298            }
299            _ => self.helix_move_and_collapse(motion, times, window, cx),
300        }
301    }
302}
303
304#[cfg(test)]
305mod test {
306    use indoc::indoc;
307
308    use crate::{state::Mode, test::VimTestContext};
309
310    #[gpui::test]
311    async fn test_word_motions(cx: &mut gpui::TestAppContext) {
312        let mut cx = VimTestContext::new(cx, true).await;
313        // «
314        // ˇ
315        // »
316        cx.set_state(
317            indoc! {"
318            Th«e quiˇ»ck brown
319            fox jumps over
320            the lazy dog."},
321            Mode::HelixNormal,
322        );
323
324        cx.simulate_keystrokes("w");
325
326        cx.assert_state(
327            indoc! {"
328            The qu«ick ˇ»brown
329            fox jumps over
330            the lazy dog."},
331            Mode::HelixNormal,
332        );
333
334        cx.simulate_keystrokes("w");
335
336        cx.assert_state(
337            indoc! {"
338            The quick «brownˇ»
339            fox jumps over
340            the lazy dog."},
341            Mode::HelixNormal,
342        );
343
344        cx.simulate_keystrokes("2 b");
345
346        cx.assert_state(
347            indoc! {"
348            The «ˇquick »brown
349            fox jumps over
350            the lazy dog."},
351            Mode::HelixNormal,
352        );
353
354        cx.simulate_keystrokes("down e up");
355
356        cx.assert_state(
357            indoc! {"
358            The quicˇk brown
359            fox jumps over
360            the lazy dog."},
361            Mode::HelixNormal,
362        );
363
364        cx.set_state("aa\n  «ˇbb»", Mode::HelixNormal);
365
366        cx.simulate_keystroke("b");
367
368        cx.assert_state("aa\n«ˇ  »bb", Mode::HelixNormal);
369    }
370
371    #[gpui::test]
372    async fn test_delete(cx: &mut gpui::TestAppContext) {
373        let mut cx = VimTestContext::new(cx, true).await;
374
375        // test delete a selection
376        cx.set_state(
377            indoc! {"
378            The qu«ick ˇ»brown
379            fox jumps over
380            the lazy dog."},
381            Mode::HelixNormal,
382        );
383
384        cx.simulate_keystrokes("d");
385
386        cx.assert_state(
387            indoc! {"
388            The quˇbrown
389            fox jumps over
390            the lazy dog."},
391            Mode::HelixNormal,
392        );
393
394        // test deleting a single character
395        cx.simulate_keystrokes("d");
396
397        cx.assert_state(
398            indoc! {"
399            The quˇrown
400            fox jumps over
401            the lazy dog."},
402            Mode::HelixNormal,
403        );
404    }
405
406    // #[gpui::test]
407    // async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
408    //     let mut cx = VimTestContext::new(cx, true).await;
409
410    //     cx.set_state(
411    //         indoc! {"
412    //         The quick brownˇ
413    //         fox jumps over
414    //         the lazy dog."},
415    //         Mode::HelixNormal,
416    //     );
417
418    //     cx.simulate_keystrokes("d");
419
420    //     cx.assert_state(
421    //         indoc! {"
422    //         The quick brownˇfox jumps over
423    //         the lazy dog."},
424    //         Mode::HelixNormal,
425    //     );
426    // }
427
428    // #[gpui::test]
429    // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
430    //     let mut cx = VimTestContext::new(cx, true).await;
431
432    //     cx.set_state(
433    //         indoc! {"
434    //         The quick brown
435    //         fox jumps over
436    //         the lazy dog.ˇ"},
437    //         Mode::HelixNormal,
438    //     );
439
440    //     cx.simulate_keystrokes("d");
441
442    //     cx.assert_state(
443    //         indoc! {"
444    //         The quick brown
445    //         fox jumps over
446    //         the lazy dog.ˇ"},
447    //         Mode::HelixNormal,
448    //     );
449    // }
450
451    #[gpui::test]
452    async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
453        let mut cx = VimTestContext::new(cx, true).await;
454
455        cx.set_state(
456            indoc! {"
457            The quˇick brown
458            fox jumps over
459            the lazy dog."},
460            Mode::HelixNormal,
461        );
462
463        cx.simulate_keystrokes("f z");
464
465        cx.assert_state(
466            indoc! {"
467                The qu«ick brown
468                fox jumps over
469                the lazˇ»y dog."},
470            Mode::HelixNormal,
471        );
472
473        cx.simulate_keystrokes("2 T r");
474
475        cx.assert_state(
476            indoc! {"
477                The quick br«ˇown
478                fox jumps over
479                the laz»y dog."},
480            Mode::HelixNormal,
481        );
482    }
483
484    #[gpui::test]
485    async fn test_newline_char(cx: &mut gpui::TestAppContext) {
486        let mut cx = VimTestContext::new(cx, true).await;
487
488        cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
489
490        cx.simulate_keystroke("w");
491
492        cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
493
494        cx.set_state("aa«\nˇ»", Mode::HelixNormal);
495
496        cx.simulate_keystroke("b");
497
498        cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
499    }
500}