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}