normal.rs

  1mod g_prefix;
  2
  3use crate::{mode::NormalState, Mode, SwitchMode, VimState};
  4use editor::{char_kind, movement, Bias};
  5use gpui::{actions, impl_actions, keymap::Binding, MutableAppContext, ViewContext};
  6use language::SelectionGoal;
  7use workspace::Workspace;
  8
  9#[derive(Clone)]
 10struct MoveToNextWordStart(pub bool);
 11
 12#[derive(Clone)]
 13struct MoveToNextWordEnd(pub bool);
 14
 15#[derive(Clone)]
 16struct MoveToPreviousWordStart(pub bool);
 17
 18impl_actions!(
 19    vim,
 20    [
 21        MoveToNextWordStart,
 22        MoveToNextWordEnd,
 23        MoveToPreviousWordStart,
 24    ]
 25);
 26
 27actions!(
 28    vim,
 29    [
 30        GPrefix,
 31        MoveLeft,
 32        MoveDown,
 33        MoveUp,
 34        MoveRight,
 35        MoveToStartOfLine,
 36        MoveToEndOfLine,
 37        MoveToEnd,
 38    ]
 39);
 40
 41pub fn init(cx: &mut MutableAppContext) {
 42    let context = Some("Editor && vim_mode == normal");
 43    cx.add_bindings(vec![
 44        Binding::new("i", SwitchMode(Mode::Insert), context),
 45        Binding::new("g", SwitchMode(Mode::Normal(NormalState::GPrefix)), context),
 46        Binding::new("h", MoveLeft, context),
 47        Binding::new("j", MoveDown, context),
 48        Binding::new("k", MoveUp, context),
 49        Binding::new("l", MoveRight, context),
 50        Binding::new("0", MoveToStartOfLine, context),
 51        Binding::new("shift-$", MoveToEndOfLine, context),
 52        Binding::new("shift-G", MoveToEnd, context),
 53        Binding::new("w", MoveToNextWordStart(false), context),
 54        Binding::new("shift-W", MoveToNextWordStart(true), context),
 55        Binding::new("e", MoveToNextWordEnd(false), context),
 56        Binding::new("shift-E", MoveToNextWordEnd(true), context),
 57        Binding::new("b", MoveToPreviousWordStart(false), context),
 58        Binding::new("shift-B", MoveToPreviousWordStart(true), context),
 59    ]);
 60    g_prefix::init(cx);
 61
 62    cx.add_action(move_left);
 63    cx.add_action(move_down);
 64    cx.add_action(move_up);
 65    cx.add_action(move_right);
 66    cx.add_action(move_to_start_of_line);
 67    cx.add_action(move_to_end_of_line);
 68    cx.add_action(move_to_end);
 69    cx.add_action(move_to_next_word_start);
 70    cx.add_action(move_to_next_word_end);
 71    cx.add_action(move_to_previous_word_start);
 72}
 73
 74fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<Workspace>) {
 75    VimState::update_global(cx, |state, cx| {
 76        state.update_active_editor(cx, |editor, cx| {
 77            editor.move_cursors(cx, |map, mut cursor, _| {
 78                *cursor.column_mut() = cursor.column().saturating_sub(1);
 79                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
 80            });
 81        });
 82    })
 83}
 84
 85fn move_down(_: &mut Workspace, _: &MoveDown, cx: &mut ViewContext<Workspace>) {
 86    VimState::update_global(cx, |state, cx| {
 87        state.update_active_editor(cx, |editor, cx| {
 88            editor.move_cursors(cx, movement::down);
 89        });
 90    });
 91}
 92
 93fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext<Workspace>) {
 94    VimState::update_global(cx, |state, cx| {
 95        state.update_active_editor(cx, |editor, cx| {
 96            editor.move_cursors(cx, movement::up);
 97        });
 98    });
 99}
100
101fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>) {
102    VimState::update_global(cx, |state, cx| {
103        state.update_active_editor(cx, |editor, cx| {
104            editor.move_cursors(cx, |map, mut cursor, _| {
105                *cursor.column_mut() += 1;
106                (map.clip_point(cursor, Bias::Right), SelectionGoal::None)
107            });
108        });
109    });
110}
111
112fn move_to_start_of_line(
113    _: &mut Workspace,
114    _: &MoveToStartOfLine,
115    cx: &mut ViewContext<Workspace>,
116) {
117    VimState::update_global(cx, |state, cx| {
118        state.update_active_editor(cx, |editor, cx| {
119            editor.move_cursors(cx, |map, cursor, _| {
120                (
121                    movement::line_beginning(map, cursor, false),
122                    SelectionGoal::None,
123                )
124            });
125        });
126    });
127}
128
129fn move_to_end_of_line(_: &mut Workspace, _: &MoveToEndOfLine, cx: &mut ViewContext<Workspace>) {
130    VimState::update_global(cx, |state, cx| {
131        state.update_active_editor(cx, |editor, cx| {
132            editor.move_cursors(cx, |map, cursor, _| {
133                (
134                    map.clip_point(movement::line_end(map, cursor, false), Bias::Left),
135                    SelectionGoal::None,
136                )
137            });
138        });
139    });
140}
141
142fn move_to_end(_: &mut Workspace, _: &MoveToEnd, cx: &mut ViewContext<Workspace>) {
143    VimState::update_global(cx, |state, cx| {
144        state.update_active_editor(cx, |editor, cx| {
145            editor.replace_selections_with(cx, |map| map.clip_point(map.max_point(), Bias::Left));
146        });
147    });
148}
149
150fn move_to_next_word_start(
151    _: &mut Workspace,
152    &MoveToNextWordStart(treat_punctuation_as_word): &MoveToNextWordStart,
153    cx: &mut ViewContext<Workspace>,
154) {
155    VimState::update_global(cx, |state, cx| {
156        state.update_active_editor(cx, |editor, cx| {
157            editor.move_cursors(cx, |map, mut cursor, _| {
158                let mut crossed_newline = false;
159                cursor = movement::find_boundary(map, cursor, |left, right| {
160                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
161                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
162                    let at_newline = right == '\n';
163
164                    let found = (left_kind != right_kind && !right.is_whitespace())
165                        || (at_newline && crossed_newline)
166                        || (at_newline && left == '\n'); // Prevents skipping repeated empty lines
167
168                    if at_newline {
169                        crossed_newline = true;
170                    }
171                    found
172                });
173                (cursor, SelectionGoal::None)
174            });
175        });
176    });
177}
178
179fn move_to_next_word_end(
180    _: &mut Workspace,
181    &MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd,
182    cx: &mut ViewContext<Workspace>,
183) {
184    VimState::update_global(cx, |state, cx| {
185        state.update_active_editor(cx, |editor, cx| {
186            editor.move_cursors(cx, |map, mut cursor, _| {
187                *cursor.column_mut() += 1;
188                cursor = movement::find_boundary(map, cursor, |left, right| {
189                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
190                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
191
192                    left_kind != right_kind && !left.is_whitespace()
193                });
194                // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
195                // we have backtraced already
196                if !map
197                    .chars_at(cursor)
198                    .skip(1)
199                    .next()
200                    .map(|c| c == '\n')
201                    .unwrap_or(true)
202                {
203                    *cursor.column_mut() = cursor.column().saturating_sub(1);
204                }
205                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
206            });
207        });
208    });
209}
210
211fn move_to_previous_word_start(
212    _: &mut Workspace,
213    &MoveToPreviousWordStart(treat_punctuation_as_word): &MoveToPreviousWordStart,
214    cx: &mut ViewContext<Workspace>,
215) {
216    VimState::update_global(cx, |state, cx| {
217        state.update_active_editor(cx, |editor, cx| {
218            editor.move_cursors(cx, |map, mut cursor, _| {
219                // This works even though find_preceding_boundary is called for every character in the line containing
220                // cursor because the newline is checked only once.
221                cursor = movement::find_preceding_boundary(map, cursor, |left, right| {
222                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
223                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
224
225                    (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
226                });
227                (cursor, SelectionGoal::None)
228            });
229        });
230    });
231}
232
233#[cfg(test)]
234mod test {
235    use indoc::indoc;
236    use util::test::marked_text;
237
238    use crate::vim_test_context::VimTestContext;
239
240    #[gpui::test]
241    async fn test_hjkl(cx: &mut gpui::TestAppContext) {
242        let mut cx = VimTestContext::new(cx, true, "Test\nTestTest\nTest").await;
243        cx.simulate_keystroke("l");
244        cx.assert_editor_state(indoc! {"
245            T|est
246            TestTest
247            Test"});
248        cx.simulate_keystroke("h");
249        cx.assert_editor_state(indoc! {"
250            |Test
251            TestTest
252            Test"});
253        cx.simulate_keystroke("j");
254        cx.assert_editor_state(indoc! {"
255            Test
256            |TestTest
257            Test"});
258        cx.simulate_keystroke("k");
259        cx.assert_editor_state(indoc! {"
260            |Test
261            TestTest
262            Test"});
263        cx.simulate_keystroke("j");
264        cx.assert_editor_state(indoc! {"
265            Test
266            |TestTest
267            Test"});
268
269        // When moving left, cursor does not wrap to the previous line
270        cx.simulate_keystroke("h");
271        cx.assert_editor_state(indoc! {"
272            Test
273            |TestTest
274            Test"});
275
276        // When moving right, cursor does not reach the line end or wrap to the next line
277        for _ in 0..9 {
278            cx.simulate_keystroke("l");
279        }
280        cx.assert_editor_state(indoc! {"
281            Test
282            TestTes|t
283            Test"});
284
285        // Goal column respects the inability to reach the end of the line
286        cx.simulate_keystroke("k");
287        cx.assert_editor_state(indoc! {"
288            Tes|t
289            TestTest
290            Test"});
291        cx.simulate_keystroke("j");
292        cx.assert_editor_state(indoc! {"
293            Test
294            TestTes|t
295            Test"});
296    }
297
298    #[gpui::test]
299    async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
300        let initial_content = indoc! {"
301            Test Test
302            
303            T"};
304        let mut cx = VimTestContext::new(cx, true, initial_content).await;
305
306        cx.simulate_keystroke("shift-$");
307        cx.assert_editor_state(indoc! {"
308            Test Tes|t
309            
310            T"});
311        cx.simulate_keystroke("0");
312        cx.assert_editor_state(indoc! {"
313            |Test Test
314            
315            T"});
316
317        cx.simulate_keystroke("j");
318        cx.simulate_keystroke("shift-$");
319        cx.assert_editor_state(indoc! {"
320            Test Test
321            |
322            T"});
323        cx.simulate_keystroke("0");
324        cx.assert_editor_state(indoc! {"
325            Test Test
326            |
327            T"});
328
329        cx.simulate_keystroke("j");
330        cx.simulate_keystroke("shift-$");
331        cx.assert_editor_state(indoc! {"
332            Test Test
333            
334            |T"});
335        cx.simulate_keystroke("0");
336        cx.assert_editor_state(indoc! {"
337            Test Test
338            
339            |T"});
340    }
341
342    #[gpui::test]
343    async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
344        let initial_content = indoc! {"
345            The quick
346            
347            brown fox jumps
348            over the lazy dog"};
349        let mut cx = VimTestContext::new(cx, true, initial_content).await;
350
351        cx.simulate_keystroke("shift-G");
352        cx.assert_editor_state(indoc! {"
353            The quick
354            
355            brown fox jumps
356            over the lazy do|g"});
357
358        // Repeat the action doesn't move
359        cx.simulate_keystroke("shift-G");
360        cx.assert_editor_state(indoc! {"
361            The quick
362            
363            brown fox jumps
364            over the lazy do|g"});
365    }
366
367    #[gpui::test]
368    async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
369        let (initial_content, cursor_offsets) = marked_text(indoc! {"
370            The |quick|-|brown
371            |
372            |
373            |fox_jumps |over
374            |th||e"});
375        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
376
377        for cursor_offset in cursor_offsets {
378            cx.simulate_keystroke("w");
379            cx.assert_newest_selection_head_offset(cursor_offset);
380        }
381
382        // Reset and test ignoring punctuation
383        cx.simulate_keystrokes(&["g", "g"]);
384        let (_, cursor_offsets) = marked_text(indoc! {"
385            The |quick-brown
386            |
387            |
388            |fox_jumps |over
389            |th||e"});
390
391        for cursor_offset in cursor_offsets {
392            cx.simulate_keystroke("shift-W");
393            cx.assert_newest_selection_head_offset(cursor_offset);
394        }
395    }
396
397    #[gpui::test]
398    async fn test_next_word_end(cx: &mut gpui::TestAppContext) {
399        let (initial_content, cursor_offsets) = marked_text(indoc! {"
400            Th|e quic|k|-brow|n
401            
402            
403            fox_jump|s ove|r
404            th|e"});
405        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
406
407        for cursor_offset in cursor_offsets {
408            cx.simulate_keystroke("e");
409            cx.assert_newest_selection_head_offset(cursor_offset);
410        }
411
412        // Reset and test ignoring punctuation
413        cx.simulate_keystrokes(&["g", "g"]);
414        let (_, cursor_offsets) = marked_text(indoc! {"
415            Th|e quick-brow|n
416            
417            
418            fox_jump|s ove|r
419            th||e"});
420        for cursor_offset in cursor_offsets {
421            cx.simulate_keystroke("shift-E");
422            cx.assert_newest_selection_head_offset(cursor_offset);
423        }
424    }
425
426    #[gpui::test]
427    async fn test_previous_word_start(cx: &mut gpui::TestAppContext) {
428        let (initial_content, cursor_offsets) = marked_text(indoc! {"
429            ||The |quick|-|brown
430            |
431            |
432            |fox_jumps |over
433            |the"});
434        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
435        cx.simulate_keystroke("shift-G");
436
437        for cursor_offset in cursor_offsets.into_iter().rev() {
438            cx.simulate_keystroke("b");
439            cx.assert_newest_selection_head_offset(cursor_offset);
440        }
441
442        // Reset and test ignoring punctuation
443        cx.simulate_keystroke("shift-G");
444        let (_, cursor_offsets) = marked_text(indoc! {"
445            ||The |quick-brown
446            |
447            |
448            |fox_jumps |over
449            |the"});
450        for cursor_offset in cursor_offsets.into_iter().rev() {
451            cx.simulate_keystroke("shift-B");
452            cx.assert_newest_selection_head_offset(cursor_offset);
453        }
454    }
455}