helix.rs

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