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