helix.rs

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