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}