normal.rs

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