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