helix.rs

  1use editor::{movement, scroll::Autoscroll, DisplayPoint, Editor};
  2use gpui::{actions, Action};
  3use gpui::{Context, Window};
  4use language::{CharClassifier, CharKind};
  5
  6use crate::{motion::Motion, state::Mode, Vim};
  7
  8actions!(vim, [HelixNormalAfter, HelixDelete]);
  9
 10pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 11    Vim::action(editor, cx, Vim::helix_normal_after);
 12    Vim::action(editor, cx, Vim::helix_delete);
 13}
 14
 15impl Vim {
 16    pub fn helix_normal_after(
 17        &mut self,
 18        action: &HelixNormalAfter,
 19        window: &mut Window,
 20        cx: &mut Context<Self>,
 21    ) {
 22        if self.active_operator().is_some() {
 23            self.operator_stack.clear();
 24            self.sync_vim_settings(window, cx);
 25            return;
 26        }
 27        self.stop_recording_immediately(action.boxed_clone(), cx);
 28        self.switch_mode(Mode::HelixNormal, false, window, cx);
 29        return;
 30    }
 31
 32    pub fn helix_normal_motion(
 33        &mut self,
 34        motion: Motion,
 35        times: Option<usize>,
 36        window: &mut Window,
 37        cx: &mut Context<Self>,
 38    ) {
 39        self.helix_move_cursor(motion, times, window, cx);
 40    }
 41
 42    fn helix_find_range_forward(
 43        &mut self,
 44        times: Option<usize>,
 45        window: &mut Window,
 46        cx: &mut Context<Self>,
 47        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
 48    ) {
 49        self.update_editor(window, cx, |_, editor, window, cx| {
 50            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
 51                s.move_with(|map, selection| {
 52                    let times = times.unwrap_or(1);
 53
 54                    if selection.head() == map.max_point() {
 55                        return;
 56                    }
 57
 58                    // collapse to block cursor
 59                    if selection.tail() < selection.head() {
 60                        selection.set_tail(movement::left(map, selection.head()), selection.goal);
 61                    } else {
 62                        selection.set_tail(selection.head(), selection.goal);
 63                        selection.set_head(movement::right(map, selection.head()), selection.goal);
 64                    }
 65
 66                    // create a classifier
 67                    let classifier = map
 68                        .buffer_snapshot
 69                        .char_classifier_at(selection.head().to_point(map));
 70
 71                    let mut last_selection = selection.clone();
 72                    for _ in 0..times {
 73                        let (new_tail, new_head) =
 74                            movement::find_boundary_trail(map, selection.head(), |left, right| {
 75                                is_boundary(left, right, &classifier)
 76                            });
 77
 78                        selection.set_head(new_head, selection.goal);
 79                        if let Some(new_tail) = new_tail {
 80                            selection.set_tail(new_tail, selection.goal);
 81                        }
 82
 83                        if selection.head() == last_selection.head()
 84                            && selection.tail() == last_selection.tail()
 85                        {
 86                            break;
 87                        }
 88                        last_selection = selection.clone();
 89                    }
 90                });
 91            });
 92        });
 93    }
 94
 95    fn helix_find_range_backward(
 96        &mut self,
 97        times: Option<usize>,
 98        window: &mut Window,
 99        cx: &mut Context<Self>,
100        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
101    ) {
102        self.update_editor(window, cx, |_, editor, window, cx| {
103            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
104                s.move_with(|map, selection| {
105                    let times = times.unwrap_or(1);
106
107                    if selection.head() == DisplayPoint::zero() {
108                        return;
109                    }
110
111                    // collapse to block cursor
112                    if selection.tail() < selection.head() {
113                        selection.set_tail(movement::left(map, selection.head()), selection.goal);
114                    } else {
115                        selection.set_tail(selection.head(), selection.goal);
116                        selection.set_head(movement::right(map, selection.head()), selection.goal);
117                    }
118
119                    // flip the selection
120                    selection.swap_head_tail();
121
122                    // create a classifier
123                    let classifier = map
124                        .buffer_snapshot
125                        .char_classifier_at(selection.head().to_point(map));
126
127                    let mut last_selection = selection.clone();
128                    for _ in 0..times {
129                        let (new_tail, new_head) = movement::find_preceding_boundary_trail(
130                            map,
131                            selection.head(),
132                            |left, right| is_boundary(left, right, &classifier),
133                        );
134
135                        selection.set_head(new_head, selection.goal);
136                        if let Some(new_tail) = new_tail {
137                            selection.set_tail(new_tail, selection.goal);
138                        }
139
140                        if selection.head() == last_selection.head()
141                            && selection.tail() == last_selection.tail()
142                        {
143                            break;
144                        }
145                        last_selection = selection.clone();
146                    }
147                });
148            })
149        });
150    }
151
152    pub fn helix_move_and_collapse(
153        &mut self,
154        motion: Motion,
155        times: Option<usize>,
156        window: &mut Window,
157        cx: &mut Context<Self>,
158    ) {
159        self.update_editor(window, cx, |_, editor, window, cx| {
160            let text_layout_details = editor.text_layout_details(window);
161            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
162                s.move_with(|map, selection| {
163                    let goal = selection.goal;
164                    let cursor = if selection.is_empty() || selection.reversed {
165                        selection.head()
166                    } else {
167                        movement::left(map, selection.head())
168                    };
169
170                    let (point, goal) = motion
171                        .move_point(map, cursor, selection.goal, times, &text_layout_details)
172                        .unwrap_or((cursor, goal));
173
174                    selection.collapse_to(point, goal)
175                })
176            });
177        });
178    }
179
180    pub fn helix_move_cursor(
181        &mut self,
182        motion: Motion,
183        times: Option<usize>,
184        window: &mut Window,
185        cx: &mut Context<Self>,
186    ) {
187        match motion {
188            Motion::NextWordStart { ignore_punctuation } => {
189                self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
190                    let left_kind = classifier.kind_with(left, ignore_punctuation);
191                    let right_kind = classifier.kind_with(right, ignore_punctuation);
192                    let at_newline = right == '\n';
193
194                    let found =
195                        left_kind != right_kind && right_kind != CharKind::Whitespace || at_newline;
196
197                    found
198                })
199            }
200            Motion::NextWordEnd { ignore_punctuation } => {
201                self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
202                    let left_kind = classifier.kind_with(left, ignore_punctuation);
203                    let right_kind = classifier.kind_with(right, ignore_punctuation);
204                    let at_newline = right == '\n';
205
206                    let found = left_kind != right_kind
207                        && (left_kind != CharKind::Whitespace || at_newline);
208
209                    found
210                })
211            }
212            Motion::PreviousWordStart { ignore_punctuation } => {
213                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
214                    let left_kind = classifier.kind_with(left, ignore_punctuation);
215                    let right_kind = classifier.kind_with(right, ignore_punctuation);
216                    let at_newline = right == '\n';
217
218                    let found = left_kind != right_kind
219                        && (left_kind != CharKind::Whitespace || at_newline);
220
221                    found
222                })
223            }
224            Motion::PreviousWordEnd { ignore_punctuation } => {
225                self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
226                    let left_kind = classifier.kind_with(left, ignore_punctuation);
227                    let right_kind = classifier.kind_with(right, ignore_punctuation);
228                    let at_newline = right == '\n';
229
230                    let found = left_kind != right_kind
231                        && right_kind != CharKind::Whitespace
232                        && !at_newline;
233
234                    found
235                })
236            }
237            _ => self.helix_move_and_collapse(motion, times, window, cx),
238        }
239    }
240
241    pub fn helix_delete(&mut self, _: &HelixDelete, window: &mut Window, cx: &mut Context<Self>) {
242        self.store_visual_marks(window, cx);
243        self.update_editor(window, cx, |vim, editor, window, cx| {
244            // Fixup selections so they have helix's semantics.
245            // Specifically:
246            //  - Make sure that each cursor acts as a 1 character wide selection
247            editor.transact(window, cx, |editor, window, cx| {
248                editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
249                    s.move_with(|map, selection| {
250                        if selection.is_empty() && !selection.reversed {
251                            selection.end = movement::right(map, selection.end);
252                        }
253                    });
254                });
255            });
256
257            vim.copy_selections_content(editor, false, window, cx);
258            editor.insert("", window, cx);
259        });
260    }
261}
262
263#[cfg(test)]
264mod test {
265    use indoc::indoc;
266
267    use crate::{state::Mode, test::VimTestContext};
268
269    #[gpui::test]
270    async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
271        let mut cx = VimTestContext::new(cx, true).await;
272        // «
273        // ˇ
274        // »
275        cx.set_state(
276            indoc! {"
277            The quˇick brown
278            fox jumps over
279            the lazy dog."},
280            Mode::HelixNormal,
281        );
282
283        cx.simulate_keystrokes("w");
284
285        cx.assert_state(
286            indoc! {"
287            The qu«ick ˇ»brown
288            fox jumps over
289            the lazy dog."},
290            Mode::HelixNormal,
291        );
292
293        cx.simulate_keystrokes("w");
294
295        cx.assert_state(
296            indoc! {"
297            The quick «brownˇ»
298            fox jumps over
299            the lazy dog."},
300            Mode::HelixNormal,
301        );
302    }
303
304    #[gpui::test]
305    async fn test_delete(cx: &mut gpui::TestAppContext) {
306        let mut cx = VimTestContext::new(cx, true).await;
307
308        // test delete a selection
309        cx.set_state(
310            indoc! {"
311            The qu«ick ˇ»brown
312            fox jumps over
313            the lazy dog."},
314            Mode::HelixNormal,
315        );
316
317        cx.simulate_keystrokes("d");
318
319        cx.assert_state(
320            indoc! {"
321            The quˇbrown
322            fox jumps over
323            the lazy dog."},
324            Mode::HelixNormal,
325        );
326
327        // test deleting a single character
328        cx.simulate_keystrokes("d");
329
330        cx.assert_state(
331            indoc! {"
332            The quˇrown
333            fox jumps over
334            the lazy dog."},
335            Mode::HelixNormal,
336        );
337    }
338
339    #[gpui::test]
340    async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
341        let mut cx = VimTestContext::new(cx, true).await;
342
343        cx.set_state(
344            indoc! {"
345            The quick brownˇ
346            fox jumps over
347            the lazy dog."},
348            Mode::HelixNormal,
349        );
350
351        cx.simulate_keystrokes("d");
352
353        cx.assert_state(
354            indoc! {"
355            The quick brownˇfox jumps over
356            the lazy dog."},
357            Mode::HelixNormal,
358        );
359    }
360
361    #[gpui::test]
362    async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
363        let mut cx = VimTestContext::new(cx, true).await;
364
365        cx.set_state(
366            indoc! {"
367            The quick brown
368            fox jumps over
369            the lazy dog.ˇ"},
370            Mode::HelixNormal,
371        );
372
373        cx.simulate_keystrokes("d");
374
375        cx.assert_state(
376            indoc! {"
377            The quick brown
378            fox jumps over
379            the lazy dog.ˇ"},
380            Mode::HelixNormal,
381        );
382    }
383}