1use editor::{movement, scroll::Autoscroll, DisplayPoint, Editor};
2use gpui::{actions, Action};
3use language::{CharClassifier, CharKind};
4use ui::ViewContext;
5
6use crate::{motion::Motion, state::Mode, Vim};
7
8actions!(vim, [HelixNormalAfter, HelixDelete]);
9
10pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
11 Vim::action(editor, cx, Vim::helix_normal_after);
12 Vim::action(editor, cx, Vim::helix_delete);
13}
14
15impl Vim {
16 pub fn helix_normal_after(&mut self, action: &HelixNormalAfter, cx: &mut ViewContext<Self>) {
17 if self.active_operator().is_some() {
18 self.operator_stack.clear();
19 self.sync_vim_settings(cx);
20 return;
21 }
22 self.stop_recording_immediately(action.boxed_clone(), cx);
23 self.switch_mode(Mode::HelixNormal, false, cx);
24 return;
25 }
26
27 pub fn helix_normal_motion(
28 &mut self,
29 motion: Motion,
30 times: Option<usize>,
31 cx: &mut ViewContext<Self>,
32 ) {
33 self.helix_move_cursor(motion, times, cx);
34 }
35
36 fn helix_find_range_forward(
37 &mut self,
38 times: Option<usize>,
39 cx: &mut ViewContext<Self>,
40 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
41 ) {
42 self.update_editor(cx, |_, editor, cx| {
43 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
44 s.move_with(|map, selection| {
45 let times = times.unwrap_or(1);
46
47 if selection.head() == map.max_point() {
48 return;
49 }
50
51 // collapse to block cursor
52 if selection.tail() < selection.head() {
53 selection.set_tail(movement::left(map, selection.head()), selection.goal);
54 } else {
55 selection.set_tail(selection.head(), selection.goal);
56 selection.set_head(movement::right(map, selection.head()), selection.goal);
57 }
58
59 // create a classifier
60 let classifier = map
61 .buffer_snapshot
62 .char_classifier_at(selection.head().to_point(map));
63
64 let mut last_selection = selection.clone();
65 for _ in 0..times {
66 let (new_tail, new_head) =
67 movement::find_boundary_trail(map, selection.head(), |left, right| {
68 is_boundary(left, right, &classifier)
69 });
70
71 selection.set_head(new_head, selection.goal);
72 if let Some(new_tail) = new_tail {
73 selection.set_tail(new_tail, selection.goal);
74 }
75
76 if selection.head() == last_selection.head()
77 && selection.tail() == last_selection.tail()
78 {
79 break;
80 }
81 last_selection = selection.clone();
82 }
83 });
84 });
85 });
86 }
87
88 fn helix_find_range_backward(
89 &mut self,
90 times: Option<usize>,
91 cx: &mut ViewContext<Self>,
92 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
93 ) {
94 self.update_editor(cx, |_, editor, cx| {
95 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
96 s.move_with(|map, selection| {
97 let times = times.unwrap_or(1);
98
99 if selection.head() == DisplayPoint::zero() {
100 return;
101 }
102
103 // collapse to block cursor
104 if selection.tail() < selection.head() {
105 selection.set_tail(movement::left(map, selection.head()), selection.goal);
106 } else {
107 selection.set_tail(selection.head(), selection.goal);
108 selection.set_head(movement::right(map, selection.head()), selection.goal);
109 }
110
111 // flip the selection
112 selection.swap_head_tail();
113
114 // create a classifier
115 let classifier = map
116 .buffer_snapshot
117 .char_classifier_at(selection.head().to_point(map));
118
119 let mut last_selection = selection.clone();
120 for _ in 0..times {
121 let (new_tail, new_head) = movement::find_preceding_boundary_trail(
122 map,
123 selection.head(),
124 |left, right| is_boundary(left, right, &classifier),
125 );
126
127 selection.set_head(new_head, selection.goal);
128 if let Some(new_tail) = new_tail {
129 selection.set_tail(new_tail, selection.goal);
130 }
131
132 if selection.head() == last_selection.head()
133 && selection.tail() == last_selection.tail()
134 {
135 break;
136 }
137 last_selection = selection.clone();
138 }
139 });
140 })
141 });
142 }
143
144 pub fn helix_move_and_collapse(
145 &mut self,
146 motion: Motion,
147 times: Option<usize>,
148 cx: &mut ViewContext<Self>,
149 ) {
150 self.update_editor(cx, |_, editor, cx| {
151 let text_layout_details = editor.text_layout_details(cx);
152 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
153 s.move_with(|map, selection| {
154 let goal = selection.goal;
155 let cursor = if selection.is_empty() || selection.reversed {
156 selection.head()
157 } else {
158 movement::left(map, selection.head())
159 };
160
161 let (point, goal) = motion
162 .move_point(map, cursor, selection.goal, times, &text_layout_details)
163 .unwrap_or((cursor, goal));
164
165 selection.collapse_to(point, goal)
166 })
167 });
168 });
169 }
170
171 pub fn helix_move_cursor(
172 &mut self,
173 motion: Motion,
174 times: Option<usize>,
175 cx: &mut ViewContext<Self>,
176 ) {
177 match motion {
178 Motion::NextWordStart { ignore_punctuation } => {
179 self.helix_find_range_forward(times, cx, |left, right, classifier| {
180 let left_kind = classifier.kind_with(left, ignore_punctuation);
181 let right_kind = classifier.kind_with(right, ignore_punctuation);
182 let at_newline = right == '\n';
183
184 let found =
185 left_kind != right_kind && right_kind != CharKind::Whitespace || at_newline;
186
187 found
188 })
189 }
190 Motion::NextWordEnd { ignore_punctuation } => {
191 self.helix_find_range_forward(times, cx, |left, right, classifier| {
192 let left_kind = classifier.kind_with(left, ignore_punctuation);
193 let right_kind = classifier.kind_with(right, ignore_punctuation);
194 let at_newline = right == '\n';
195
196 let found = left_kind != right_kind
197 && (left_kind != CharKind::Whitespace || at_newline);
198
199 found
200 })
201 }
202 Motion::PreviousWordStart { ignore_punctuation } => {
203 self.helix_find_range_backward(times, cx, |left, right, classifier| {
204 let left_kind = classifier.kind_with(left, ignore_punctuation);
205 let right_kind = classifier.kind_with(right, ignore_punctuation);
206 let at_newline = right == '\n';
207
208 let found = left_kind != right_kind
209 && (left_kind != CharKind::Whitespace || at_newline);
210
211 found
212 })
213 }
214 Motion::PreviousWordEnd { ignore_punctuation } => {
215 self.helix_find_range_backward(times, cx, |left, right, classifier| {
216 let left_kind = classifier.kind_with(left, ignore_punctuation);
217 let right_kind = classifier.kind_with(right, ignore_punctuation);
218 let at_newline = right == '\n';
219
220 let found = left_kind != right_kind
221 && right_kind != CharKind::Whitespace
222 && !at_newline;
223
224 found
225 })
226 }
227 _ => self.helix_move_and_collapse(motion, times, cx),
228 }
229 }
230
231 pub fn helix_delete(&mut self, _: &HelixDelete, cx: &mut ViewContext<Self>) {
232 self.store_visual_marks(cx);
233 self.update_editor(cx, |vim, editor, cx| {
234 // Fixup selections so they have helix's semantics.
235 // Specifically:
236 // - Make sure that each cursor acts as a 1 character wide selection
237 editor.transact(cx, |editor, cx| {
238 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
239 s.move_with(|map, selection| {
240 if selection.is_empty() && !selection.reversed {
241 selection.end = movement::right(map, selection.end);
242 }
243 });
244 });
245 });
246
247 vim.copy_selections_content(editor, false, cx);
248 editor.insert("", cx);
249 });
250 }
251}
252
253#[cfg(test)]
254mod test {
255 use indoc::indoc;
256
257 use crate::{state::Mode, test::VimTestContext};
258
259 #[gpui::test]
260 async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
261 let mut cx = VimTestContext::new(cx, true).await;
262 // «
263 // ˇ
264 // »
265 cx.set_state(
266 indoc! {"
267 The quˇick brown
268 fox jumps over
269 the lazy dog."},
270 Mode::HelixNormal,
271 );
272
273 cx.simulate_keystrokes("w");
274
275 cx.assert_state(
276 indoc! {"
277 The qu«ick ˇ»brown
278 fox jumps over
279 the lazy dog."},
280 Mode::HelixNormal,
281 );
282
283 cx.simulate_keystrokes("w");
284
285 cx.assert_state(
286 indoc! {"
287 The quick «brownˇ»
288 fox jumps over
289 the lazy dog."},
290 Mode::HelixNormal,
291 );
292 }
293
294 #[gpui::test]
295 async fn test_delete(cx: &mut gpui::TestAppContext) {
296 let mut cx = VimTestContext::new(cx, true).await;
297
298 // test delete a selection
299 cx.set_state(
300 indoc! {"
301 The qu«ick ˇ»brown
302 fox jumps over
303 the lazy dog."},
304 Mode::HelixNormal,
305 );
306
307 cx.simulate_keystrokes("d");
308
309 cx.assert_state(
310 indoc! {"
311 The quˇbrown
312 fox jumps over
313 the lazy dog."},
314 Mode::HelixNormal,
315 );
316
317 // test deleting a single character
318 cx.simulate_keystrokes("d");
319
320 cx.assert_state(
321 indoc! {"
322 The quˇrown
323 fox jumps over
324 the lazy dog."},
325 Mode::HelixNormal,
326 );
327 }
328
329 #[gpui::test]
330 async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
331 let mut cx = VimTestContext::new(cx, true).await;
332
333 cx.set_state(
334 indoc! {"
335 The quick brownˇ
336 fox jumps over
337 the lazy dog."},
338 Mode::HelixNormal,
339 );
340
341 cx.simulate_keystrokes("d");
342
343 cx.assert_state(
344 indoc! {"
345 The quick brownˇfox jumps over
346 the lazy dog."},
347 Mode::HelixNormal,
348 );
349 }
350
351 #[gpui::test]
352 async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
353 let mut cx = VimTestContext::new(cx, true).await;
354
355 cx.set_state(
356 indoc! {"
357 The quick brown
358 fox jumps over
359 the lazy dog.ˇ"},
360 Mode::HelixNormal,
361 );
362
363 cx.simulate_keystrokes("d");
364
365 cx.assert_state(
366 indoc! {"
367 The quick brown
368 fox jumps over
369 the lazy dog.ˇ"},
370 Mode::HelixNormal,
371 );
372 }
373}