1mod boundary;
2mod duplicate;
3mod object;
4mod paste;
5mod select;
6
7use editor::display_map::DisplaySnapshot;
8use editor::{
9 DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, SelectionEffects, ToOffset,
10 ToPoint, movement,
11};
12use gpui::actions;
13use gpui::{Context, Window};
14use language::{CharClassifier, CharKind, Point};
15use search::{BufferSearchBar, SearchOptions};
16use settings::Settings;
17use text::{Bias, SelectionGoal};
18use workspace::searchable;
19use workspace::searchable::FilteredSearchRange;
20
21use crate::motion::{self, MotionKind};
22use crate::state::SearchState;
23use crate::{
24 Vim,
25 motion::{Motion, right},
26 state::Mode,
27};
28
29actions!(
30 vim,
31 [
32 /// Yanks the current selection or character if no selection.
33 HelixYank,
34 /// Inserts at the beginning of the selection.
35 HelixInsert,
36 /// Appends at the end of the selection.
37 HelixAppend,
38 /// Goes to the location of the last modification.
39 HelixGotoLastModification,
40 /// Select entire line or multiple lines, extending downwards.
41 HelixSelectLine,
42 /// Select all matches of a given pattern within the current selection.
43 HelixSelectRegex,
44 /// Removes all but the one selection that was created last.
45 /// `Newest` can eventually be `Primary`.
46 HelixKeepNewestSelection,
47 /// Copies all selections below.
48 HelixDuplicateBelow,
49 /// Copies all selections above.
50 HelixDuplicateAbove,
51 /// Delete the selection and enter edit mode.
52 HelixSubstitute,
53 /// Delete the selection and enter edit mode, without yanking the selection.
54 HelixSubstituteNoYank,
55 ]
56);
57
58pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
59 Vim::action(editor, cx, Vim::helix_select_lines);
60 Vim::action(editor, cx, Vim::helix_insert);
61 Vim::action(editor, cx, Vim::helix_append);
62 Vim::action(editor, cx, Vim::helix_yank);
63 Vim::action(editor, cx, Vim::helix_goto_last_modification);
64 Vim::action(editor, cx, Vim::helix_paste);
65 Vim::action(editor, cx, Vim::helix_select_regex);
66 Vim::action(editor, cx, Vim::helix_keep_newest_selection);
67 Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| {
68 let times = Vim::take_count(cx);
69 vim.helix_duplicate_selections_below(times, window, cx);
70 });
71 Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| {
72 let times = Vim::take_count(cx);
73 vim.helix_duplicate_selections_above(times, window, cx);
74 });
75 Vim::action(editor, cx, Vim::helix_substitute);
76 Vim::action(editor, cx, Vim::helix_substitute_no_yank);
77}
78
79impl Vim {
80 pub fn helix_normal_motion(
81 &mut self,
82 motion: Motion,
83 times: Option<usize>,
84 window: &mut Window,
85 cx: &mut Context<Self>,
86 ) {
87 self.helix_move_cursor(motion, times, window, cx);
88 }
89
90 pub fn helix_select_motion(
91 &mut self,
92 motion: Motion,
93 times: Option<usize>,
94 window: &mut Window,
95 cx: &mut Context<Self>,
96 ) {
97 self.update_editor(cx, |_, editor, cx| {
98 let text_layout_details = editor.text_layout_details(window);
99 editor.change_selections(Default::default(), window, cx, |s| {
100 s.move_with(|map, selection| {
101 let current_head = selection.head();
102
103 let Some((new_head, goal)) = motion.move_point(
104 map,
105 current_head,
106 selection.goal,
107 times,
108 &text_layout_details,
109 ) else {
110 return;
111 };
112
113 selection.set_head(new_head, goal);
114 })
115 });
116 });
117 }
118
119 /// Updates all selections based on where the cursors are.
120 fn helix_new_selections(
121 &mut self,
122 window: &mut Window,
123 cx: &mut Context<Self>,
124 mut change: impl FnMut(
125 // the start of the cursor
126 DisplayPoint,
127 &DisplaySnapshot,
128 ) -> Option<(DisplayPoint, DisplayPoint)>,
129 ) {
130 self.update_editor(cx, |_, editor, cx| {
131 editor.change_selections(Default::default(), window, cx, |s| {
132 s.move_with(|map, selection| {
133 let cursor_start = if selection.reversed || selection.is_empty() {
134 selection.head()
135 } else {
136 movement::left(map, selection.head())
137 };
138 let Some((head, tail)) = change(cursor_start, map) else {
139 return;
140 };
141
142 selection.set_head_tail(head, tail, SelectionGoal::None);
143 });
144 });
145 });
146 }
147
148 fn helix_find_range_forward(
149 &mut self,
150 times: Option<usize>,
151 window: &mut Window,
152 cx: &mut Context<Self>,
153 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
154 ) {
155 let times = times.unwrap_or(1);
156 self.helix_new_selections(window, cx, |cursor, map| {
157 let mut head = movement::right(map, cursor);
158 let mut tail = cursor;
159 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
160 if head == map.max_point() {
161 return None;
162 }
163 for _ in 0..times {
164 let (maybe_next_tail, next_head) =
165 movement::find_boundary_trail(map, head, |left, right| {
166 is_boundary(left, right, &classifier)
167 });
168
169 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
170 break;
171 }
172
173 head = next_head;
174 if let Some(next_tail) = maybe_next_tail {
175 tail = next_tail;
176 }
177 }
178 Some((head, tail))
179 });
180 }
181
182 fn helix_find_range_backward(
183 &mut self,
184 times: Option<usize>,
185 window: &mut Window,
186 cx: &mut Context<Self>,
187 mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
188 ) {
189 let times = times.unwrap_or(1);
190 self.helix_new_selections(window, cx, |cursor, map| {
191 let mut head = cursor;
192 // The original cursor was one character wide,
193 // but the search starts from the left side of it,
194 // so to include that space the selection must end one character to the right.
195 let mut tail = movement::right(map, cursor);
196 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
197 if head == DisplayPoint::zero() {
198 return None;
199 }
200 for _ in 0..times {
201 let (maybe_next_tail, next_head) =
202 movement::find_preceding_boundary_trail(map, head, |left, right| {
203 is_boundary(left, right, &classifier)
204 });
205
206 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
207 break;
208 }
209
210 head = next_head;
211 if let Some(next_tail) = maybe_next_tail {
212 tail = next_tail;
213 }
214 }
215 Some((head, tail))
216 });
217 }
218
219 pub fn helix_move_and_collapse(
220 &mut self,
221 motion: Motion,
222 times: Option<usize>,
223 window: &mut Window,
224 cx: &mut Context<Self>,
225 ) {
226 self.update_editor(cx, |_, editor, cx| {
227 let text_layout_details = editor.text_layout_details(window);
228 editor.change_selections(Default::default(), window, cx, |s| {
229 s.move_with(|map, selection| {
230 let goal = selection.goal;
231 let cursor = if selection.is_empty() || selection.reversed {
232 selection.head()
233 } else {
234 movement::left(map, selection.head())
235 };
236
237 let (point, goal) = motion
238 .move_point(map, cursor, selection.goal, times, &text_layout_details)
239 .unwrap_or((cursor, goal));
240
241 selection.collapse_to(point, goal)
242 })
243 });
244 });
245 }
246
247 pub fn helix_move_cursor(
248 &mut self,
249 motion: Motion,
250 times: Option<usize>,
251 window: &mut Window,
252 cx: &mut Context<Self>,
253 ) {
254 match motion {
255 Motion::NextWordStart { ignore_punctuation } => {
256 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
257 let left_kind = classifier.kind_with(left, ignore_punctuation);
258 let right_kind = classifier.kind_with(right, ignore_punctuation);
259 let at_newline = (left == '\n') ^ (right == '\n');
260
261 (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
262 })
263 }
264 Motion::NextWordEnd { ignore_punctuation } => {
265 self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
266 let left_kind = classifier.kind_with(left, ignore_punctuation);
267 let right_kind = classifier.kind_with(right, ignore_punctuation);
268 let at_newline = (left == '\n') ^ (right == '\n');
269
270 (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
271 })
272 }
273 Motion::PreviousWordStart { ignore_punctuation } => {
274 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
275 let left_kind = classifier.kind_with(left, ignore_punctuation);
276 let right_kind = classifier.kind_with(right, ignore_punctuation);
277 let at_newline = (left == '\n') ^ (right == '\n');
278
279 (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
280 })
281 }
282 Motion::PreviousWordEnd { ignore_punctuation } => {
283 self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
284 let left_kind = classifier.kind_with(left, ignore_punctuation);
285 let right_kind = classifier.kind_with(right, ignore_punctuation);
286 let at_newline = (left == '\n') ^ (right == '\n');
287
288 (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
289 })
290 }
291 Motion::FindForward {
292 before,
293 char,
294 mode,
295 smartcase,
296 } => {
297 self.helix_new_selections(window, cx, |cursor, map| {
298 let start = cursor;
299 let mut last_boundary = start;
300 for _ in 0..times.unwrap_or(1) {
301 last_boundary = movement::find_boundary(
302 map,
303 movement::right(map, last_boundary),
304 mode,
305 |left, right| {
306 let current_char = if before { right } else { left };
307 motion::is_character_match(char, current_char, smartcase)
308 },
309 );
310 }
311 Some((last_boundary, start))
312 });
313 }
314 Motion::FindBackward {
315 after,
316 char,
317 mode,
318 smartcase,
319 } => {
320 self.helix_new_selections(window, cx, |cursor, map| {
321 let start = cursor;
322 let mut last_boundary = start;
323 for _ in 0..times.unwrap_or(1) {
324 last_boundary = movement::find_preceding_boundary_display_point(
325 map,
326 last_boundary,
327 mode,
328 |left, right| {
329 let current_char = if after { left } else { right };
330 motion::is_character_match(char, current_char, smartcase)
331 },
332 );
333 }
334 // The original cursor was one character wide,
335 // but the search started from the left side of it,
336 // so to include that space the selection must end one character to the right.
337 Some((last_boundary, movement::right(map, start)))
338 });
339 }
340 _ => self.helix_move_and_collapse(motion, times, window, cx),
341 }
342 }
343
344 pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
345 self.update_editor(cx, |vim, editor, cx| {
346 let has_selection = editor
347 .selections
348 .all_adjusted(cx)
349 .iter()
350 .any(|selection| !selection.is_empty());
351
352 if !has_selection {
353 // If no selection, expand to current character (like 'v' does)
354 editor.change_selections(Default::default(), window, cx, |s| {
355 s.move_with(|map, selection| {
356 let head = selection.head();
357 let new_head = movement::saturating_right(map, head);
358 selection.set_tail(head, SelectionGoal::None);
359 selection.set_head(new_head, SelectionGoal::None);
360 });
361 });
362 vim.yank_selections_content(
363 editor,
364 crate::motion::MotionKind::Exclusive,
365 window,
366 cx,
367 );
368 editor.change_selections(Default::default(), window, cx, |s| {
369 s.move_with(|_map, selection| {
370 selection.collapse_to(selection.start, SelectionGoal::None);
371 });
372 });
373 } else {
374 // Yank the selection(s)
375 vim.yank_selections_content(
376 editor,
377 crate::motion::MotionKind::Exclusive,
378 window,
379 cx,
380 );
381 }
382 });
383
384 // Drop back to normal mode after yanking
385 self.switch_mode(Mode::HelixNormal, true, window, cx);
386 }
387
388 fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
389 self.start_recording(cx);
390 self.update_editor(cx, |_, editor, cx| {
391 editor.change_selections(Default::default(), window, cx, |s| {
392 s.move_with(|_map, selection| {
393 // In helix normal mode, move cursor to start of selection and collapse
394 if !selection.is_empty() {
395 selection.collapse_to(selection.start, SelectionGoal::None);
396 }
397 });
398 });
399 });
400 self.switch_mode(Mode::Insert, false, window, cx);
401 }
402
403 fn helix_select_regex(
404 &mut self,
405 _: &HelixSelectRegex,
406 window: &mut Window,
407 cx: &mut Context<Self>,
408 ) {
409 Vim::take_forced_motion(cx);
410 let Some(pane) = self.pane(window, cx) else {
411 return;
412 };
413 let prior_selections = self.editor_selections(window, cx);
414 pane.update(cx, |pane, cx| {
415 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
416 search_bar.update(cx, |search_bar, cx| {
417 if !search_bar.show(window, cx) {
418 return;
419 }
420
421 search_bar.select_query(window, cx);
422 cx.focus_self(window);
423
424 search_bar.set_replacement(None, cx);
425 let mut options = SearchOptions::NONE;
426 options |= SearchOptions::REGEX;
427 if EditorSettings::get_global(cx).search.case_sensitive {
428 options |= SearchOptions::CASE_SENSITIVE;
429 }
430 search_bar.set_search_options(options, cx);
431 if let Some(search) = search_bar.set_search_within_selection(
432 Some(FilteredSearchRange::Selection),
433 window,
434 cx,
435 ) {
436 cx.spawn_in(window, async move |search_bar, cx| {
437 if search.await.is_ok() {
438 search_bar.update_in(cx, |search_bar, window, cx| {
439 search_bar.activate_current_match(window, cx)
440 })
441 } else {
442 Ok(())
443 }
444 })
445 .detach_and_log_err(cx);
446 }
447 self.search = SearchState {
448 direction: searchable::Direction::Next,
449 count: 1,
450 prior_selections,
451 prior_operator: self.operator_stack.last().cloned(),
452 prior_mode: self.mode,
453 helix_select: true,
454 }
455 });
456 }
457 });
458 self.start_recording(cx);
459 }
460
461 fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
462 self.start_recording(cx);
463 self.switch_mode(Mode::Insert, false, window, cx);
464 self.update_editor(cx, |_, editor, cx| {
465 editor.change_selections(Default::default(), window, cx, |s| {
466 s.move_with(|map, selection| {
467 let point = if selection.is_empty() {
468 right(map, selection.head(), 1)
469 } else {
470 selection.end
471 };
472 selection.collapse_to(point, SelectionGoal::None);
473 });
474 });
475 });
476 }
477
478 pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
479 self.update_editor(cx, |_, editor, cx| {
480 editor.transact(window, cx, |editor, window, cx| {
481 let (map, selections) = editor.selections.all_display(cx);
482
483 // Store selection info for positioning after edit
484 let selection_info: Vec<_> = selections
485 .iter()
486 .map(|selection| {
487 let range = selection.range();
488 let start_offset = range.start.to_offset(&map, Bias::Left);
489 let end_offset = range.end.to_offset(&map, Bias::Left);
490 let was_empty = range.is_empty();
491 let was_reversed = selection.reversed;
492 (
493 map.buffer_snapshot().anchor_before(start_offset),
494 end_offset - start_offset,
495 was_empty,
496 was_reversed,
497 )
498 })
499 .collect();
500
501 let mut edits = Vec::new();
502 for selection in &selections {
503 let mut range = selection.range();
504
505 // For empty selections, extend to replace one character
506 if range.is_empty() {
507 range.end = movement::saturating_right(&map, range.start);
508 }
509
510 let byte_range = range.start.to_offset(&map, Bias::Left)
511 ..range.end.to_offset(&map, Bias::Left);
512
513 if !byte_range.is_empty() {
514 let replacement_text = text.repeat(byte_range.len());
515 edits.push((byte_range, replacement_text));
516 }
517 }
518
519 editor.edit(edits, cx);
520
521 // Restore selections based on original info
522 let snapshot = editor.buffer().read(cx).snapshot(cx);
523 let ranges: Vec<_> = selection_info
524 .into_iter()
525 .map(|(start_anchor, original_len, was_empty, was_reversed)| {
526 let start_point = start_anchor.to_point(&snapshot);
527 if was_empty {
528 // For cursor-only, collapse to start
529 start_point..start_point
530 } else {
531 // For selections, span the replaced text
532 let replacement_len = text.len() * original_len;
533 let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
534 let end_point = snapshot.offset_to_point(end_offset);
535 if was_reversed {
536 end_point..start_point
537 } else {
538 start_point..end_point
539 }
540 }
541 })
542 .collect();
543
544 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
545 s.select_ranges(ranges);
546 });
547 });
548 });
549 self.switch_mode(Mode::HelixNormal, true, window, cx);
550 }
551
552 pub fn helix_goto_last_modification(
553 &mut self,
554 _: &HelixGotoLastModification,
555 window: &mut Window,
556 cx: &mut Context<Self>,
557 ) {
558 self.jump(".".into(), false, false, window, cx);
559 }
560
561 pub fn helix_select_lines(
562 &mut self,
563 _: &HelixSelectLine,
564 window: &mut Window,
565 cx: &mut Context<Self>,
566 ) {
567 let count = Vim::take_count(cx).unwrap_or(1);
568 self.update_editor(cx, |_, editor, cx| {
569 editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
570 let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
571 let mut selections = editor.selections.all::<Point>(cx);
572 let max_point = display_map.buffer_snapshot().max_point();
573 let buffer_snapshot = &display_map.buffer_snapshot();
574
575 for selection in &mut selections {
576 // Start always goes to column 0 of the first selected line
577 let start_row = selection.start.row;
578 let current_end_row = selection.end.row;
579
580 // Check if cursor is on empty line by checking first character
581 let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
582 let first_char = buffer_snapshot.chars_at(line_start_offset).next();
583 let extra_line = if first_char == Some('\n') { 1 } else { 0 };
584
585 let end_row = current_end_row + count as u32 + extra_line;
586
587 selection.start = Point::new(start_row, 0);
588 selection.end = if end_row > max_point.row {
589 max_point
590 } else {
591 Point::new(end_row, 0)
592 };
593 selection.reversed = false;
594 }
595
596 editor.change_selections(Default::default(), window, cx, |s| {
597 s.select(selections);
598 });
599 });
600 }
601
602 fn helix_keep_newest_selection(
603 &mut self,
604 _: &HelixKeepNewestSelection,
605 window: &mut Window,
606 cx: &mut Context<Self>,
607 ) {
608 self.update_editor(cx, |_, editor, cx| {
609 let newest = editor.selections.newest::<usize>(cx);
610 editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest]));
611 });
612 }
613
614 fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context<Self>) {
615 self.update_editor(cx, |vim, editor, cx| {
616 editor.set_clip_at_line_ends(false, cx);
617 editor.transact(window, cx, |editor, window, cx| {
618 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
619 s.move_with(|map, selection| {
620 if selection.start == selection.end {
621 selection.end = movement::right(map, selection.end);
622 }
623
624 // If the selection starts and ends on a newline, we exclude the last one.
625 if !selection.is_empty()
626 && selection.start.column() == 0
627 && selection.end.column() == 0
628 {
629 selection.end = movement::left(map, selection.end);
630 }
631 })
632 });
633 if yank {
634 vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
635 }
636 let selections = editor.selections.all::<Point>(cx).into_iter();
637 let edits = selections.map(|selection| (selection.start..selection.end, ""));
638 editor.edit(edits, cx);
639 });
640 });
641 self.switch_mode(Mode::Insert, true, window, cx);
642 }
643
644 fn helix_substitute(
645 &mut self,
646 _: &HelixSubstitute,
647 window: &mut Window,
648 cx: &mut Context<Self>,
649 ) {
650 self.do_helix_substitute(true, window, cx);
651 }
652
653 fn helix_substitute_no_yank(
654 &mut self,
655 _: &HelixSubstituteNoYank,
656 window: &mut Window,
657 cx: &mut Context<Self>,
658 ) {
659 self.do_helix_substitute(false, window, cx);
660 }
661}
662
663#[cfg(test)]
664mod test {
665 use indoc::indoc;
666
667 use crate::{state::Mode, test::VimTestContext};
668
669 #[gpui::test]
670 async fn test_word_motions(cx: &mut gpui::TestAppContext) {
671 let mut cx = VimTestContext::new(cx, true).await;
672 cx.enable_helix();
673 // «
674 // ˇ
675 // »
676 cx.set_state(
677 indoc! {"
678 Th«e quiˇ»ck brown
679 fox jumps over
680 the lazy dog."},
681 Mode::HelixNormal,
682 );
683
684 cx.simulate_keystrokes("w");
685
686 cx.assert_state(
687 indoc! {"
688 The qu«ick ˇ»brown
689 fox jumps over
690 the lazy dog."},
691 Mode::HelixNormal,
692 );
693
694 cx.simulate_keystrokes("w");
695
696 cx.assert_state(
697 indoc! {"
698 The quick «brownˇ»
699 fox jumps over
700 the lazy dog."},
701 Mode::HelixNormal,
702 );
703
704 cx.simulate_keystrokes("2 b");
705
706 cx.assert_state(
707 indoc! {"
708 The «ˇquick »brown
709 fox jumps over
710 the lazy dog."},
711 Mode::HelixNormal,
712 );
713
714 cx.simulate_keystrokes("down e up");
715
716 cx.assert_state(
717 indoc! {"
718 The quicˇk brown
719 fox jumps over
720 the lazy dog."},
721 Mode::HelixNormal,
722 );
723
724 cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
725
726 cx.simulate_keystroke("b");
727
728 cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
729 }
730
731 #[gpui::test]
732 async fn test_delete(cx: &mut gpui::TestAppContext) {
733 let mut cx = VimTestContext::new(cx, true).await;
734 cx.enable_helix();
735
736 // test delete a selection
737 cx.set_state(
738 indoc! {"
739 The qu«ick ˇ»brown
740 fox jumps over
741 the lazy dog."},
742 Mode::HelixNormal,
743 );
744
745 cx.simulate_keystrokes("d");
746
747 cx.assert_state(
748 indoc! {"
749 The quˇbrown
750 fox jumps over
751 the lazy dog."},
752 Mode::HelixNormal,
753 );
754
755 // test deleting a single character
756 cx.simulate_keystrokes("d");
757
758 cx.assert_state(
759 indoc! {"
760 The quˇrown
761 fox jumps over
762 the lazy dog."},
763 Mode::HelixNormal,
764 );
765 }
766
767 #[gpui::test]
768 async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
769 let mut cx = VimTestContext::new(cx, true).await;
770
771 cx.set_state(
772 indoc! {"
773 The quick brownˇ
774 fox jumps over
775 the lazy dog."},
776 Mode::HelixNormal,
777 );
778
779 cx.simulate_keystrokes("d");
780
781 cx.assert_state(
782 indoc! {"
783 The quick brownˇfox jumps over
784 the lazy dog."},
785 Mode::HelixNormal,
786 );
787 }
788
789 // #[gpui::test]
790 // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
791 // let mut cx = VimTestContext::new(cx, true).await;
792
793 // cx.set_state(
794 // indoc! {"
795 // The quick brown
796 // fox jumps over
797 // the lazy dog.ˇ"},
798 // Mode::HelixNormal,
799 // );
800
801 // cx.simulate_keystrokes("d");
802
803 // cx.assert_state(
804 // indoc! {"
805 // The quick brown
806 // fox jumps over
807 // the lazy dog.ˇ"},
808 // Mode::HelixNormal,
809 // );
810 // }
811
812 #[gpui::test]
813 async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
814 let mut cx = VimTestContext::new(cx, true).await;
815 cx.enable_helix();
816
817 cx.set_state(
818 indoc! {"
819 The quˇick brown
820 fox jumps over
821 the lazy dog."},
822 Mode::HelixNormal,
823 );
824
825 cx.simulate_keystrokes("f z");
826
827 cx.assert_state(
828 indoc! {"
829 The qu«ick brown
830 fox jumps over
831 the lazˇ»y dog."},
832 Mode::HelixNormal,
833 );
834
835 cx.simulate_keystrokes("F e F e");
836
837 cx.assert_state(
838 indoc! {"
839 The quick brown
840 fox jumps ov«ˇer
841 the» lazy dog."},
842 Mode::HelixNormal,
843 );
844
845 cx.simulate_keystrokes("e 2 F e");
846
847 cx.assert_state(
848 indoc! {"
849 Th«ˇe quick brown
850 fox jumps over»
851 the lazy dog."},
852 Mode::HelixNormal,
853 );
854
855 cx.simulate_keystrokes("t r t r");
856
857 cx.assert_state(
858 indoc! {"
859 The quick «brown
860 fox jumps oveˇ»r
861 the lazy dog."},
862 Mode::HelixNormal,
863 );
864 }
865
866 #[gpui::test]
867 async fn test_newline_char(cx: &mut gpui::TestAppContext) {
868 let mut cx = VimTestContext::new(cx, true).await;
869 cx.enable_helix();
870
871 cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
872
873 cx.simulate_keystroke("w");
874
875 cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
876
877 cx.set_state("aa«\nˇ»", Mode::HelixNormal);
878
879 cx.simulate_keystroke("b");
880
881 cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
882 }
883
884 #[gpui::test]
885 async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
886 let mut cx = VimTestContext::new(cx, true).await;
887 cx.enable_helix();
888 cx.set_state(
889 indoc! {"
890 «The ˇ»quick brown
891 fox jumps over
892 the lazy dog."},
893 Mode::HelixNormal,
894 );
895
896 cx.simulate_keystrokes("i");
897
898 cx.assert_state(
899 indoc! {"
900 ˇThe quick brown
901 fox jumps over
902 the lazy dog."},
903 Mode::Insert,
904 );
905 }
906
907 #[gpui::test]
908 async fn test_append(cx: &mut gpui::TestAppContext) {
909 let mut cx = VimTestContext::new(cx, true).await;
910 cx.enable_helix();
911 // test from the end of the selection
912 cx.set_state(
913 indoc! {"
914 «Theˇ» quick brown
915 fox jumps over
916 the lazy dog."},
917 Mode::HelixNormal,
918 );
919
920 cx.simulate_keystrokes("a");
921
922 cx.assert_state(
923 indoc! {"
924 Theˇ quick brown
925 fox jumps over
926 the lazy dog."},
927 Mode::Insert,
928 );
929
930 // test from the beginning of the selection
931 cx.set_state(
932 indoc! {"
933 «ˇThe» quick brown
934 fox jumps over
935 the lazy dog."},
936 Mode::HelixNormal,
937 );
938
939 cx.simulate_keystrokes("a");
940
941 cx.assert_state(
942 indoc! {"
943 Theˇ quick brown
944 fox jumps over
945 the lazy dog."},
946 Mode::Insert,
947 );
948 }
949
950 #[gpui::test]
951 async fn test_replace(cx: &mut gpui::TestAppContext) {
952 let mut cx = VimTestContext::new(cx, true).await;
953 cx.enable_helix();
954
955 // No selection (single character)
956 cx.set_state("ˇaa", Mode::HelixNormal);
957
958 cx.simulate_keystrokes("r x");
959
960 cx.assert_state("ˇxa", Mode::HelixNormal);
961
962 // Cursor at the beginning
963 cx.set_state("«ˇaa»", Mode::HelixNormal);
964
965 cx.simulate_keystrokes("r x");
966
967 cx.assert_state("«ˇxx»", Mode::HelixNormal);
968
969 // Cursor at the end
970 cx.set_state("«aaˇ»", Mode::HelixNormal);
971
972 cx.simulate_keystrokes("r x");
973
974 cx.assert_state("«xxˇ»", Mode::HelixNormal);
975 }
976
977 #[gpui::test]
978 async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
979 let mut cx = VimTestContext::new(cx, true).await;
980 cx.enable_helix();
981
982 // Test yanking current character with no selection
983 cx.set_state("hello ˇworld", Mode::HelixNormal);
984 cx.simulate_keystrokes("y");
985
986 // Test cursor remains at the same position after yanking single character
987 cx.assert_state("hello ˇworld", Mode::HelixNormal);
988 cx.shared_clipboard().assert_eq("w");
989
990 // Move cursor and yank another character
991 cx.simulate_keystrokes("l");
992 cx.simulate_keystrokes("y");
993 cx.shared_clipboard().assert_eq("o");
994
995 // Test yanking with existing selection
996 cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
997 cx.simulate_keystrokes("y");
998 cx.shared_clipboard().assert_eq("worl");
999 cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
1000
1001 // Test yanking in select mode character by character
1002 cx.set_state("hello ˇworld", Mode::HelixNormal);
1003 cx.simulate_keystroke("v");
1004 cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
1005 cx.simulate_keystroke("y");
1006 cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
1007 cx.shared_clipboard().assert_eq("w");
1008 }
1009
1010 #[gpui::test]
1011 async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
1012 let mut cx = VimTestContext::new(cx, true).await;
1013 cx.enable_helix();
1014
1015 // First copy some text to clipboard
1016 cx.set_state("«hello worldˇ»", Mode::HelixNormal);
1017 cx.simulate_keystrokes("y");
1018
1019 // Test paste with shift-r on single cursor
1020 cx.set_state("foo ˇbar", Mode::HelixNormal);
1021 cx.simulate_keystrokes("shift-r");
1022
1023 cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
1024
1025 // Test paste with shift-r on selection
1026 cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
1027 cx.simulate_keystrokes("shift-r");
1028
1029 cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
1030 }
1031
1032 #[gpui::test]
1033 async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
1034 let mut cx = VimTestContext::new(cx, true).await;
1035
1036 assert_eq!(cx.mode(), Mode::Normal);
1037 cx.enable_helix();
1038
1039 cx.simulate_keystrokes("v");
1040 assert_eq!(cx.mode(), Mode::HelixSelect);
1041 cx.simulate_keystrokes("escape");
1042 assert_eq!(cx.mode(), Mode::HelixNormal);
1043 }
1044
1045 #[gpui::test]
1046 async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
1047 let mut cx = VimTestContext::new(cx, true).await;
1048 cx.enable_helix();
1049
1050 // Make a modification at a specific location
1051 cx.set_state("ˇhello", Mode::HelixNormal);
1052 assert_eq!(cx.mode(), Mode::HelixNormal);
1053 cx.simulate_keystrokes("i");
1054 assert_eq!(cx.mode(), Mode::Insert);
1055 cx.simulate_keystrokes("escape");
1056 assert_eq!(cx.mode(), Mode::HelixNormal);
1057 }
1058
1059 #[gpui::test]
1060 async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
1061 let mut cx = VimTestContext::new(cx, true).await;
1062 cx.enable_helix();
1063
1064 // Make a modification at a specific location
1065 cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
1066 cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
1067 cx.simulate_keystrokes("i");
1068 cx.simulate_keystrokes("escape");
1069 cx.simulate_keystrokes("i");
1070 cx.simulate_keystrokes("m o d i f i e d space");
1071 cx.simulate_keystrokes("escape");
1072
1073 // TODO: this fails, because state is no longer helix
1074 cx.assert_state(
1075 "line one\nline modified ˇtwo\nline three",
1076 Mode::HelixNormal,
1077 );
1078
1079 // Move cursor away from the modification
1080 cx.simulate_keystrokes("up");
1081
1082 // Use "g ." to go back to last modification
1083 cx.simulate_keystrokes("g .");
1084
1085 // Verify we're back at the modification location and still in HelixNormal mode
1086 cx.assert_state(
1087 "line one\nline modifiedˇ two\nline three",
1088 Mode::HelixNormal,
1089 );
1090 }
1091
1092 #[gpui::test]
1093 async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
1094 let mut cx = VimTestContext::new(cx, true).await;
1095 cx.set_state(
1096 "line one\nline ˇtwo\nline three\nline four",
1097 Mode::HelixNormal,
1098 );
1099 cx.simulate_keystrokes("2 x");
1100 cx.assert_state(
1101 "line one\n«line two\nline three\nˇ»line four",
1102 Mode::HelixNormal,
1103 );
1104
1105 // Test extending existing line selection
1106 cx.set_state(
1107 indoc! {"
1108 li«ˇne one
1109 li»ne two
1110 line three
1111 line four"},
1112 Mode::HelixNormal,
1113 );
1114 cx.simulate_keystrokes("x");
1115 cx.assert_state(
1116 indoc! {"
1117 «line one
1118 line two
1119 ˇ»line three
1120 line four"},
1121 Mode::HelixNormal,
1122 );
1123
1124 // Pressing x in empty line, select next line (because helix considers cursor a selection)
1125 cx.set_state(
1126 indoc! {"
1127 line one
1128 ˇ
1129 line three
1130 line four"},
1131 Mode::HelixNormal,
1132 );
1133 cx.simulate_keystrokes("x");
1134 cx.assert_state(
1135 indoc! {"
1136 line one
1137 «
1138 line three
1139 ˇ»line four"},
1140 Mode::HelixNormal,
1141 );
1142
1143 // Empty line with count selects extra + count lines
1144 cx.set_state(
1145 indoc! {"
1146 line one
1147 ˇ
1148 line three
1149 line four
1150 line five"},
1151 Mode::HelixNormal,
1152 );
1153 cx.simulate_keystrokes("2 x");
1154 cx.assert_state(
1155 indoc! {"
1156 line one
1157 «
1158 line three
1159 line four
1160 ˇ»line five"},
1161 Mode::HelixNormal,
1162 );
1163
1164 // Compare empty vs non-empty line behavior
1165 cx.set_state(
1166 indoc! {"
1167 ˇnon-empty line
1168 line two
1169 line three"},
1170 Mode::HelixNormal,
1171 );
1172 cx.simulate_keystrokes("x");
1173 cx.assert_state(
1174 indoc! {"
1175 «non-empty line
1176 ˇ»line two
1177 line three"},
1178 Mode::HelixNormal,
1179 );
1180
1181 // Same test but with empty line - should select one extra
1182 cx.set_state(
1183 indoc! {"
1184 ˇ
1185 line two
1186 line three"},
1187 Mode::HelixNormal,
1188 );
1189 cx.simulate_keystrokes("x");
1190 cx.assert_state(
1191 indoc! {"
1192 «
1193 line two
1194 ˇ»line three"},
1195 Mode::HelixNormal,
1196 );
1197
1198 // Test selecting multiple lines with count
1199 cx.set_state(
1200 indoc! {"
1201 ˇline one
1202 line two
1203 line threeˇ
1204 line four
1205 line five"},
1206 Mode::HelixNormal,
1207 );
1208 cx.simulate_keystrokes("x");
1209 cx.assert_state(
1210 indoc! {"
1211 «line one
1212 ˇ»line two
1213 «line three
1214 ˇ»line four
1215 line five"},
1216 Mode::HelixNormal,
1217 );
1218 cx.simulate_keystrokes("x");
1219 cx.assert_state(
1220 indoc! {"
1221 «line one
1222 line two
1223 line three
1224 line four
1225 ˇ»line five"},
1226 Mode::HelixNormal,
1227 );
1228 }
1229
1230 #[gpui::test]
1231 async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
1232 let mut cx = VimTestContext::new(cx, true).await;
1233
1234 assert_eq!(cx.mode(), Mode::Normal);
1235 cx.enable_helix();
1236
1237 cx.set_state("ˇhello", Mode::HelixNormal);
1238 cx.simulate_keystrokes("l v l l");
1239 cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
1240 }
1241
1242 #[gpui::test]
1243 async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) {
1244 let mut cx = VimTestContext::new(cx, true).await;
1245
1246 assert_eq!(cx.mode(), Mode::Normal);
1247 cx.enable_helix();
1248
1249 // Start with multiple cursors (no selections)
1250 cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
1251
1252 // Enter select mode and move right twice
1253 cx.simulate_keystrokes("v l l");
1254
1255 // Each cursor should independently create and extend its own selection
1256 cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
1257 }
1258
1259 #[gpui::test]
1260 async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
1261 let mut cx = VimTestContext::new(cx, true).await;
1262
1263 cx.set_state("ˇone two", Mode::Normal);
1264 cx.simulate_keystrokes("v w");
1265 cx.assert_state("«one tˇ»wo", Mode::Visual);
1266
1267 // In Vim, this selects "t". In helix selections stops just before "t"
1268
1269 cx.enable_helix();
1270 cx.set_state("ˇone two", Mode::HelixNormal);
1271 cx.simulate_keystrokes("v w");
1272 cx.assert_state("«one ˇ»two", Mode::HelixSelect);
1273 }
1274
1275 #[gpui::test]
1276 async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) {
1277 let mut cx = VimTestContext::new(cx, true).await;
1278 cx.enable_helix();
1279
1280 cx.set_state("ˇone two one", Mode::HelixNormal);
1281 cx.simulate_keystrokes("x");
1282 cx.assert_state("«one two oneˇ»", Mode::HelixNormal);
1283 cx.simulate_keystrokes("s o n e");
1284 cx.run_until_parked();
1285 cx.simulate_keystrokes("enter");
1286 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
1287
1288 cx.simulate_keystrokes("x");
1289 cx.simulate_keystrokes("s");
1290 cx.run_until_parked();
1291 cx.simulate_keystrokes("enter");
1292 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
1293
1294 cx.set_state("ˇone two one", Mode::HelixNormal);
1295 cx.simulate_keystrokes("s o n e enter");
1296 cx.assert_state("ˇone two one", Mode::HelixNormal);
1297 }
1298
1299 #[gpui::test]
1300 async fn test_helix_substitute(cx: &mut gpui::TestAppContext) {
1301 let mut cx = VimTestContext::new(cx, true).await;
1302
1303 cx.set_state("ˇone two", Mode::HelixNormal);
1304 cx.simulate_keystrokes("c");
1305 cx.assert_state("ˇne two", Mode::Insert);
1306
1307 cx.set_state("«oneˇ» two", Mode::HelixNormal);
1308 cx.simulate_keystrokes("c");
1309 cx.assert_state("ˇ two", Mode::Insert);
1310
1311 cx.set_state(
1312 indoc! {"
1313 oneˇ two
1314 three
1315 "},
1316 Mode::HelixNormal,
1317 );
1318 cx.simulate_keystrokes("x c");
1319 cx.assert_state(
1320 indoc! {"
1321 ˇ
1322 three
1323 "},
1324 Mode::Insert,
1325 );
1326
1327 cx.set_state(
1328 indoc! {"
1329 one twoˇ
1330 three
1331 "},
1332 Mode::HelixNormal,
1333 );
1334 cx.simulate_keystrokes("c");
1335 cx.assert_state(
1336 indoc! {"
1337 one twoˇthree
1338 "},
1339 Mode::Insert,
1340 );
1341
1342 // Helix doesn't set the cursor to the first non-blank one when
1343 // replacing lines: it uses language-dependent indent queries instead.
1344 cx.set_state(
1345 indoc! {"
1346 one two
1347 « indented
1348 three not indentedˇ»
1349 "},
1350 Mode::HelixNormal,
1351 );
1352 cx.simulate_keystrokes("c");
1353 cx.set_state(
1354 indoc! {"
1355 one two
1356 ˇ
1357 "},
1358 Mode::Insert,
1359 );
1360 }
1361}