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}