Skip to repository content4398 lines · 155.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:36:06.310Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
helix.rs
1mod boundary;
2mod duplicate;
3mod object;
4mod paste;
5mod select;
6mod surround;
7
8use editor::display_map::{DisplayRow, DisplaySnapshot};
9use editor::{
10 DisplayPoint, Editor, EditorSettings, MultiBufferOffset, NavigationOverlayLabel,
11 NavigationTargetOverlay, SelectionEffects, ToOffset, ToPoint, movement,
12};
13use gpui::actions;
14use gpui::{App, Context, Font, Hsla, Pixels, TaskExt, Window, WindowTextSystem};
15use language::{CharClassifier, CharKind, Point, Selection};
16use multi_buffer::MultiBufferSnapshot;
17use search::{BufferSearchBar, SearchOptions};
18use settings::Settings;
19use text::{Bias, LineEnding, SelectionGoal};
20use theme::ActiveTheme as _;
21use ui::px;
22use workspace::searchable::{self, Direction, FilteredSearchRange};
23
24use crate::motion::{self, MotionKind};
25use crate::state::{HelixJumpBehaviour, HelixJumpLabel, Mode, Operator, SearchState};
26use crate::{
27 PushHelixSurroundAdd, PushHelixSurroundDelete, PushHelixSurroundReplace, Vim,
28 motion::{Motion, right},
29};
30use std::ops::Range;
31
32actions!(
33 vim,
34 [
35 /// Yanks the current selection or character if no selection.
36 HelixYank,
37 /// Inserts at the beginning of the selection.
38 HelixInsert,
39 /// Appends at the end of the selection.
40 HelixAppend,
41 /// Inserts at the end of the current Helix cursor line.
42 HelixInsertEndOfLine,
43 /// Goes to the location of the last modification.
44 HelixGotoLastModification,
45 /// Select entire line or multiple lines, extending downwards.
46 HelixSelectLine,
47 /// Select all matches of a given pattern within the current selection.
48 HelixSelectRegex,
49 /// Removes all but the one selection that was created last.
50 /// `Newest` can eventually be `Primary`.
51 HelixKeepNewestSelection,
52 /// Copies all selections below.
53 HelixDuplicateBelow,
54 /// Copies all selections above.
55 HelixDuplicateAbove,
56 /// Delete the selection and enter edit mode.
57 HelixSubstitute,
58 /// Delete the selection and enter edit mode, without yanking the selection.
59 HelixSubstituteNoYank,
60 /// Activate Helix-style word jump labels.
61 HelixJumpToWord,
62 /// Select the next match for the current search query.
63 HelixSelectNext,
64 /// Select the previous match for the current search query.
65 HelixSelectPrevious,
66 ]
67);
68
69pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
70 Vim::action(editor, cx, Vim::helix_select_lines);
71 Vim::action(editor, cx, Vim::helix_insert);
72 Vim::action(editor, cx, Vim::helix_append);
73 Vim::action(editor, cx, Vim::helix_insert_end_of_line);
74 Vim::action(editor, cx, Vim::helix_yank);
75 Vim::action(editor, cx, Vim::helix_goto_last_modification);
76 Vim::action(editor, cx, Vim::helix_paste);
77 Vim::action(editor, cx, Vim::helix_select_regex);
78 Vim::action(editor, cx, Vim::helix_keep_newest_selection);
79 Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| {
80 let times = Vim::take_count(cx);
81 vim.helix_duplicate_selections_below(times, window, cx);
82 });
83 Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| {
84 let times = Vim::take_count(cx);
85 vim.helix_duplicate_selections_above(times, window, cx);
86 });
87 Vim::action(editor, cx, Vim::helix_substitute);
88 Vim::action(editor, cx, Vim::helix_substitute_no_yank);
89 Vim::action(editor, cx, Vim::helix_jump_to_word);
90 Vim::action(editor, cx, Vim::helix_select_next);
91 Vim::action(editor, cx, Vim::helix_select_previous);
92 Vim::action(editor, cx, |vim, _: &PushHelixSurroundAdd, window, cx| {
93 vim.clear_operator(window, cx);
94 vim.push_operator(Operator::HelixSurroundAdd, window, cx);
95 });
96 Vim::action(
97 editor,
98 cx,
99 |vim, _: &PushHelixSurroundReplace, window, cx| {
100 vim.clear_operator(window, cx);
101 vim.push_operator(
102 Operator::HelixSurroundReplace {
103 replaced_char: None,
104 },
105 window,
106 cx,
107 );
108 },
109 );
110 Vim::action(
111 editor,
112 cx,
113 |vim, _: &PushHelixSurroundDelete, window, cx| {
114 vim.clear_operator(window, cx);
115 vim.push_operator(Operator::HelixSurroundDelete, window, cx);
116 },
117 );
118}
119
120/// Returns column 0 of the document's first line, imitating `gg` in Helix.
121///
122/// With a count, Helix treats it as a (1-based) line number, so a count of `n`
123/// targets buffer row `n - 1`, clamped to the last line.
124fn start_of_document(map: &DisplaySnapshot, times: Option<usize>) -> DisplayPoint {
125 let buffer_row = match times {
126 None => 0,
127 Some(times) => (times.saturating_sub(1) as u32).min(map.max_row().0),
128 };
129 map.point_to_display_point(Point::new(buffer_row, 0), Bias::Left)
130}
131
132/// Returns column 0 of the document's last line, imitating `ge` in Helix.
133///
134/// Helix ignores any count for `ge`, so it is not taken here.
135fn end_of_document(map: &DisplaySnapshot) -> DisplayPoint {
136 map.point_to_display_point(Point::new(map.max_row().0, 0), Bias::Left)
137}
138
139impl Vim {
140 pub fn helix_normal_motion(
141 &mut self,
142 motion: Motion,
143 times: Option<usize>,
144 window: &mut Window,
145 cx: &mut Context<Self>,
146 ) {
147 self.helix_move_cursor(motion, times, window, cx);
148 }
149
150 pub fn helix_select_motion(
151 &mut self,
152 motion: Motion,
153 times: Option<usize>,
154 window: &mut Window,
155 cx: &mut Context<Self>,
156 ) {
157 self.update_editor(cx, |_, editor, cx| {
158 let text_layout_details = editor.text_layout_details(window, cx);
159 editor.change_selections(Default::default(), window, cx, |s| {
160 if let Motion::ZedSearchResult { new_selections, .. } = &motion {
161 s.select_anchor_ranges(new_selections.clone());
162 return;
163 };
164
165 s.move_with(&mut |map, selection| {
166 let was_reversed = selection.reversed;
167 let mut current_head = selection.head();
168
169 // our motions assume the current character is after the cursor,
170 // but in (forward) visual mode the current character is just
171 // before the end of the selection.
172 if !selection.reversed && !selection.is_empty() {
173 current_head = movement::left(map, selection.end)
174 }
175
176 let (new_head, goal) = match motion {
177 Motion::StartOfDocument => {
178 (start_of_document(map, times), SelectionGoal::None)
179 }
180 Motion::EndOfDocument => (end_of_document(map), SelectionGoal::None),
181 // EndOfLine positions after the last character, but in
182 // helix visual mode we want the selection to end ON the
183 // last character. Adjust left here so the subsequent
184 // right-expansion (below) includes the last char without
185 // spilling into the newline.
186 Motion::EndOfLine { .. } => {
187 let (point, goal) = motion
188 .move_point(
189 map,
190 current_head,
191 selection.goal,
192 times,
193 &text_layout_details,
194 )
195 .unwrap_or((current_head, selection.goal));
196 (movement::saturating_left(map, point), goal)
197 }
198 // Going to next word start is special cased
199 // since Vim differs from Helix in that motion
200 // Vim: `w` goes to the first character of a word
201 // Helix: `w` goes to the character before a word
202 Motion::NextWordStart { ignore_punctuation } => {
203 let mut head = movement::right(map, current_head);
204 let classifier =
205 map.buffer_snapshot().char_classifier_at(head.to_point(map));
206 for _ in 0..times.unwrap_or(1) {
207 let (_, new_head) =
208 movement::find_boundary_trail(map, head, &mut |left, right| {
209 Self::is_boundary_right(ignore_punctuation)(
210 left,
211 right,
212 &classifier,
213 )
214 });
215 head = new_head;
216 }
217 head = movement::left(map, head);
218 (head, SelectionGoal::None)
219 }
220 _ => motion
221 .move_point(
222 map,
223 current_head,
224 selection.goal,
225 times,
226 &text_layout_details,
227 )
228 .unwrap_or((current_head, selection.goal)),
229 };
230
231 selection.set_head(new_head, goal);
232
233 // ensure the current character is included in the selection.
234 if !selection.reversed {
235 let next_point = movement::right(map, selection.end);
236
237 selection.end = next_point;
238 }
239
240 // vim always ensures the anchor character stays selected.
241 // if our selection has reversed, we need to move the opposite end
242 // to ensure the anchor is still selected.
243 if was_reversed && !selection.reversed {
244 selection.start = movement::left(map, selection.start);
245 } else if !was_reversed && selection.reversed {
246 selection.end = movement::right(map, selection.end);
247 }
248 })
249 });
250 });
251 }
252
253 /// Updates all selections based on where the cursors are.
254 fn helix_new_selections(
255 &mut self,
256 window: &mut Window,
257 cx: &mut Context<Self>,
258 change: &mut dyn FnMut(
259 // the start of the cursor
260 DisplayPoint,
261 &DisplaySnapshot,
262 ) -> Option<(DisplayPoint, DisplayPoint)>,
263 ) {
264 self.update_editor(cx, |_, editor, cx| {
265 editor.change_selections(Default::default(), window, cx, |s| {
266 s.move_with(&mut |map, selection| {
267 let cursor_start = if selection.reversed || selection.is_empty() {
268 selection.head()
269 } else {
270 movement::left(map, selection.head())
271 };
272 let Some((head, tail)) = change(cursor_start, map) else {
273 return;
274 };
275
276 selection.set_head_tail(head, tail, SelectionGoal::None);
277 });
278 });
279 });
280 }
281
282 fn helix_find_range_forward(
283 &mut self,
284 times: Option<usize>,
285 window: &mut Window,
286 cx: &mut Context<Self>,
287 is_boundary: &mut dyn FnMut(char, char, &CharClassifier) -> bool,
288 ) {
289 let times = times.unwrap_or(1);
290 self.helix_new_selections(window, cx, &mut |cursor, map| {
291 let mut head = movement::right(map, cursor);
292 let mut tail = cursor;
293 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
294 if head == map.max_point() {
295 return None;
296 }
297 for _ in 0..times {
298 let (maybe_next_tail, next_head) =
299 movement::find_boundary_trail(map, head, &mut |left, right| {
300 is_boundary(left, right, &classifier)
301 });
302
303 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
304 break;
305 }
306
307 head = next_head;
308 if let Some(next_tail) = maybe_next_tail {
309 tail = next_tail;
310 }
311 }
312 Some((head, tail))
313 });
314 }
315
316 fn helix_find_range_backward(
317 &mut self,
318 times: Option<usize>,
319 window: &mut Window,
320 cx: &mut Context<Self>,
321 is_boundary: &mut dyn FnMut(char, char, &CharClassifier) -> bool,
322 ) {
323 let times = times.unwrap_or(1);
324 self.helix_new_selections(window, cx, &mut |cursor, map| {
325 let mut head = cursor;
326 // The original cursor was one character wide,
327 // but the search starts from the left side of it,
328 // so to include that space the selection must end one character to the right.
329 let mut tail = movement::right(map, cursor);
330 let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
331 if head == DisplayPoint::zero() {
332 return None;
333 }
334 for _ in 0..times {
335 let (maybe_next_tail, next_head) =
336 movement::find_preceding_boundary_trail(map, head, &mut |left, right| {
337 is_boundary(left, right, &classifier)
338 });
339
340 if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
341 break;
342 }
343
344 head = next_head;
345 if let Some(next_tail) = maybe_next_tail {
346 tail = next_tail;
347 }
348 }
349 Some((head, tail))
350 });
351 }
352
353 pub fn helix_move_and_collapse(
354 &mut self,
355 motion: Motion,
356 times: Option<usize>,
357 window: &mut Window,
358 cx: &mut Context<Self>,
359 ) {
360 self.update_editor(cx, |_, editor, cx| {
361 let text_layout_details = editor.text_layout_details(window, cx);
362 editor.change_selections(Default::default(), window, cx, |s| {
363 s.move_with(&mut |map, selection| {
364 let goal = selection.goal;
365 let cursor = if selection.is_empty() || selection.reversed {
366 selection.head()
367 } else {
368 movement::left(map, selection.head())
369 };
370
371 let (point, goal) = motion
372 .move_point(map, cursor, selection.goal, times, &text_layout_details)
373 .unwrap_or((cursor, goal));
374
375 selection.collapse_to(point, goal)
376 })
377 });
378 });
379 }
380
381 fn is_boundary_right(
382 ignore_punctuation: bool,
383 ) -> impl FnMut(char, char, &CharClassifier) -> bool {
384 move |left, right, classifier| {
385 let left_kind = classifier.kind_with(left, ignore_punctuation);
386 let right_kind = classifier.kind_with(right, ignore_punctuation);
387 let at_newline = (left == '\n') ^ (right == '\n');
388
389 (left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
390 }
391 }
392
393 fn is_boundary_left(
394 ignore_punctuation: bool,
395 ) -> impl FnMut(char, char, &CharClassifier) -> bool {
396 move |left, right, classifier| {
397 let left_kind = classifier.kind_with(left, ignore_punctuation);
398 let right_kind = classifier.kind_with(right, ignore_punctuation);
399 let at_newline = (left == '\n') ^ (right == '\n');
400
401 (left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
402 }
403 }
404
405 /// When `reversed` is true (used with `helix_find_range_backward`), the
406 /// `left` and `right` characters are yielded in reverse text order, so the
407 /// camelCase transition check must be flipped accordingly.
408 fn subword_boundary_start(
409 ignore_punctuation: bool,
410 reversed: bool,
411 ) -> impl FnMut(char, char, &CharClassifier) -> bool {
412 move |left, right, classifier| {
413 let left_kind = classifier.kind_with(left, ignore_punctuation);
414 let right_kind = classifier.kind_with(right, ignore_punctuation);
415 let at_newline = (left == '\n') ^ (right == '\n');
416 let is_separator = |c: char| "_$=".contains(c);
417
418 let is_word = left_kind != right_kind && right_kind != CharKind::Whitespace;
419 let is_subword = (is_separator(left) && !is_separator(right))
420 || if reversed {
421 right.is_lowercase() && left.is_uppercase()
422 } else {
423 left.is_lowercase() && right.is_uppercase()
424 };
425
426 is_word || (is_subword && !right.is_whitespace()) || at_newline
427 }
428 }
429
430 /// When `reversed` is true (used with `helix_find_range_backward`), the
431 /// `left` and `right` characters are yielded in reverse text order, so the
432 /// camelCase transition check must be flipped accordingly.
433 fn subword_boundary_end(
434 ignore_punctuation: bool,
435 reversed: bool,
436 ) -> impl FnMut(char, char, &CharClassifier) -> bool {
437 move |left, right, classifier| {
438 let left_kind = classifier.kind_with(left, ignore_punctuation);
439 let right_kind = classifier.kind_with(right, ignore_punctuation);
440 let at_newline = (left == '\n') ^ (right == '\n');
441 let is_separator = |c: char| "_$=".contains(c);
442
443 let is_word = left_kind != right_kind && left_kind != CharKind::Whitespace;
444 let is_subword = (!is_separator(left) && is_separator(right))
445 || if reversed {
446 right.is_lowercase() && left.is_uppercase()
447 } else {
448 left.is_lowercase() && right.is_uppercase()
449 };
450
451 is_word || (is_subword && !left.is_whitespace()) || at_newline
452 }
453 }
454
455 pub fn helix_move_cursor(
456 &mut self,
457 motion: Motion,
458 times: Option<usize>,
459 window: &mut Window,
460 cx: &mut Context<Self>,
461 ) {
462 match motion {
463 Motion::NextWordStart { ignore_punctuation } => {
464 let mut is_boundary = Self::is_boundary_right(ignore_punctuation);
465 self.helix_find_range_forward(times, window, cx, &mut is_boundary)
466 }
467 Motion::NextWordEnd { ignore_punctuation } => {
468 let mut is_boundary = Self::is_boundary_left(ignore_punctuation);
469 self.helix_find_range_forward(times, window, cx, &mut is_boundary)
470 }
471 Motion::PreviousWordStart { ignore_punctuation } => {
472 let mut is_boundary = Self::is_boundary_left(ignore_punctuation);
473 self.helix_find_range_backward(times, window, cx, &mut is_boundary)
474 }
475 Motion::PreviousWordEnd { ignore_punctuation } => {
476 let mut is_boundary = Self::is_boundary_right(ignore_punctuation);
477 self.helix_find_range_backward(times, window, cx, &mut is_boundary)
478 }
479 // The subword motions implementation is based off of the same
480 // commands present in Helix itself, namely:
481 //
482 // * `move_next_sub_word_start`
483 // * `move_next_sub_word_end`
484 // * `move_prev_sub_word_start`
485 // * `move_prev_sub_word_end`
486 Motion::NextSubwordStart { ignore_punctuation } => {
487 let mut is_boundary = Self::subword_boundary_start(ignore_punctuation, false);
488 self.helix_find_range_forward(times, window, cx, &mut is_boundary)
489 }
490 Motion::NextSubwordEnd { ignore_punctuation } => {
491 let mut is_boundary = Self::subword_boundary_end(ignore_punctuation, false);
492 self.helix_find_range_forward(times, window, cx, &mut is_boundary)
493 }
494 Motion::PreviousSubwordStart { ignore_punctuation } => {
495 let mut is_boundary = Self::subword_boundary_end(ignore_punctuation, true);
496 self.helix_find_range_backward(times, window, cx, &mut is_boundary)
497 }
498 Motion::PreviousSubwordEnd { ignore_punctuation } => {
499 let mut is_boundary = Self::subword_boundary_start(ignore_punctuation, true);
500 self.helix_find_range_backward(times, window, cx, &mut is_boundary)
501 }
502 Motion::StartOfDocument => {
503 self.update_editor(cx, |_, editor, cx| {
504 editor.change_selections(Default::default(), window, cx, |s| {
505 s.move_with(&mut |map, selection| {
506 selection
507 .collapse_to(start_of_document(map, times), SelectionGoal::None)
508 })
509 });
510 });
511 }
512 Motion::EndOfDocument => {
513 self.update_editor(cx, |_, editor, cx| {
514 editor.change_selections(Default::default(), window, cx, |s| {
515 s.move_with(&mut |map, selection| {
516 selection.collapse_to(end_of_document(map), SelectionGoal::None)
517 })
518 });
519 });
520 }
521 Motion::EndOfLine { .. } => {
522 // In Helix mode, EndOfLine should position cursor ON the last character,
523 // not after it. We therefore need special handling for it.
524 self.update_editor(cx, |_, editor, cx| {
525 let text_layout_details = editor.text_layout_details(window, cx);
526 editor.change_selections(Default::default(), window, cx, |s| {
527 s.move_with(&mut |map, selection| {
528 let goal = selection.goal;
529 let cursor = if selection.is_empty() || selection.reversed {
530 selection.head()
531 } else {
532 movement::left(map, selection.head())
533 };
534
535 let (point, _goal) = motion
536 .move_point(map, cursor, goal, times, &text_layout_details)
537 .unwrap_or((cursor, goal));
538
539 // Move left by one character to position on the last character
540 let adjusted_point = movement::saturating_left(map, point);
541 selection.collapse_to(adjusted_point, SelectionGoal::None)
542 })
543 });
544 });
545 }
546 Motion::FindForward {
547 before,
548 char,
549 mode,
550 smartcase,
551 } => {
552 self.helix_new_selections(window, cx, &mut |cursor, map| {
553 let start = cursor;
554 let mut last_boundary = start;
555 for _ in 0..times.unwrap_or(1) {
556 last_boundary = movement::find_boundary(
557 map,
558 movement::right(map, last_boundary),
559 mode,
560 &mut |left, right| {
561 let current_char = if before { right } else { left };
562 motion::is_character_match(char, current_char, smartcase)
563 },
564 );
565 }
566 Some((last_boundary, start))
567 });
568 }
569 Motion::FindBackward {
570 after,
571 char,
572 mode,
573 smartcase,
574 } => {
575 self.helix_new_selections(window, cx, &mut |cursor, map| {
576 let start = cursor;
577 let mut last_boundary = start;
578 for _ in 0..times.unwrap_or(1) {
579 last_boundary = movement::find_preceding_boundary_display_point(
580 map,
581 last_boundary,
582 mode,
583 &mut |left, right| {
584 let current_char = if after { left } else { right };
585 motion::is_character_match(char, current_char, smartcase)
586 },
587 );
588 }
589 // The original cursor was one character wide,
590 // but the search started from the left side of it,
591 // so to include that space the selection must end one character to the right.
592 Some((last_boundary, movement::right(map, start)))
593 });
594 }
595 _ => self.helix_move_and_collapse(motion, times, window, cx),
596 }
597 }
598
599 pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
600 self.update_editor(cx, |vim, editor, cx| {
601 let has_selection = editor
602 .selections
603 .all_adjusted(&editor.display_snapshot(cx))
604 .iter()
605 .any(|selection| !selection.is_empty());
606
607 if !has_selection {
608 // If no selection, expand to current character (like 'v' does)
609 editor.change_selections(Default::default(), window, cx, |s| {
610 s.move_with(&mut |map, selection| {
611 let head = selection.head();
612 let new_head = movement::saturating_right(map, head);
613 selection.set_tail(head, SelectionGoal::None);
614 selection.set_head(new_head, SelectionGoal::None);
615 });
616 });
617 vim.yank_selections_content(
618 editor,
619 crate::motion::MotionKind::Exclusive,
620 window,
621 cx,
622 );
623 editor.change_selections(Default::default(), window, cx, |s| {
624 s.move_with(&mut |_map, selection| {
625 selection.collapse_to(selection.start, SelectionGoal::None);
626 });
627 });
628 } else {
629 // Yank the selection(s)
630 vim.yank_selections_content(
631 editor,
632 crate::motion::MotionKind::Exclusive,
633 window,
634 cx,
635 );
636 }
637 });
638
639 // Drop back to normal mode after yanking
640 self.switch_mode(Mode::HelixNormal, true, window, cx);
641 }
642
643 fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
644 self.start_recording(cx);
645 self.update_editor(cx, |_, editor, cx| {
646 editor.change_selections(Default::default(), window, cx, |s| {
647 s.move_with(&mut |_map, selection| {
648 // In helix normal mode, move cursor to start of selection and collapse
649 if !selection.is_empty() {
650 selection.collapse_to(selection.start, SelectionGoal::None);
651 }
652 });
653 });
654 });
655 self.switch_mode(Mode::Insert, false, window, cx);
656 }
657
658 fn helix_select_regex(
659 &mut self,
660 _: &HelixSelectRegex,
661 window: &mut Window,
662 cx: &mut Context<Self>,
663 ) {
664 Vim::take_forced_motion(cx);
665 let Some(pane) = self.pane(window, cx) else {
666 return;
667 };
668 let prior_selections = self.editor_selections(window, cx);
669 pane.update(cx, |pane, cx| {
670 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
671 search_bar.update(cx, |search_bar, cx| {
672 if !search_bar.show(window, cx) {
673 return;
674 }
675
676 search_bar.select_query(window, cx);
677 cx.focus_self(window);
678
679 search_bar.set_replacement(None, cx);
680 let mut options = SearchOptions::NONE;
681 options |= SearchOptions::REGEX;
682 if EditorSettings::get_global(cx).search.case_sensitive {
683 options |= SearchOptions::CASE_SENSITIVE;
684 }
685 search_bar.set_search_options(options, cx);
686 if let Some(search) = search_bar.set_search_within_selection(
687 Some(FilteredSearchRange::Selection),
688 window,
689 cx,
690 ) {
691 cx.spawn_in(window, async move |search_bar, cx| {
692 if search.await.is_ok() {
693 search_bar.update_in(cx, |search_bar, window, cx| {
694 search_bar.activate_current_match(window, cx)
695 })
696 } else {
697 Ok(())
698 }
699 })
700 .detach_and_log_err(cx);
701 }
702 self.search = SearchState {
703 direction: searchable::Direction::Next,
704 count: 1,
705 cmd_f_search: false,
706 prior_selections,
707 prior_operator: self.operator_stack.last().cloned(),
708 prior_mode: self.mode,
709 helix_select: true,
710 _dismiss_subscription: None,
711 }
712 });
713 }
714 });
715 self.start_recording(cx);
716 }
717
718 fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
719 self.start_recording(cx);
720 self.switch_mode(Mode::Insert, false, window, cx);
721 self.update_editor(cx, |_, editor, cx| {
722 editor.change_selections(Default::default(), window, cx, |s| {
723 s.move_with(&mut |map, selection| {
724 let point = if selection.is_empty() {
725 right(map, selection.head(), 1)
726 } else {
727 selection.end
728 };
729 selection.collapse_to(point, SelectionGoal::None);
730 });
731 });
732 });
733 }
734
735 /// Helix-specific implementation of `shift-a` that accounts for Helix's
736 /// selection model, where selecting a line with `x` creates a selection
737 /// from column 0 of the current row to column 0 of the next row, so the
738 /// default [`vim::normal::InsertEndOfLine`] would move the cursor to the
739 /// end of the wrong line.
740 fn helix_insert_end_of_line(
741 &mut self,
742 _: &HelixInsertEndOfLine,
743 window: &mut Window,
744 cx: &mut Context<Self>,
745 ) {
746 self.start_recording(cx);
747 self.switch_mode(Mode::Insert, false, window, cx);
748 self.update_editor(cx, |_, editor, cx| {
749 editor.change_selections(Default::default(), window, cx, |s| {
750 s.move_with(&mut |map, selection| {
751 let cursor = if !selection.is_empty() && !selection.reversed {
752 movement::left(map, selection.head())
753 } else {
754 selection.head()
755 };
756 selection
757 .collapse_to(motion::next_line_end(map, cursor, 1), SelectionGoal::None);
758 });
759 });
760 });
761 }
762
763 pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
764 self.update_editor(cx, |_, editor, cx| {
765 editor.transact(window, cx, |editor, window, cx| {
766 let display_map = editor.display_snapshot(cx);
767 let selections = editor.selections.all_display(&display_map);
768
769 let mut edits = Vec::new();
770 let mut selection_info = Vec::new();
771 for selection in &selections {
772 let mut range = selection.range();
773 let was_empty = range.is_empty();
774 let was_reversed = selection.reversed;
775
776 if was_empty {
777 range.end = movement::saturating_right(&display_map, range.start);
778 }
779
780 let byte_range = range.start.to_offset(&display_map, Bias::Left)
781 ..range.end.to_offset(&display_map, Bias::Left);
782
783 let snapshot = display_map.buffer_snapshot();
784 let grapheme_count = snapshot.grapheme_count_for_range(&byte_range);
785 let anchor = snapshot.anchor_before(byte_range.start);
786 let mut replacement_len = 0;
787
788 if !byte_range.is_empty() {
789 let mut replacement_text = text.repeat(grapheme_count);
790 LineEnding::normalize(&mut replacement_text);
791 replacement_len = replacement_text.len();
792 edits.push((byte_range, replacement_text));
793 }
794
795 selection_info.push((anchor, replacement_len, was_empty, was_reversed));
796 }
797
798 editor.edit(edits, cx);
799
800 // Restore selections based on original info
801 let snapshot = editor.buffer().read(cx).snapshot(cx);
802 let ranges: Vec<_> = selection_info
803 .into_iter()
804 .map(|(start_anchor, replacement_len, was_empty, was_reversed)| {
805 let start_point = start_anchor.to_point(&snapshot);
806 if was_empty {
807 start_point..start_point
808 } else {
809 let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
810 let end_point = snapshot.offset_to_point(end_offset);
811 if was_reversed {
812 end_point..start_point
813 } else {
814 start_point..end_point
815 }
816 }
817 })
818 .collect();
819
820 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
821 s.select_ranges(ranges);
822 });
823 });
824 });
825 self.switch_mode(Mode::HelixNormal, true, window, cx);
826 }
827
828 pub fn helix_goto_last_modification(
829 &mut self,
830 _: &HelixGotoLastModification,
831 window: &mut Window,
832 cx: &mut Context<Self>,
833 ) {
834 self.jump(".".into(), false, false, window, cx);
835 }
836
837 pub fn helix_select_lines(
838 &mut self,
839 _: &HelixSelectLine,
840 window: &mut Window,
841 cx: &mut Context<Self>,
842 ) {
843 let count = Vim::take_count(cx).unwrap_or(1);
844 self.update_editor(cx, |_, editor, cx| {
845 let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
846 let mut selections = editor.selections.all::<Point>(&display_map);
847 let max_point = display_map.buffer_snapshot().max_point();
848 let buffer_snapshot = &display_map.buffer_snapshot();
849
850 for selection in &mut selections {
851 // Start always goes to column 0 of the first selected line
852 let start_row = selection.start.row;
853 let current_end_row = selection.end.row;
854
855 // Check if cursor is on empty line by checking first character
856 let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
857 let first_char = buffer_snapshot.chars_at(line_start_offset).next();
858 let extra_line = if first_char == Some('\n') && selection.is_empty() {
859 1
860 } else {
861 0
862 };
863
864 let end_row = current_end_row + count as u32 + extra_line;
865
866 selection.start = Point::new(start_row, 0);
867 selection.end = if end_row > max_point.row {
868 max_point
869 } else {
870 Point::new(end_row, 0)
871 };
872 selection.reversed = false;
873 }
874
875 editor.change_selections(Default::default(), window, cx, |s| {
876 s.select(selections);
877 });
878 });
879 }
880
881 fn helix_keep_newest_selection(
882 &mut self,
883 _: &HelixKeepNewestSelection,
884 window: &mut Window,
885 cx: &mut Context<Self>,
886 ) {
887 self.update_editor(cx, |_, editor, cx| {
888 let newest = editor
889 .selections
890 .newest::<MultiBufferOffset>(&editor.display_snapshot(cx));
891 editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest]));
892 });
893 }
894
895 fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context<Self>) {
896 self.update_editor(cx, |vim, editor, cx| {
897 editor.set_clip_at_line_ends(false, cx);
898 editor.transact(window, cx, |editor, window, cx| {
899 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
900 s.move_with(&mut |map, selection| {
901 if selection.start == selection.end {
902 selection.end = movement::right(map, selection.end);
903 }
904
905 // If the selection starts and ends on a newline, we exclude the last one.
906 if !selection.is_empty()
907 && selection.start.column() == 0
908 && selection.end.column() == 0
909 {
910 selection.end = movement::left(map, selection.end);
911 }
912 })
913 });
914 if yank {
915 vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
916 }
917 let selections = editor
918 .selections
919 .all::<Point>(&editor.display_snapshot(cx))
920 .into_iter();
921 let edits = selections.map(|selection| (selection.start..selection.end, ""));
922 editor.edit(edits, cx);
923 });
924 });
925 self.switch_mode(Mode::Insert, true, window, cx);
926 }
927
928 fn helix_substitute(
929 &mut self,
930 _: &HelixSubstitute,
931 window: &mut Window,
932 cx: &mut Context<Self>,
933 ) {
934 self.do_helix_substitute(true, window, cx);
935 }
936
937 fn helix_substitute_no_yank(
938 &mut self,
939 _: &HelixSubstituteNoYank,
940 window: &mut Window,
941 cx: &mut Context<Self>,
942 ) {
943 self.do_helix_substitute(false, window, cx);
944 }
945
946 fn helix_select_next(
947 &mut self,
948 _: &HelixSelectNext,
949 window: &mut Window,
950 cx: &mut Context<Self>,
951 ) {
952 self.do_helix_select(Direction::Next, window, cx);
953 }
954
955 fn helix_select_previous(
956 &mut self,
957 _: &HelixSelectPrevious,
958 window: &mut Window,
959 cx: &mut Context<Self>,
960 ) {
961 self.do_helix_select(Direction::Prev, window, cx);
962 }
963
964 fn do_helix_select(
965 &mut self,
966 direction: searchable::Direction,
967 window: &mut Window,
968 cx: &mut Context<Self>,
969 ) {
970 let Some(pane) = self.pane(window, cx) else {
971 return;
972 };
973 let count = Vim::take_count(cx).unwrap_or(1);
974 Vim::take_forced_motion(cx);
975 let prior_selections = self.editor_selections(window, cx);
976
977 let success = pane.update(cx, |pane, cx| {
978 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
979 return false;
980 };
981 search_bar.update(cx, |search_bar, cx| {
982 if !search_bar.has_active_match() || !search_bar.show(window, cx) {
983 return false;
984 }
985 search_bar.select_match(direction, count, window, cx);
986 true
987 })
988 });
989
990 if !success {
991 return;
992 }
993 if self.mode == Mode::HelixSelect {
994 self.update_editor(cx, |_vim, editor, cx| {
995 let snapshot = editor.snapshot(window, cx);
996 editor.change_selections(SelectionEffects::default(), window, cx, |s| {
997 let buffer = snapshot.buffer_snapshot();
998
999 s.select_ranges(
1000 prior_selections
1001 .iter()
1002 .cloned()
1003 .chain(s.all_anchors(&snapshot).iter().map(|s| s.range()))
1004 .map(|range| {
1005 let start = range.start.to_offset(buffer);
1006 let end = range.end.to_offset(buffer);
1007 start..end
1008 }),
1009 );
1010 })
1011 });
1012 }
1013 }
1014
1015 pub fn helix_jump_to_word(
1016 &mut self,
1017 _: &HelixJumpToWord,
1018 window: &mut Window,
1019 cx: &mut Context<Self>,
1020 ) {
1021 let behaviour = match self.mode {
1022 // Vim normal mode treats jump-to-word as a cursor motion, while Helix
1023 // normal mode treats the cursor as a single-character selection.
1024 Mode::Normal => HelixJumpBehaviour::MoveToWordStart,
1025 // Vim visual mode extends like a motion, so the cursor stops at the
1026 // same word boundary as normal mode instead of selecting the word.
1027 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
1028 HelixJumpBehaviour::ExtendToWordStart
1029 }
1030 Mode::HelixSelect => HelixJumpBehaviour::Extend,
1031 _ => HelixJumpBehaviour::Move,
1032 };
1033 self.start_helix_jump(behaviour, window, cx);
1034 }
1035
1036 fn start_helix_jump(
1037 &mut self,
1038 behaviour: HelixJumpBehaviour,
1039 window: &mut Window,
1040 cx: &mut Context<Self>,
1041 ) {
1042 let allow_targets_in_selection = self.mode.has_selection();
1043 let Some(data) = self.collect_helix_jump_data(allow_targets_in_selection, window, cx)
1044 else {
1045 return;
1046 };
1047
1048 if data.labels.is_empty() {
1049 self.clear_helix_jump_ui(window, cx);
1050 return;
1051 }
1052
1053 if !self.apply_helix_jump_ui(data.overlays, window, cx) {
1054 return;
1055 }
1056
1057 self.push_operator(
1058 Operator::HelixJump {
1059 behaviour,
1060 first_char: None,
1061 labels: data.labels,
1062 },
1063 window,
1064 cx,
1065 );
1066 }
1067
1068 fn collect_helix_jump_data(
1069 &mut self,
1070 allow_targets_in_selection: bool,
1071 window: &mut Window,
1072 cx: &mut Context<Self>,
1073 ) -> Option<HelixJumpUiData> {
1074 self.update_editor(cx, |_, editor, cx| {
1075 let snapshot = editor.snapshot(window, cx);
1076 let display_snapshot = &snapshot.display_snapshot;
1077 let buffer_snapshot = display_snapshot.buffer_snapshot();
1078 let visible_range = Self::visible_jump_range(editor, &snapshot, display_snapshot, cx);
1079 let start_offset = buffer_snapshot.point_to_offset(visible_range.start);
1080 let end_offset = buffer_snapshot.point_to_offset(visible_range.end);
1081
1082 let selections = editor.selections.all::<Point>(&display_snapshot);
1083 let skip_data = Self::selection_skip_offsets(
1084 buffer_snapshot,
1085 &selections,
1086 allow_targets_in_selection,
1087 );
1088
1089 // Get the primary cursor position for alternating forward/backward labeling
1090 let cursor_offset = selections
1091 .first()
1092 .map(|s| buffer_snapshot.point_to_offset(s.head()))
1093 .unwrap_or(start_offset);
1094
1095 let style = editor.style(cx);
1096 let font = style.text.font();
1097 let font_size = style.text.font_size.to_pixels(window.rem_size());
1098 let label_color = cx.theme().colors().vim_helix_jump_label_foreground;
1099
1100 Self::build_helix_jump_ui_data(
1101 buffer_snapshot,
1102 start_offset,
1103 end_offset,
1104 cursor_offset,
1105 label_color,
1106 &skip_data,
1107 window.text_system(),
1108 font,
1109 font_size,
1110 )
1111 })
1112 }
1113
1114 fn visible_jump_range(
1115 editor: &Editor,
1116 snapshot: &editor::EditorSnapshot,
1117 display_snapshot: &DisplaySnapshot,
1118 cx: &App,
1119 ) -> Range<Point> {
1120 let visible_range = editor.multi_buffer_visible_range(display_snapshot, cx);
1121 if editor.visible_line_count().is_some() || visible_range.start != visible_range.end {
1122 return visible_range;
1123 }
1124
1125 let scroll_position = snapshot.scroll_position();
1126 let top_row = scroll_position.y.floor().max(0.0) as u32;
1127 let visible_rows = display_snapshot
1128 .max_point()
1129 .row()
1130 .0
1131 .saturating_sub(top_row)
1132 .saturating_add(1);
1133 let start_display_point = DisplayPoint::new(DisplayRow(top_row), 0);
1134 let end_display_point =
1135 DisplayPoint::new(DisplayRow(top_row.saturating_add(visible_rows)), 0);
1136
1137 display_snapshot.display_point_to_point(start_display_point, Bias::Left)
1138 ..display_snapshot.display_point_to_point(end_display_point, Bias::Right)
1139 }
1140
1141 fn build_helix_jump_ui_data(
1142 buffer: &MultiBufferSnapshot,
1143 start_offset: MultiBufferOffset,
1144 end_offset: MultiBufferOffset,
1145 cursor_offset: MultiBufferOffset,
1146 label_color: Hsla,
1147 skip_data: &HelixJumpSkipData,
1148 text_system: &WindowTextSystem,
1149 font: Font,
1150 font_size: Pixels,
1151 ) -> HelixJumpUiData {
1152 if start_offset >= end_offset {
1153 return HelixJumpUiData::default();
1154 }
1155
1156 // First pass: collect all word candidates without assigning labels
1157 let candidates = Self::collect_jump_candidates(buffer, start_offset, end_offset, skip_data);
1158
1159 if candidates.is_empty() {
1160 return HelixJumpUiData::default();
1161 }
1162
1163 let ordered_candidates = Self::order_jump_candidates(candidates, cursor_offset);
1164
1165 // Now assign labels and build UI data
1166 let mut labels = Vec::with_capacity(ordered_candidates.len());
1167 let mut overlays = Vec::with_capacity(ordered_candidates.len());
1168
1169 let width_of = |text: &str| -> Pixels {
1170 if text.is_empty() {
1171 return px(0.0);
1172 }
1173
1174 let run = gpui::TextRun {
1175 len: text.len(),
1176 font: font.clone(),
1177 color: Hsla::default(),
1178 background_color: None,
1179 underline: None,
1180 strikethrough: None,
1181 };
1182
1183 text_system.layout_line(text, font_size, &[run], None).width
1184 };
1185
1186 let is_monospace = Self::is_monospace_jump_font(text_system, &font, font_size);
1187
1188 for (label_index, candidate) in ordered_candidates.into_iter().enumerate() {
1189 let start_anchor = buffer.anchor_after(candidate.word_start);
1190 let end_anchor = buffer.anchor_after(candidate.word_end);
1191 let label = Self::jump_label_for_index(label_index);
1192 let label_text = label.iter().collect::<String>();
1193 // Monospace fonts: the label always matches the width of the first two characters,
1194 // so no per-word measurement is needed.
1195 // Proportional fonts: a label like "mw" can be wider than a short word like "if",
1196 // so we hide enough of the word (and possibly trailing whitespace) to make room,
1197 // or shift the label left into preceding whitespace.
1198 let fit = if is_monospace {
1199 JumpLabelFit::monospace(candidate.first_two_end)
1200 } else {
1201 let label_width = width_of(&label_text);
1202 Self::fit_proportional_jump_label(
1203 buffer,
1204 &candidate,
1205 end_offset,
1206 label_width,
1207 &width_of,
1208 )
1209 };
1210
1211 let hide_end_anchor = buffer.anchor_after(fit.hide_end_offset);
1212
1213 labels.push(HelixJumpLabel {
1214 label,
1215 range: start_anchor..end_anchor,
1216 });
1217
1218 overlays.push(NavigationTargetOverlay {
1219 target_range: start_anchor..end_anchor,
1220 label: NavigationOverlayLabel {
1221 text: label_text.into(),
1222 text_color: label_color,
1223 x_offset: -fit.left_shift,
1224 scale_factor: fit.scale_factor,
1225 },
1226 covered_text_range: Some(start_anchor..hide_end_anchor),
1227 });
1228 }
1229
1230 HelixJumpUiData { labels, overlays }
1231 }
1232
1233 fn collect_jump_candidates(
1234 buffer: &MultiBufferSnapshot,
1235 start_offset: MultiBufferOffset,
1236 end_offset: MultiBufferOffset,
1237 skip_data: &HelixJumpSkipData,
1238 ) -> Vec<JumpCandidate> {
1239 let mut candidates = Vec::new();
1240
1241 let mut offset = start_offset;
1242 let mut in_word = false;
1243 let mut word_start = start_offset;
1244 let mut first_two_end = start_offset;
1245 let mut char_count = 0;
1246
1247 for chunk in buffer.text_for_range(start_offset..end_offset) {
1248 for (idx, ch) in chunk.char_indices() {
1249 let absolute = offset + idx;
1250 let is_word = is_jump_word_char(ch);
1251 if is_word {
1252 if !in_word {
1253 in_word = true;
1254 word_start = absolute;
1255 char_count = 0;
1256 }
1257 if char_count == 1 {
1258 first_two_end = absolute + ch.len_utf8();
1259 }
1260 char_count += 1;
1261 }
1262
1263 if !is_word && in_word {
1264 if char_count >= 2
1265 && !Self::should_skip_jump_candidate(word_start, absolute, skip_data)
1266 {
1267 candidates.push(JumpCandidate {
1268 word_start,
1269 word_end: absolute,
1270 first_two_end,
1271 });
1272 }
1273 in_word = false;
1274 }
1275 }
1276 offset += chunk.len();
1277 }
1278
1279 // Handle word at end of buffer
1280 if in_word
1281 && char_count >= 2
1282 && !Self::should_skip_jump_candidate(word_start, end_offset, skip_data)
1283 {
1284 candidates.push(JumpCandidate {
1285 word_start,
1286 word_end: end_offset,
1287 first_two_end,
1288 });
1289 }
1290
1291 candidates
1292 }
1293
1294 fn selection_skip_offsets(
1295 buffer: &MultiBufferSnapshot,
1296 selections: &[Selection<Point>],
1297 allow_targets_in_selection: bool,
1298 ) -> HelixJumpSkipData {
1299 let mut skip_points = Vec::with_capacity(selections.len());
1300 let mut skip_ranges = Vec::new();
1301
1302 for selection in selections {
1303 let head_offset = buffer.point_to_offset(selection.head());
1304 skip_points.push(head_offset);
1305
1306 if !allow_targets_in_selection && selection.start != selection.end {
1307 let mut start = buffer.point_to_offset(selection.start);
1308 let mut end = buffer.point_to_offset(selection.end);
1309 if start > end {
1310 std::mem::swap(&mut start, &mut end);
1311 }
1312 skip_ranges.push(start..end);
1313 }
1314 }
1315
1316 skip_points.sort_unstable();
1317
1318 skip_ranges.sort_unstable_by_key(|range| range.start);
1319 let mut merged_ranges: Vec<Range<MultiBufferOffset>> =
1320 Vec::with_capacity(skip_ranges.len());
1321 for range in skip_ranges {
1322 if let Some(previous_range) = merged_ranges.last_mut()
1323 && range.start <= previous_range.end
1324 {
1325 previous_range.end = previous_range.end.max(range.end);
1326 } else {
1327 merged_ranges.push(range);
1328 }
1329 }
1330
1331 HelixJumpSkipData {
1332 points: skip_points,
1333 ranges: merged_ranges,
1334 }
1335 }
1336
1337 fn should_skip_jump_candidate(
1338 word_start: MultiBufferOffset,
1339 word_end: MultiBufferOffset,
1340 skip_data: &HelixJumpSkipData,
1341 ) -> bool {
1342 // word_end is exclusive, so points at the following delimiter should not skip the word.
1343 let point_index = skip_data
1344 .points
1345 .partition_point(|offset| *offset < word_start);
1346 if skip_data
1347 .points
1348 .get(point_index)
1349 .is_some_and(|offset| *offset < word_end)
1350 {
1351 return true;
1352 }
1353
1354 let range_index = skip_data
1355 .ranges
1356 .partition_point(|range| range.end <= word_start);
1357 skip_data
1358 .ranges
1359 .get(range_index)
1360 .is_some_and(|range| range.start < word_end)
1361 }
1362
1363 /// Interleave candidates so forward targets get even label indices (aa, ac, ae...)
1364 /// and backward targets get odd indices (ab, ad, af...), matching Helix's algorithm.
1365 /// This keeps the earliest label assignments close to the cursor in both directions.
1366 fn order_jump_candidates(
1367 candidates: Vec<JumpCandidate>,
1368 cursor_offset: MultiBufferOffset,
1369 ) -> Vec<JumpCandidate> {
1370 let mut forward = Vec::with_capacity(candidates.len());
1371 let mut backward = Vec::new();
1372
1373 for candidate in candidates {
1374 if candidate.word_start < cursor_offset {
1375 backward.push(candidate);
1376 } else {
1377 forward.push(candidate);
1378 }
1379 }
1380
1381 backward.reverse();
1382
1383 let mut ordered_candidates =
1384 Vec::with_capacity((forward.len() + backward.len()).min(HELIX_JUMP_LABEL_LIMIT));
1385 let mut forward_candidates = forward.into_iter();
1386 let mut backward_candidates = backward.into_iter();
1387
1388 loop {
1389 let mut pushed_candidate = false;
1390
1391 if ordered_candidates.len() < HELIX_JUMP_LABEL_LIMIT
1392 && let Some(candidate) = forward_candidates.next()
1393 {
1394 ordered_candidates.push(candidate);
1395 pushed_candidate = true;
1396 }
1397
1398 if ordered_candidates.len() < HELIX_JUMP_LABEL_LIMIT
1399 && let Some(candidate) = backward_candidates.next()
1400 {
1401 ordered_candidates.push(candidate);
1402 pushed_candidate = true;
1403 }
1404
1405 if !pushed_candidate {
1406 break;
1407 }
1408 }
1409
1410 ordered_candidates
1411 }
1412
1413 fn jump_label_for_index(index: usize) -> [char; 2] {
1414 [
1415 HELIX_JUMP_ALPHABET[index / HELIX_JUMP_ALPHABET.len()],
1416 HELIX_JUMP_ALPHABET[index % HELIX_JUMP_ALPHABET.len()],
1417 ]
1418 }
1419
1420 fn is_monospace_jump_font(
1421 text_system: &WindowTextSystem,
1422 font: &Font,
1423 font_size: Pixels,
1424 ) -> bool {
1425 let font_id = text_system.resolve_font(font);
1426 let width_of_char = |ch| {
1427 text_system
1428 .advance(font_id, font_size, ch)
1429 .map(|size| size.width)
1430 .unwrap_or_else(|_| text_system.layout_width(font_id, font_size, ch))
1431 };
1432
1433 let a = width_of_char('i');
1434 let b = width_of_char('w');
1435 let c = width_of_char('0');
1436 let d = width_of_char('1');
1437 let diff_1 = if a > b { a - b } else { b - a };
1438 let diff_2 = if c > d { c - d } else { d - c };
1439 diff_1 <= HELIX_JUMP_MONOSPACE_TOLERANCE && diff_2 <= HELIX_JUMP_MONOSPACE_TOLERANCE
1440 }
1441
1442 /// Fit a jump label over a word in a proportional font.
1443 ///
1444 /// Prefer fitting within the word itself, using available whitespace to the left
1445 /// before consuming trailing whitespace after the word. If the label still cannot
1446 /// fit cleanly, allow a small amount of scaling.
1447 fn fit_proportional_jump_label<F: Fn(&str) -> Pixels>(
1448 buffer: &MultiBufferSnapshot,
1449 candidate: &JumpCandidate,
1450 end_offset: MultiBufferOffset,
1451 label_width: Pixels,
1452 width_of: &F,
1453 ) -> JumpLabelFit {
1454 let fit_budget = Self::jump_label_fit_budget(buffer, candidate, end_offset, width_of);
1455
1456 let mut hidden_prefix = HiddenPrefixFitState::new(candidate.first_two_end);
1457 let min_label_scale = if fit_budget.preserve_full_scale {
1458 1.0
1459 } else {
1460 HELIX_JUMP_MIN_LABEL_SCALE
1461 };
1462
1463 hidden_prefix.extend_to_fit(
1464 buffer,
1465 candidate.word_start,
1466 candidate.word_end,
1467 candidate.word_end,
1468 label_width,
1469 fit_budget.max_left_shift,
1470 min_label_scale,
1471 width_of,
1472 );
1473
1474 if label_width > px(0.0)
1475 && hidden_prefix.needs_more_width(label_width, fit_budget.max_left_shift)
1476 && fit_budget.allowed_trailing_hide_end > candidate.word_end
1477 {
1478 hidden_prefix.extend_to_fit(
1479 buffer,
1480 candidate.word_end,
1481 fit_budget.allowed_trailing_hide_end,
1482 candidate.word_end,
1483 label_width,
1484 fit_budget.max_left_shift,
1485 min_label_scale,
1486 width_of,
1487 );
1488 }
1489
1490 // Jump candidates always contain at least two word characters, and the initial
1491 // scan above always measures through that second character before we read the width.
1492 let hidden_width = hidden_prefix.hidden_width;
1493
1494 let left_shift = if label_width > hidden_width {
1495 (label_width - hidden_width).min(fit_budget.max_left_shift)
1496 } else {
1497 px(0.0)
1498 };
1499
1500 let scale_factor = if label_width > px(0.0) {
1501 let scale = ((hidden_width + left_shift) / label_width).min(1.0);
1502 if scale < 1.0 { scale * 0.99 } else { 1.0 }
1503 } else {
1504 1.0
1505 };
1506
1507 JumpLabelFit {
1508 hide_end_offset: hidden_prefix.hide_end_offset,
1509 left_shift,
1510 scale_factor: if fit_budget.preserve_full_scale {
1511 1.0
1512 } else {
1513 scale_factor
1514 },
1515 }
1516 }
1517
1518 fn jump_label_fit_budget<F: Fn(&str) -> Pixels>(
1519 buffer: &MultiBufferSnapshot,
1520 candidate: &JumpCandidate,
1521 end_offset: MultiBufferOffset,
1522 width_of: &F,
1523 ) -> JumpLabelFitBudget {
1524 let mut left_ws_rev = String::new();
1525 let mut left_ws_count = 0usize;
1526 let mut left_stopped_at_line_break = false;
1527 let mut left_stopped_at_non_ws = false;
1528 let mut left_hit_limit = false;
1529
1530 for ch in buffer.reversed_chars_at(candidate.word_start) {
1531 if ch == '\n' || ch == '\r' {
1532 left_stopped_at_line_break = true;
1533 break;
1534 }
1535
1536 if !ch.is_whitespace() {
1537 left_stopped_at_non_ws = true;
1538 break;
1539 }
1540
1541 left_ws_count += 1;
1542 if left_ws_count > HELIX_JUMP_MAX_LEFT_WS_CHARS {
1543 left_hit_limit = true;
1544 break;
1545 }
1546
1547 left_ws_rev.push(ch);
1548 }
1549
1550 let left_ws: String = left_ws_rev.chars().rev().collect();
1551 let left_ws_width = width_of(&left_ws);
1552 let left_is_indentation =
1553 left_stopped_at_line_break || (!left_stopped_at_non_ws && !left_hit_limit);
1554 // Between tokens leave a small gap so the label doesn't touch the previous word;
1555 // for line-leading indentation the full width is safe.
1556 let min_left_gap = if left_is_indentation {
1557 px(0.0)
1558 } else {
1559 px(2.0)
1560 };
1561 let max_left_shift = (left_ws_width - min_left_gap).max(px(0.0));
1562
1563 let mut allowed_trailing_hide_end = candidate.word_end;
1564 let mut ws_count = 0usize;
1565 let mut last_ws_start = candidate.word_end;
1566 let mut ws_end_offset = candidate.word_end;
1567 let mut next_non_ws = None;
1568 let mut hit_line_break_after_word = false;
1569
1570 let mut ws_scan_offset = candidate.word_end;
1571 'scan: for chunk in buffer.text_for_range(candidate.word_end..end_offset) {
1572 for (idx, ch) in chunk.char_indices() {
1573 let absolute = ws_scan_offset + idx;
1574 if ch == '\n' || ch == '\r' {
1575 hit_line_break_after_word = true;
1576 break 'scan;
1577 }
1578 if !ch.is_whitespace() {
1579 next_non_ws = Some(ch);
1580 break 'scan;
1581 }
1582
1583 ws_count += 1;
1584 last_ws_start = absolute;
1585 ws_end_offset = absolute + ch.len_utf8();
1586 }
1587 ws_scan_offset += chunk.len();
1588 }
1589
1590 let preserve_full_scale = hit_line_break_after_word && next_non_ws.is_none()
1591 || matches!(
1592 buffer.chars_at(candidate.word_end).next(),
1593 None | Some('\n') | Some('\r')
1594 );
1595
1596 if ws_count > 0 {
1597 let next_is_word = match next_non_ws {
1598 Some(ch) => is_jump_word_char(ch),
1599 None => false,
1600 };
1601
1602 if next_is_word {
1603 // Keep at least one whitespace character visible so adjacent labels
1604 // remain visually separated.
1605 if ws_count > 1 {
1606 allowed_trailing_hide_end = last_ws_start;
1607 }
1608 } else {
1609 // Next token is punctuation or end-of-range — safe to hide all whitespace.
1610 allowed_trailing_hide_end = ws_end_offset;
1611 }
1612 }
1613
1614 JumpLabelFitBudget {
1615 max_left_shift,
1616 allowed_trailing_hide_end,
1617 preserve_full_scale,
1618 }
1619 }
1620}
1621
1622const HELIX_JUMP_ALPHABET: &[char; 26] = &[
1623 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
1624 't', 'u', 'v', 'w', 'x', 'y', 'z',
1625];
1626const HELIX_JUMP_LABEL_LIMIT: usize = HELIX_JUMP_ALPHABET.len() * HELIX_JUMP_ALPHABET.len();
1627const HELIX_JUMP_MONOSPACE_TOLERANCE: Pixels = px(0.5);
1628const HELIX_JUMP_MIN_LABEL_SCALE: f32 = 1.0;
1629const HELIX_JUMP_MAX_HIDDEN_CHARS: usize = 16;
1630const HELIX_JUMP_MAX_LEFT_WS_CHARS: usize = 32;
1631
1632fn is_jump_word_char(ch: char) -> bool {
1633 ch == '_' || ch.is_alphanumeric()
1634}
1635
1636/// A word candidate for jump labels, before label assignment.
1637#[derive(Clone)]
1638struct JumpCandidate {
1639 word_start: MultiBufferOffset,
1640 word_end: MultiBufferOffset,
1641 first_two_end: MultiBufferOffset,
1642}
1643
1644struct HelixJumpSkipData {
1645 points: Vec<MultiBufferOffset>,
1646 ranges: Vec<Range<MultiBufferOffset>>,
1647}
1648
1649struct JumpLabelFit {
1650 hide_end_offset: MultiBufferOffset,
1651 left_shift: Pixels,
1652 scale_factor: f32,
1653}
1654
1655struct JumpLabelFitBudget {
1656 max_left_shift: Pixels,
1657 allowed_trailing_hide_end: MultiBufferOffset,
1658 preserve_full_scale: bool,
1659}
1660
1661struct HiddenPrefixFitState {
1662 text: String,
1663 hide_end_offset: MultiBufferOffset,
1664 hidden_width: Pixels,
1665 total_char_count: usize,
1666 word_char_count: usize,
1667}
1668
1669impl JumpLabelFit {
1670 fn monospace(hide_end_offset: MultiBufferOffset) -> Self {
1671 Self {
1672 hide_end_offset,
1673 left_shift: px(0.0),
1674 scale_factor: 1.0,
1675 }
1676 }
1677}
1678
1679impl HiddenPrefixFitState {
1680 fn new(hide_end_offset: MultiBufferOffset) -> Self {
1681 Self {
1682 text: String::new(),
1683 hide_end_offset,
1684 hidden_width: px(0.0),
1685 total_char_count: 0,
1686 word_char_count: 0,
1687 }
1688 }
1689
1690 fn needs_more_width(&self, label_width: Pixels, max_left_shift: Pixels) -> bool {
1691 (self.hidden_width + max_left_shift) / label_width < HELIX_JUMP_MIN_LABEL_SCALE
1692 }
1693
1694 fn extend_to_fit<F: Fn(&str) -> Pixels>(
1695 &mut self,
1696 buffer: &MultiBufferSnapshot,
1697 range_start: MultiBufferOffset,
1698 range_end: MultiBufferOffset,
1699 word_end: MultiBufferOffset,
1700 label_width: Pixels,
1701 max_left_shift: Pixels,
1702 min_label_scale: f32,
1703 width_of: &F,
1704 ) {
1705 let mut offset = range_start;
1706 for chunk in buffer.text_for_range(range_start..range_end) {
1707 for (idx, ch) in chunk.char_indices() {
1708 let absolute = offset + idx;
1709
1710 self.total_char_count += 1;
1711 if self.total_char_count > HELIX_JUMP_MAX_HIDDEN_CHARS {
1712 return;
1713 }
1714
1715 self.text.push(ch);
1716 let end_offset = absolute + ch.len_utf8();
1717
1718 if absolute < word_end && is_jump_word_char(ch) {
1719 self.word_char_count += 1;
1720 }
1721
1722 if self.word_char_count < 2 {
1723 continue;
1724 }
1725
1726 self.hide_end_offset = end_offset;
1727 self.hidden_width = width_of(self.text.as_str());
1728
1729 let effective_width = self.hidden_width + max_left_shift;
1730 let scale_needed = if label_width > px(0.0) {
1731 (effective_width / label_width).min(1.0)
1732 } else {
1733 1.0
1734 };
1735
1736 if scale_needed >= min_label_scale {
1737 return;
1738 }
1739 }
1740 offset += chunk.len();
1741 }
1742 }
1743}
1744
1745#[derive(Default)]
1746struct HelixJumpUiData {
1747 labels: Vec<HelixJumpLabel>,
1748 overlays: Vec<NavigationTargetOverlay>,
1749}
1750
1751#[cfg(test)]
1752mod test {
1753 use futures::StreamExt;
1754 use std::{fmt::Write, time::Duration};
1755
1756 use editor::{HighlightKey, MultiBufferOffset};
1757 use gpui::{KeyBinding, UpdateGlobal, VisualTestContext};
1758 use indoc::indoc;
1759 use language::{CursorShape, Point};
1760 use project::FakeFs;
1761 use search::{ProjectSearchView, project_search};
1762 use serde_json::json;
1763 use settings::{SettingsStore, ThemeColorsContent, ThemeStyleContent};
1764 use theme::ActiveTheme as _;
1765 use util::path;
1766 use workspace::{DeploySearch, MultiWorkspace};
1767
1768 use super::{HELIX_JUMP_LABEL_LIMIT, HelixJumpToWord};
1769 use crate::{
1770 HELIX_JUMP_OVERLAY_KEY, Vim, VimAddon,
1771 state::{Mode, Operator},
1772 test::VimTestContext,
1773 };
1774
1775 fn active_helix_jump_labels(cx: &mut VimTestContext) -> Vec<(String, String)> {
1776 cx.update_editor(|editor, window, cx| {
1777 let labels = match editor
1778 .addon::<VimAddon>()
1779 .unwrap()
1780 .entity
1781 .read(cx)
1782 .operator_stack
1783 .last()
1784 .cloned()
1785 {
1786 Some(Operator::HelixJump { labels, .. }) => labels,
1787 other => panic!("expected active HelixJump operator, got {other:?}"),
1788 };
1789
1790 let snapshot = editor.snapshot(window, cx);
1791 let buffer_snapshot = snapshot.display_snapshot.buffer_snapshot();
1792
1793 labels
1794 .into_iter()
1795 .map(|label| {
1796 let jump_label = label.label.iter().collect::<String>();
1797 let word = buffer_snapshot
1798 .text_for_range(label.range)
1799 .collect::<String>();
1800 (jump_label, word)
1801 })
1802 .collect()
1803 })
1804 }
1805
1806 fn helix_jump_label_for_word(cx: &mut VimTestContext, target_word: &str) -> String {
1807 active_helix_jump_labels(cx)
1808 .into_iter()
1809 .find_map(|(label, word)| (word == target_word).then_some(label))
1810 .unwrap_or_else(|| {
1811 let mut message = String::new();
1812 let labels = active_helix_jump_labels(cx);
1813 let _ = write!(
1814 &mut message,
1815 "expected jump label for word {target_word:?}, available labels: {labels:?}"
1816 );
1817 panic!("{message}");
1818 })
1819 }
1820
1821 fn jump_to_word(cx: &mut VimTestContext, target_word: &str) {
1822 jump_to_word_with_keystrokes(cx, "g w", target_word);
1823 }
1824
1825 fn jump_to_word_with_keystrokes(cx: &mut VimTestContext, keystrokes: &str, target_word: &str) {
1826 cx.simulate_keystrokes(keystrokes);
1827
1828 let label = helix_jump_label_for_word(cx, target_word);
1829
1830 let mut chars = label.chars();
1831 let first = chars.next().expect("jump labels are two characters long");
1832 let second = chars.next().expect("jump labels are two characters long");
1833 cx.simulate_keystrokes(&format!("{first} {second}"));
1834 }
1835
1836 fn bind_vim_jump_to_word(cx: &mut VimTestContext, keystrokes: &'static str) {
1837 cx.update(|_, cx| {
1838 cx.bind_keys([KeyBinding::new(
1839 keystrokes,
1840 HelixJumpToWord,
1841 Some("vim_mode == normal || vim_mode == visual"),
1842 )])
1843 });
1844 }
1845
1846 fn active_helix_jump_overlay_counts(cx: &mut VimTestContext) -> (usize, usize) {
1847 let covered_text_range_count = cx.update_editor(|editor, window, cx| {
1848 let snapshot = editor.snapshot(window, cx);
1849 snapshot
1850 .text_highlight_ranges(HighlightKey::NavigationOverlay(HELIX_JUMP_OVERLAY_KEY))
1851 .map(|ranges| ranges.as_ref().clone().1.len())
1852 .unwrap_or_default()
1853 });
1854 let label_count = match cx.active_operator() {
1855 Some(Operator::HelixJump { labels, .. }) => labels.len(),
1856 _ => 0,
1857 };
1858
1859 (covered_text_range_count, label_count)
1860 }
1861
1862 fn assert_helix_jump_cleared(cx: &mut VimTestContext, expected_overlay_counts: (usize, usize)) {
1863 assert_eq!(cx.active_operator(), None);
1864 assert_eq!(
1865 active_helix_jump_overlay_counts(cx),
1866 expected_overlay_counts,
1867 "expected Helix jump UI to be fully cleared"
1868 );
1869 }
1870
1871 fn helix_jump_labels_for_full_buffer(cx: &mut VimTestContext) -> Vec<(String, String)> {
1872 cx.update_editor(|editor, window, cx| {
1873 let snapshot = editor.snapshot(window, cx);
1874 let display_snapshot = &snapshot.display_snapshot;
1875 let buffer_snapshot = display_snapshot.buffer_snapshot();
1876 let selections = editor.selections.all::<Point>(display_snapshot);
1877 let skip_data = Vim::selection_skip_offsets(buffer_snapshot, &selections, false);
1878 let cursor_offset = selections
1879 .first()
1880 .map(|selection| buffer_snapshot.point_to_offset(selection.head()))
1881 .unwrap_or(MultiBufferOffset(0));
1882 let style = editor.style(cx);
1883 let font = style.text.font();
1884 let font_size = style.text.font_size.to_pixels(window.rem_size());
1885 let label_color = cx.theme().colors().vim_helix_jump_label_foreground;
1886 let data = Vim::build_helix_jump_ui_data(
1887 buffer_snapshot,
1888 MultiBufferOffset(0),
1889 buffer_snapshot.len(),
1890 cursor_offset,
1891 label_color,
1892 &skip_data,
1893 window.text_system(),
1894 font,
1895 font_size,
1896 );
1897
1898 data.labels
1899 .into_iter()
1900 .map(|label| {
1901 let jump_label = label.label.iter().collect::<String>();
1902 let word = buffer_snapshot
1903 .text_for_range(label.range)
1904 .collect::<String>();
1905 (jump_label, word)
1906 })
1907 .collect()
1908 })
1909 }
1910
1911 #[gpui::test]
1912 async fn test_word_motions(cx: &mut gpui::TestAppContext) {
1913 let mut cx = VimTestContext::new(cx, true).await;
1914 cx.enable_helix();
1915 // «
1916 // ˇ
1917 // »
1918 cx.set_state(
1919 indoc! {"
1920 Th«e quiˇ»ck brown
1921 fox jumps over
1922 the lazy dog."},
1923 Mode::HelixNormal,
1924 );
1925
1926 cx.simulate_keystrokes("w");
1927
1928 cx.assert_state(
1929 indoc! {"
1930 The qu«ick ˇ»brown
1931 fox jumps over
1932 the lazy dog."},
1933 Mode::HelixNormal,
1934 );
1935
1936 cx.simulate_keystrokes("w");
1937
1938 cx.assert_state(
1939 indoc! {"
1940 The quick «brownˇ»
1941 fox jumps over
1942 the lazy dog."},
1943 Mode::HelixNormal,
1944 );
1945
1946 cx.simulate_keystrokes("2 b");
1947
1948 cx.assert_state(
1949 indoc! {"
1950 The «ˇquick »brown
1951 fox jumps over
1952 the lazy dog."},
1953 Mode::HelixNormal,
1954 );
1955
1956 cx.simulate_keystrokes("down e up");
1957
1958 cx.assert_state(
1959 indoc! {"
1960 The quicˇk brown
1961 fox jumps over
1962 the lazy dog."},
1963 Mode::HelixNormal,
1964 );
1965
1966 cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
1967
1968 cx.simulate_keystroke("b");
1969
1970 cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
1971 }
1972
1973 #[gpui::test]
1974 async fn test_next_subword_start(cx: &mut gpui::TestAppContext) {
1975 let mut cx = VimTestContext::new(cx, true).await;
1976 cx.enable_helix();
1977
1978 // Setup custom keybindings for subword motions so we can use the bindings
1979 // in `simulate_keystroke`.
1980 cx.update(|_window, cx| {
1981 cx.bind_keys([KeyBinding::new(
1982 "w",
1983 crate::motion::NextSubwordStart {
1984 ignore_punctuation: false,
1985 },
1986 None,
1987 )]);
1988 });
1989
1990 cx.set_state("ˇfoo.bar", Mode::HelixNormal);
1991 cx.simulate_keystroke("w");
1992 cx.assert_state("«fooˇ».bar", Mode::HelixNormal);
1993 cx.simulate_keystroke("w");
1994 cx.assert_state("foo«.ˇ»bar", Mode::HelixNormal);
1995 cx.simulate_keystroke("w");
1996 cx.assert_state("foo.«barˇ»", Mode::HelixNormal);
1997
1998 cx.set_state("ˇfoo(bar)", Mode::HelixNormal);
1999 cx.simulate_keystroke("w");
2000 cx.assert_state("«fooˇ»(bar)", Mode::HelixNormal);
2001 cx.simulate_keystroke("w");
2002 cx.assert_state("foo«(ˇ»bar)", Mode::HelixNormal);
2003 cx.simulate_keystroke("w");
2004 cx.assert_state("foo(«barˇ»)", Mode::HelixNormal);
2005
2006 cx.set_state("ˇfoo_bar_baz", Mode::HelixNormal);
2007 cx.simulate_keystroke("w");
2008 cx.assert_state("«foo_ˇ»bar_baz", Mode::HelixNormal);
2009 cx.simulate_keystroke("w");
2010 cx.assert_state("foo_«bar_ˇ»baz", Mode::HelixNormal);
2011
2012 cx.set_state("ˇfooBarBaz", Mode::HelixNormal);
2013 cx.simulate_keystroke("w");
2014 cx.assert_state("«fooˇ»BarBaz", Mode::HelixNormal);
2015 cx.simulate_keystroke("w");
2016 cx.assert_state("foo«Barˇ»Baz", Mode::HelixNormal);
2017
2018 cx.set_state("ˇfoo;bar", Mode::HelixNormal);
2019 cx.simulate_keystroke("w");
2020 cx.assert_state("«fooˇ»;bar", Mode::HelixNormal);
2021 cx.simulate_keystroke("w");
2022 cx.assert_state("foo«;ˇ»bar", Mode::HelixNormal);
2023 cx.simulate_keystroke("w");
2024 cx.assert_state("foo;«barˇ»", Mode::HelixNormal);
2025
2026 cx.set_state("ˇ<?php\n\n$someVariable = 2;", Mode::HelixNormal);
2027 cx.simulate_keystroke("w");
2028 cx.assert_state("«<?ˇ»php\n\n$someVariable = 2;", Mode::HelixNormal);
2029 cx.simulate_keystroke("w");
2030 cx.assert_state("<?«phpˇ»\n\n$someVariable = 2;", Mode::HelixNormal);
2031 cx.simulate_keystroke("w");
2032 cx.assert_state("<?php\n\n«$ˇ»someVariable = 2;", Mode::HelixNormal);
2033 cx.simulate_keystroke("w");
2034 cx.assert_state("<?php\n\n$«someˇ»Variable = 2;", Mode::HelixNormal);
2035 cx.simulate_keystroke("w");
2036 cx.assert_state("<?php\n\n$some«Variable ˇ»= 2;", Mode::HelixNormal);
2037 cx.simulate_keystroke("w");
2038 cx.assert_state("<?php\n\n$someVariable «= ˇ»2;", Mode::HelixNormal);
2039 cx.simulate_keystroke("w");
2040 cx.assert_state("<?php\n\n$someVariable = «2ˇ»;", Mode::HelixNormal);
2041 cx.simulate_keystroke("w");
2042 cx.assert_state("<?php\n\n$someVariable = 2«;ˇ»", Mode::HelixNormal);
2043 }
2044
2045 #[gpui::test]
2046 async fn test_next_subword_end(cx: &mut gpui::TestAppContext) {
2047 let mut cx = VimTestContext::new(cx, true).await;
2048 cx.enable_helix();
2049
2050 // Setup custom keybindings for subword motions so we can use the bindings
2051 // in `simulate_keystroke`.
2052 cx.update(|_window, cx| {
2053 cx.bind_keys([KeyBinding::new(
2054 "e",
2055 crate::motion::NextSubwordEnd {
2056 ignore_punctuation: false,
2057 },
2058 None,
2059 )]);
2060 });
2061
2062 cx.set_state("ˇfoo.bar", Mode::HelixNormal);
2063 cx.simulate_keystroke("e");
2064 cx.assert_state("«fooˇ».bar", Mode::HelixNormal);
2065 cx.simulate_keystroke("e");
2066 cx.assert_state("foo«.ˇ»bar", Mode::HelixNormal);
2067 cx.simulate_keystroke("e");
2068 cx.assert_state("foo.«barˇ»", Mode::HelixNormal);
2069
2070 cx.set_state("ˇfoo(bar)", Mode::HelixNormal);
2071 cx.simulate_keystroke("e");
2072 cx.assert_state("«fooˇ»(bar)", Mode::HelixNormal);
2073 cx.simulate_keystroke("e");
2074 cx.assert_state("foo«(ˇ»bar)", Mode::HelixNormal);
2075 cx.simulate_keystroke("e");
2076 cx.assert_state("foo(«barˇ»)", Mode::HelixNormal);
2077
2078 cx.set_state("ˇfoo_bar_baz", Mode::HelixNormal);
2079 cx.simulate_keystroke("e");
2080 cx.assert_state("«fooˇ»_bar_baz", Mode::HelixNormal);
2081 cx.simulate_keystroke("e");
2082 cx.assert_state("foo«_barˇ»_baz", Mode::HelixNormal);
2083 cx.simulate_keystroke("e");
2084 cx.assert_state("foo_bar«_bazˇ»", Mode::HelixNormal);
2085
2086 cx.set_state("ˇfooBarBaz", Mode::HelixNormal);
2087 cx.simulate_keystroke("e");
2088 cx.assert_state("«fooˇ»BarBaz", Mode::HelixNormal);
2089 cx.simulate_keystroke("e");
2090 cx.assert_state("foo«Barˇ»Baz", Mode::HelixNormal);
2091 cx.simulate_keystroke("e");
2092 cx.assert_state("fooBar«Bazˇ»", Mode::HelixNormal);
2093
2094 cx.set_state("ˇfoo;bar", Mode::HelixNormal);
2095 cx.simulate_keystroke("e");
2096 cx.assert_state("«fooˇ»;bar", Mode::HelixNormal);
2097 cx.simulate_keystroke("e");
2098 cx.assert_state("foo«;ˇ»bar", Mode::HelixNormal);
2099 cx.simulate_keystroke("e");
2100 cx.assert_state("foo;«barˇ»", Mode::HelixNormal);
2101
2102 cx.set_state("ˇ<?php\n\n$someVariable = 2;", Mode::HelixNormal);
2103 cx.simulate_keystroke("e");
2104 cx.assert_state("«<?ˇ»php\n\n$someVariable = 2;", Mode::HelixNormal);
2105 cx.simulate_keystroke("e");
2106 cx.assert_state("<?«phpˇ»\n\n$someVariable = 2;", Mode::HelixNormal);
2107 cx.simulate_keystroke("e");
2108 cx.assert_state("<?php\n\n«$ˇ»someVariable = 2;", Mode::HelixNormal);
2109 cx.simulate_keystroke("e");
2110 cx.assert_state("<?php\n\n$«someˇ»Variable = 2;", Mode::HelixNormal);
2111 cx.simulate_keystroke("e");
2112 cx.assert_state("<?php\n\n$some«Variableˇ» = 2;", Mode::HelixNormal);
2113 cx.simulate_keystroke("e");
2114 cx.assert_state("<?php\n\n$someVariable« =ˇ» 2;", Mode::HelixNormal);
2115 cx.simulate_keystroke("e");
2116 cx.assert_state("<?php\n\n$someVariable =« 2ˇ»;", Mode::HelixNormal);
2117 cx.simulate_keystroke("e");
2118 cx.assert_state("<?php\n\n$someVariable = 2«;ˇ»", Mode::HelixNormal);
2119 }
2120
2121 #[gpui::test]
2122 async fn test_previous_subword_start(cx: &mut gpui::TestAppContext) {
2123 let mut cx = VimTestContext::new(cx, true).await;
2124 cx.enable_helix();
2125
2126 // Setup custom keybindings for subword motions so we can use the bindings
2127 // in `simulate_keystroke`.
2128 cx.update(|_window, cx| {
2129 cx.bind_keys([KeyBinding::new(
2130 "b",
2131 crate::motion::PreviousSubwordStart {
2132 ignore_punctuation: false,
2133 },
2134 None,
2135 )]);
2136 });
2137
2138 cx.set_state("foo.barˇ", Mode::HelixNormal);
2139 cx.simulate_keystroke("b");
2140 cx.assert_state("foo.«ˇbar»", Mode::HelixNormal);
2141 cx.simulate_keystroke("b");
2142 cx.assert_state("foo«ˇ.»bar", Mode::HelixNormal);
2143 cx.simulate_keystroke("b");
2144 cx.assert_state("«ˇfoo».bar", Mode::HelixNormal);
2145
2146 cx.set_state("foo(bar)ˇ", Mode::HelixNormal);
2147 cx.simulate_keystroke("b");
2148 cx.assert_state("foo(bar«ˇ)»", Mode::HelixNormal);
2149 cx.simulate_keystroke("b");
2150 cx.assert_state("foo(«ˇbar»)", Mode::HelixNormal);
2151 cx.simulate_keystroke("b");
2152 cx.assert_state("foo«ˇ(»bar)", Mode::HelixNormal);
2153 cx.simulate_keystroke("b");
2154 cx.assert_state("«ˇfoo»(bar)", Mode::HelixNormal);
2155
2156 cx.set_state("foo_bar_bazˇ", Mode::HelixNormal);
2157 cx.simulate_keystroke("b");
2158 cx.assert_state("foo_bar_«ˇbaz»", Mode::HelixNormal);
2159 cx.simulate_keystroke("b");
2160 cx.assert_state("foo_«ˇbar_»baz", Mode::HelixNormal);
2161 cx.simulate_keystroke("b");
2162 cx.assert_state("«ˇfoo_»bar_baz", Mode::HelixNormal);
2163
2164 cx.set_state("foo;barˇ", Mode::HelixNormal);
2165 cx.simulate_keystroke("b");
2166 cx.assert_state("foo;«ˇbar»", Mode::HelixNormal);
2167 cx.simulate_keystroke("b");
2168 cx.assert_state("foo«ˇ;»bar", Mode::HelixNormal);
2169 cx.simulate_keystroke("b");
2170 cx.assert_state("«ˇfoo»;bar", Mode::HelixNormal);
2171
2172 cx.set_state("<?php\n\n$someVariable = 2;ˇ", Mode::HelixNormal);
2173 cx.simulate_keystroke("b");
2174 cx.assert_state("<?php\n\n$someVariable = 2«ˇ;»", Mode::HelixNormal);
2175 cx.simulate_keystroke("b");
2176 cx.assert_state("<?php\n\n$someVariable = «ˇ2»;", Mode::HelixNormal);
2177 cx.simulate_keystroke("b");
2178 cx.assert_state("<?php\n\n$someVariable «ˇ= »2;", Mode::HelixNormal);
2179 cx.simulate_keystroke("b");
2180 cx.assert_state("<?php\n\n$some«ˇVariable »= 2;", Mode::HelixNormal);
2181 cx.simulate_keystroke("b");
2182 cx.assert_state("<?php\n\n$«ˇsome»Variable = 2;", Mode::HelixNormal);
2183 cx.simulate_keystroke("b");
2184 cx.assert_state("<?php\n\n«ˇ$»someVariable = 2;", Mode::HelixNormal);
2185 cx.simulate_keystroke("b");
2186 cx.assert_state("<?«ˇphp»\n\n$someVariable = 2;", Mode::HelixNormal);
2187 cx.simulate_keystroke("b");
2188 cx.assert_state("«ˇ<?»php\n\n$someVariable = 2;", Mode::HelixNormal);
2189
2190 cx.set_state("fooBarBazˇ", Mode::HelixNormal);
2191 cx.simulate_keystroke("b");
2192 cx.assert_state("fooBar«ˇBaz»", Mode::HelixNormal);
2193 cx.simulate_keystroke("b");
2194 cx.assert_state("foo«ˇBar»Baz", Mode::HelixNormal);
2195 cx.simulate_keystroke("b");
2196 cx.assert_state("«ˇfoo»BarBaz", Mode::HelixNormal);
2197 }
2198
2199 #[gpui::test]
2200 async fn test_previous_subword_end(cx: &mut gpui::TestAppContext) {
2201 let mut cx = VimTestContext::new(cx, true).await;
2202 cx.enable_helix();
2203
2204 // Setup custom keybindings for subword motions so we can use the bindings
2205 // in `simulate_keystrokes`.
2206 cx.update(|_window, cx| {
2207 cx.bind_keys([KeyBinding::new(
2208 "g e",
2209 crate::motion::PreviousSubwordEnd {
2210 ignore_punctuation: false,
2211 },
2212 None,
2213 )]);
2214 });
2215
2216 cx.set_state("foo.barˇ", Mode::HelixNormal);
2217 cx.simulate_keystrokes("g e");
2218 cx.assert_state("foo.«ˇbar»", Mode::HelixNormal);
2219 cx.simulate_keystrokes("g e");
2220 cx.assert_state("foo«ˇ.»bar", Mode::HelixNormal);
2221 cx.simulate_keystrokes("g e");
2222 cx.assert_state("«ˇfoo».bar", Mode::HelixNormal);
2223
2224 cx.set_state("foo(bar)ˇ", Mode::HelixNormal);
2225 cx.simulate_keystrokes("g e");
2226 cx.assert_state("foo(bar«ˇ)»", Mode::HelixNormal);
2227 cx.simulate_keystrokes("g e");
2228 cx.assert_state("foo(«ˇbar»)", Mode::HelixNormal);
2229 cx.simulate_keystrokes("g e");
2230 cx.assert_state("foo«ˇ(»bar)", Mode::HelixNormal);
2231 cx.simulate_keystrokes("g e");
2232 cx.assert_state("«ˇfoo»(bar)", Mode::HelixNormal);
2233
2234 cx.set_state("foo_bar_bazˇ", Mode::HelixNormal);
2235 cx.simulate_keystrokes("g e");
2236 cx.assert_state("foo_bar«ˇ_baz»", Mode::HelixNormal);
2237 cx.simulate_keystrokes("g e");
2238 cx.assert_state("foo«ˇ_bar»_baz", Mode::HelixNormal);
2239 cx.simulate_keystrokes("g e");
2240 cx.assert_state("«ˇfoo»_bar_baz", Mode::HelixNormal);
2241
2242 cx.set_state("foo;barˇ", Mode::HelixNormal);
2243 cx.simulate_keystrokes("g e");
2244 cx.assert_state("foo;«ˇbar»", Mode::HelixNormal);
2245 cx.simulate_keystrokes("g e");
2246 cx.assert_state("foo«ˇ;»bar", Mode::HelixNormal);
2247 cx.simulate_keystrokes("g e");
2248 cx.assert_state("«ˇfoo»;bar", Mode::HelixNormal);
2249
2250 cx.set_state("<?php\n\n$someVariable = 2;ˇ", Mode::HelixNormal);
2251 cx.simulate_keystrokes("g e");
2252 cx.assert_state("<?php\n\n$someVariable = 2«ˇ;»", Mode::HelixNormal);
2253 cx.simulate_keystrokes("g e");
2254 cx.assert_state("<?php\n\n$someVariable =«ˇ 2»;", Mode::HelixNormal);
2255 cx.simulate_keystrokes("g e");
2256 cx.assert_state("<?php\n\n$someVariable«ˇ =» 2;", Mode::HelixNormal);
2257 cx.simulate_keystrokes("g e");
2258 cx.assert_state("<?php\n\n$some«ˇVariable» = 2;", Mode::HelixNormal);
2259 cx.simulate_keystrokes("g e");
2260 cx.assert_state("<?php\n\n$«ˇsome»Variable = 2;", Mode::HelixNormal);
2261 cx.simulate_keystrokes("g e");
2262 cx.assert_state("<?php\n\n«ˇ$»someVariable = 2;", Mode::HelixNormal);
2263 cx.simulate_keystrokes("g e");
2264 cx.assert_state("<?«ˇphp»\n\n$someVariable = 2;", Mode::HelixNormal);
2265 cx.simulate_keystrokes("g e");
2266 cx.assert_state("«ˇ<?»php\n\n$someVariable = 2;", Mode::HelixNormal);
2267
2268 cx.set_state("fooBarBazˇ", Mode::HelixNormal);
2269 cx.simulate_keystrokes("g e");
2270 cx.assert_state("fooBar«ˇBaz»", Mode::HelixNormal);
2271 cx.simulate_keystrokes("g e");
2272 cx.assert_state("foo«ˇBar»Baz", Mode::HelixNormal);
2273 cx.simulate_keystrokes("g e");
2274 cx.assert_state("«ˇfoo»BarBaz", Mode::HelixNormal);
2275 }
2276
2277 #[gpui::test]
2278 async fn test_delete(cx: &mut gpui::TestAppContext) {
2279 let mut cx = VimTestContext::new(cx, true).await;
2280 cx.enable_helix();
2281
2282 // test delete a selection
2283 cx.set_state(
2284 indoc! {"
2285 The qu«ick ˇ»brown
2286 fox jumps over
2287 the lazy dog."},
2288 Mode::HelixNormal,
2289 );
2290
2291 cx.simulate_keystrokes("d");
2292
2293 cx.assert_state(
2294 indoc! {"
2295 The quˇbrown
2296 fox jumps over
2297 the lazy dog."},
2298 Mode::HelixNormal,
2299 );
2300
2301 // test deleting a single character
2302 cx.simulate_keystrokes("d");
2303
2304 cx.assert_state(
2305 indoc! {"
2306 The quˇrown
2307 fox jumps over
2308 the lazy dog."},
2309 Mode::HelixNormal,
2310 );
2311 }
2312
2313 #[gpui::test]
2314 async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
2315 let mut cx = VimTestContext::new(cx, true).await;
2316
2317 cx.set_state(
2318 indoc! {"
2319 The quick brownˇ
2320 fox jumps over
2321 the lazy dog."},
2322 Mode::HelixNormal,
2323 );
2324
2325 cx.simulate_keystrokes("d");
2326
2327 cx.assert_state(
2328 indoc! {"
2329 The quick brownˇfox jumps over
2330 the lazy dog."},
2331 Mode::HelixNormal,
2332 );
2333 }
2334
2335 // Deleting a selection that ends at the last non-newline character should
2336 // leave the cursor on the newline (matching Helix), not clamp it onto the
2337 // character to the left of the selection.
2338 #[gpui::test]
2339 async fn test_delete_to_end_of_line_keeps_cursor_on_newline(cx: &mut gpui::TestAppContext) {
2340 let mut cx = VimTestContext::new(cx, true).await;
2341 cx.enable_helix();
2342
2343 cx.set_state(
2344 indoc! {"
2345 ab«cdefgˇ»
2346 hij"},
2347 Mode::HelixNormal,
2348 );
2349
2350 cx.simulate_keystrokes("d");
2351
2352 cx.assert_state(
2353 indoc! {"
2354 abˇ
2355 hij"},
2356 Mode::HelixNormal,
2357 );
2358 }
2359
2360 // #[gpui::test]
2361 // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
2362 // let mut cx = VimTestContext::new(cx, true).await;
2363
2364 // cx.set_state(
2365 // indoc! {"
2366 // The quick brown
2367 // fox jumps over
2368 // the lazy dog.ˇ"},
2369 // Mode::HelixNormal,
2370 // );
2371
2372 // cx.simulate_keystrokes("d");
2373
2374 // cx.assert_state(
2375 // indoc! {"
2376 // The quick brown
2377 // fox jumps over
2378 // the lazy dog.ˇ"},
2379 // Mode::HelixNormal,
2380 // );
2381 // }
2382
2383 #[gpui::test]
2384 async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
2385 let mut cx = VimTestContext::new(cx, true).await;
2386 cx.enable_helix();
2387
2388 cx.set_state(
2389 indoc! {"
2390 The quˇick brown
2391 fox jumps over
2392 the lazy dog."},
2393 Mode::HelixNormal,
2394 );
2395
2396 cx.simulate_keystrokes("f z");
2397
2398 cx.assert_state(
2399 indoc! {"
2400 The qu«ick brown
2401 fox jumps over
2402 the lazˇ»y dog."},
2403 Mode::HelixNormal,
2404 );
2405
2406 cx.simulate_keystrokes("F e F e");
2407
2408 cx.assert_state(
2409 indoc! {"
2410 The quick brown
2411 fox jumps ov«ˇer
2412 the» lazy dog."},
2413 Mode::HelixNormal,
2414 );
2415
2416 cx.simulate_keystrokes("e 2 F e");
2417
2418 cx.assert_state(
2419 indoc! {"
2420 Th«ˇe quick brown
2421 fox jumps over»
2422 the lazy dog."},
2423 Mode::HelixNormal,
2424 );
2425
2426 cx.simulate_keystrokes("t r t r");
2427
2428 cx.assert_state(
2429 indoc! {"
2430 The quick «brown
2431 fox jumps oveˇ»r
2432 the lazy dog."},
2433 Mode::HelixNormal,
2434 );
2435 }
2436
2437 #[gpui::test]
2438 async fn test_newline_char(cx: &mut gpui::TestAppContext) {
2439 let mut cx = VimTestContext::new(cx, true).await;
2440 cx.enable_helix();
2441
2442 cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
2443
2444 cx.simulate_keystroke("w");
2445
2446 cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
2447
2448 cx.set_state("aa«\nˇ»", Mode::HelixNormal);
2449
2450 cx.simulate_keystroke("b");
2451
2452 cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
2453 }
2454
2455 #[gpui::test]
2456 async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
2457 let mut cx = VimTestContext::new(cx, true).await;
2458 cx.enable_helix();
2459 cx.set_state(
2460 indoc! {"
2461 «The ˇ»quick brown
2462 fox jumps over
2463 the lazy dog."},
2464 Mode::HelixNormal,
2465 );
2466
2467 cx.simulate_keystrokes("i");
2468
2469 cx.assert_state(
2470 indoc! {"
2471 ˇThe quick brown
2472 fox jumps over
2473 the lazy dog."},
2474 Mode::Insert,
2475 );
2476 }
2477
2478 #[gpui::test]
2479 async fn test_append(cx: &mut gpui::TestAppContext) {
2480 let mut cx = VimTestContext::new(cx, true).await;
2481 cx.enable_helix();
2482 // test from the end of the selection
2483 cx.set_state(
2484 indoc! {"
2485 «Theˇ» quick brown
2486 fox jumps over
2487 the lazy dog."},
2488 Mode::HelixNormal,
2489 );
2490
2491 cx.simulate_keystrokes("a");
2492
2493 cx.assert_state(
2494 indoc! {"
2495 Theˇ quick brown
2496 fox jumps over
2497 the lazy dog."},
2498 Mode::Insert,
2499 );
2500
2501 // test from the beginning of the selection
2502 cx.set_state(
2503 indoc! {"
2504 «ˇThe» quick brown
2505 fox jumps over
2506 the lazy dog."},
2507 Mode::HelixNormal,
2508 );
2509
2510 cx.simulate_keystrokes("a");
2511
2512 cx.assert_state(
2513 indoc! {"
2514 Theˇ quick brown
2515 fox jumps over
2516 the lazy dog."},
2517 Mode::Insert,
2518 );
2519 }
2520
2521 #[gpui::test]
2522 async fn test_replace(cx: &mut gpui::TestAppContext) {
2523 let mut cx = VimTestContext::new(cx, true).await;
2524 cx.enable_helix();
2525
2526 // No selection (single character)
2527 cx.set_state("ˇaa", Mode::HelixNormal);
2528
2529 cx.simulate_keystrokes("r x");
2530
2531 cx.assert_state("ˇxa", Mode::HelixNormal);
2532
2533 // Cursor at the beginning
2534 cx.set_state("«ˇaa»", Mode::HelixNormal);
2535
2536 cx.simulate_keystrokes("r x");
2537
2538 cx.assert_state("«ˇxx»", Mode::HelixNormal);
2539
2540 // Cursor at the end
2541 cx.set_state("«aaˇ»", Mode::HelixNormal);
2542
2543 cx.simulate_keystrokes("r x");
2544
2545 cx.assert_state("«xxˇ»", Mode::HelixNormal);
2546
2547 cx.set_state("«aaˇ»", Mode::HelixSelect);
2548
2549 cx.simulate_keystrokes("r x");
2550
2551 cx.assert_state("«xxˇ»", Mode::HelixNormal);
2552 }
2553
2554 #[gpui::test]
2555 async fn test_replace_with_crlf(cx: &mut gpui::TestAppContext) {
2556 let mut cx = VimTestContext::new(cx, true).await;
2557 cx.enable_helix();
2558 cx.set_state("«xˇ»z", Mode::HelixNormal);
2559
2560 let vim =
2561 cx.update_editor(|editor, _window, _cx| editor.addon::<VimAddon>().cloned().unwrap());
2562 cx.update(|window, cx| {
2563 vim.entity.update(cx, |vim, cx| {
2564 vim.helix_replace("a\r\nb", window, cx);
2565 });
2566 });
2567
2568 cx.assert_state("«a\nbˇ»z", Mode::HelixNormal);
2569 }
2570
2571 #[gpui::test]
2572 async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
2573 let mut cx = VimTestContext::new(cx, true).await;
2574 cx.enable_helix();
2575
2576 // Test yanking current character with no selection
2577 cx.set_state("hello ˇworld", Mode::HelixNormal);
2578 cx.simulate_keystrokes("y");
2579
2580 // Test cursor remains at the same position after yanking single character
2581 cx.assert_state("hello ˇworld", Mode::HelixNormal);
2582 cx.shared_clipboard().assert_eq("w");
2583
2584 // Move cursor and yank another character
2585 cx.simulate_keystrokes("l");
2586 cx.simulate_keystrokes("y");
2587 cx.shared_clipboard().assert_eq("o");
2588
2589 // Test yanking with existing selection
2590 cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
2591 cx.simulate_keystrokes("y");
2592 cx.shared_clipboard().assert_eq("worl");
2593 cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
2594
2595 // Test yanking in select mode character by character
2596 cx.set_state("hello ˇworld", Mode::HelixNormal);
2597 cx.simulate_keystroke("v");
2598 cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
2599 cx.simulate_keystroke("y");
2600 cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
2601 cx.shared_clipboard().assert_eq("w");
2602 }
2603
2604 #[gpui::test]
2605 async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
2606 let mut cx = VimTestContext::new(cx, true).await;
2607 cx.enable_helix();
2608
2609 // First copy some text to clipboard
2610 cx.set_state("«hello worldˇ»", Mode::HelixNormal);
2611 cx.simulate_keystrokes("y");
2612
2613 // Test paste with shift-r on single cursor
2614 cx.set_state("foo ˇbar", Mode::HelixNormal);
2615 cx.simulate_keystrokes("shift-r");
2616
2617 cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
2618
2619 // Test paste with shift-r on selection
2620 cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
2621 cx.simulate_keystrokes("shift-r");
2622
2623 cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
2624 }
2625
2626 #[gpui::test]
2627 async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
2628 let mut cx = VimTestContext::new(cx, true).await;
2629
2630 assert_eq!(cx.mode(), Mode::Normal);
2631 cx.enable_helix();
2632
2633 cx.simulate_keystrokes("v");
2634 assert_eq!(cx.mode(), Mode::HelixSelect);
2635 cx.simulate_keystrokes("escape");
2636 assert_eq!(cx.mode(), Mode::HelixNormal);
2637 }
2638
2639 #[gpui::test]
2640 async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
2641 let mut cx = VimTestContext::new(cx, true).await;
2642 cx.enable_helix();
2643
2644 // Make a modification at a specific location
2645 cx.set_state("ˇhello", Mode::HelixNormal);
2646 assert_eq!(cx.mode(), Mode::HelixNormal);
2647 cx.simulate_keystrokes("i");
2648 assert_eq!(cx.mode(), Mode::Insert);
2649 cx.simulate_keystrokes("escape");
2650 assert_eq!(cx.mode(), Mode::HelixNormal);
2651 }
2652
2653 #[gpui::test]
2654 async fn test_helix_select_append(cx: &mut gpui::TestAppContext) {
2655 let mut cx = VimTestContext::new(cx, true).await;
2656 cx.enable_helix();
2657
2658 cx.set_state("aˇbcd", Mode::HelixNormal);
2659 cx.simulate_keystrokes("v a");
2660 cx.assert_state("abˇcd", Mode::Insert);
2661 }
2662
2663 #[gpui::test]
2664 async fn test_helix_select_move_to_trailing_newline(cx: &mut gpui::TestAppContext) {
2665 let mut cx = VimTestContext::new(cx, true).await;
2666 cx.enable_helix();
2667
2668 cx.set_state("line one\nline two\nline threˇe\n", Mode::HelixNormal);
2669 cx.simulate_keystrokes("v");
2670 cx.assert_state("line one\nline two\nline thre«eˇ»\n", Mode::HelixSelect);
2671 cx.simulate_keystrokes("l");
2672 cx.assert_state("line one\nline two\nline thre«e\nˇ»", Mode::HelixSelect);
2673 }
2674
2675 #[gpui::test]
2676 async fn test_helix_select_trailing_newline(cx: &mut gpui::TestAppContext) {
2677 let mut cx = VimTestContext::new(cx, true).await;
2678 cx.enable_helix();
2679
2680 cx.set_state("line one\nline two\nline threeˇ\n", Mode::HelixNormal);
2681 cx.simulate_keystrokes("v");
2682 cx.assert_state("line one\nline two\nline three«\nˇ»", Mode::HelixSelect);
2683 cx.simulate_keystrokes("g h");
2684 cx.assert_state("line one\nline two\n«ˇline three\n»", Mode::HelixSelect);
2685 }
2686
2687 #[gpui::test]
2688 async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
2689 let mut cx = VimTestContext::new(cx, true).await;
2690 cx.enable_helix();
2691
2692 // Make a modification at a specific location
2693 cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
2694 cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
2695 cx.simulate_keystrokes("i");
2696 cx.simulate_keystrokes("escape");
2697 cx.simulate_keystrokes("i");
2698 cx.simulate_keystrokes("m o d i f i e d space");
2699 cx.simulate_keystrokes("escape");
2700
2701 // TODO: this fails, because state is no longer helix
2702 cx.assert_state(
2703 "line one\nline modified ˇtwo\nline three",
2704 Mode::HelixNormal,
2705 );
2706
2707 // Move cursor away from the modification
2708 cx.simulate_keystrokes("up");
2709
2710 // Use "g ." to go back to last modification
2711 cx.simulate_keystrokes("g .");
2712
2713 // Verify we're back at the modification location and still in HelixNormal mode
2714 cx.assert_state(
2715 "line one\nline modifiedˇ two\nline three",
2716 Mode::HelixNormal,
2717 );
2718 }
2719
2720 #[gpui::test]
2721 async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
2722 let mut cx = VimTestContext::new(cx, true).await;
2723 cx.set_state(
2724 "line one\nline ˇtwo\nline three\nline four",
2725 Mode::HelixNormal,
2726 );
2727 cx.simulate_keystrokes("2 x");
2728 cx.assert_state(
2729 "line one\n«line two\nline three\nˇ»line four",
2730 Mode::HelixNormal,
2731 );
2732
2733 // Test extending existing line selection
2734 cx.set_state(
2735 indoc! {"
2736 li«ˇne one
2737 li»ne two
2738 line three
2739 line four"},
2740 Mode::HelixNormal,
2741 );
2742 cx.simulate_keystrokes("x");
2743 cx.assert_state(
2744 indoc! {"
2745 «line one
2746 line two
2747 ˇ»line three
2748 line four"},
2749 Mode::HelixNormal,
2750 );
2751
2752 // Pressing x in empty line, select next line (because helix considers cursor a selection)
2753 cx.set_state(
2754 indoc! {"
2755 line one
2756 ˇ
2757 line three
2758 line four
2759 line five
2760 line six"},
2761 Mode::HelixNormal,
2762 );
2763 cx.simulate_keystrokes("x");
2764 cx.assert_state(
2765 indoc! {"
2766 line one
2767 «
2768 line three
2769 ˇ»line four
2770 line five
2771 line six"},
2772 Mode::HelixNormal,
2773 );
2774
2775 // Another x should only select the next line
2776 cx.simulate_keystrokes("x");
2777 cx.assert_state(
2778 indoc! {"
2779 line one
2780 «
2781 line three
2782 line four
2783 ˇ»line five
2784 line six"},
2785 Mode::HelixNormal,
2786 );
2787
2788 // Empty line with count selects extra + count lines
2789 cx.set_state(
2790 indoc! {"
2791 line one
2792 ˇ
2793 line three
2794 line four
2795 line five"},
2796 Mode::HelixNormal,
2797 );
2798 cx.simulate_keystrokes("2 x");
2799 cx.assert_state(
2800 indoc! {"
2801 line one
2802 «
2803 line three
2804 line four
2805 ˇ»line five"},
2806 Mode::HelixNormal,
2807 );
2808
2809 // Compare empty vs non-empty line behavior
2810 cx.set_state(
2811 indoc! {"
2812 ˇnon-empty line
2813 line two
2814 line three"},
2815 Mode::HelixNormal,
2816 );
2817 cx.simulate_keystrokes("x");
2818 cx.assert_state(
2819 indoc! {"
2820 «non-empty line
2821 ˇ»line two
2822 line three"},
2823 Mode::HelixNormal,
2824 );
2825
2826 // Same test but with empty line - should select one extra
2827 cx.set_state(
2828 indoc! {"
2829 ˇ
2830 line two
2831 line three"},
2832 Mode::HelixNormal,
2833 );
2834 cx.simulate_keystrokes("x");
2835 cx.assert_state(
2836 indoc! {"
2837 «
2838 line two
2839 ˇ»line three"},
2840 Mode::HelixNormal,
2841 );
2842
2843 // Test selecting multiple lines with count
2844 cx.set_state(
2845 indoc! {"
2846 ˇline one
2847 line two
2848 line threeˇ
2849 line four
2850 line five"},
2851 Mode::HelixNormal,
2852 );
2853 cx.simulate_keystrokes("x");
2854 cx.assert_state(
2855 indoc! {"
2856 «line one
2857 ˇ»line two
2858 «line three
2859 ˇ»line four
2860 line five"},
2861 Mode::HelixNormal,
2862 );
2863 cx.simulate_keystrokes("x");
2864 // Adjacent line selections stay separate (not merged)
2865 cx.assert_state(
2866 indoc! {"
2867 «line one
2868 line two
2869 ˇ»«line three
2870 line four
2871 ˇ»line five"},
2872 Mode::HelixNormal,
2873 );
2874
2875 // Test selecting with an empty line below the current line
2876 cx.set_state(
2877 indoc! {"
2878 line one
2879 line twoˇ
2880
2881 line four
2882 line five"},
2883 Mode::HelixNormal,
2884 );
2885 cx.simulate_keystrokes("x");
2886 cx.assert_state(
2887 indoc! {"
2888 line one
2889 «line two
2890 ˇ»
2891 line four
2892 line five"},
2893 Mode::HelixNormal,
2894 );
2895 cx.simulate_keystrokes("x");
2896 cx.assert_state(
2897 indoc! {"
2898 line one
2899 «line two
2900
2901 ˇ»line four
2902 line five"},
2903 Mode::HelixNormal,
2904 );
2905 cx.simulate_keystrokes("x");
2906 cx.assert_state(
2907 indoc! {"
2908 line one
2909 «line two
2910
2911 line four
2912 ˇ»line five"},
2913 Mode::HelixNormal,
2914 );
2915
2916 cx.set_state("oneˇ\ntwo\nthree", Mode::HelixNormal);
2917 cx.simulate_keystrokes("d u x");
2918 cx.assert_state("«one\nˇ»two\nthree", Mode::HelixNormal);
2919 }
2920
2921 #[gpui::test]
2922 async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) {
2923 let mut cx = VimTestContext::new(cx, true).await;
2924
2925 cx.set_state(
2926 "line one\nline ˇtwo\nline three\nline four",
2927 Mode::HelixNormal,
2928 );
2929 cx.simulate_keystrokes("2 x");
2930 cx.assert_state(
2931 "line one\n«line two\nline three\nˇ»line four",
2932 Mode::HelixNormal,
2933 );
2934 cx.simulate_keystrokes("o");
2935 cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
2936
2937 cx.set_state(
2938 "line one\nline ˇtwo\nline three\nline four",
2939 Mode::HelixNormal,
2940 );
2941 cx.simulate_keystrokes("2 x");
2942 cx.assert_state(
2943 "line one\n«line two\nline three\nˇ»line four",
2944 Mode::HelixNormal,
2945 );
2946 cx.simulate_keystrokes("shift-o");
2947 cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert);
2948 }
2949
2950 #[gpui::test]
2951 async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) {
2952 let mut cx = VimTestContext::new(cx, true).await;
2953 cx.enable_helix();
2954
2955 // Test new line in selection direction
2956 cx.set_state(
2957 "ˇline one\nline two\nline three\nline four",
2958 Mode::HelixNormal,
2959 );
2960 cx.simulate_keystrokes("v j j");
2961 cx.assert_state(
2962 "«line one\nline two\nlˇ»ine three\nline four",
2963 Mode::HelixSelect,
2964 );
2965 cx.simulate_keystrokes("o");
2966 cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
2967
2968 cx.set_state(
2969 "line one\nline two\nˇline three\nline four",
2970 Mode::HelixNormal,
2971 );
2972 cx.simulate_keystrokes("v k k");
2973 cx.assert_state(
2974 "«ˇline one\nline two\nl»ine three\nline four",
2975 Mode::HelixSelect,
2976 );
2977 cx.simulate_keystrokes("shift-o");
2978 cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert);
2979
2980 // Test new line in opposite selection direction
2981 cx.set_state(
2982 "ˇline one\nline two\nline three\nline four",
2983 Mode::HelixNormal,
2984 );
2985 cx.simulate_keystrokes("v j j");
2986 cx.assert_state(
2987 "«line one\nline two\nlˇ»ine three\nline four",
2988 Mode::HelixSelect,
2989 );
2990 cx.simulate_keystrokes("shift-o");
2991 cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert);
2992
2993 cx.set_state(
2994 "line one\nline two\nˇline three\nline four",
2995 Mode::HelixNormal,
2996 );
2997 cx.simulate_keystrokes("v k k");
2998 cx.assert_state(
2999 "«ˇline one\nline two\nl»ine three\nline four",
3000 Mode::HelixSelect,
3001 );
3002 cx.simulate_keystrokes("o");
3003 cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
3004 }
3005
3006 #[gpui::test]
3007 async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
3008 let mut cx = VimTestContext::new(cx, true).await;
3009
3010 assert_eq!(cx.mode(), Mode::Normal);
3011 cx.enable_helix();
3012
3013 cx.set_state("ˇhello", Mode::HelixNormal);
3014 cx.simulate_keystrokes("l v l l");
3015 cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
3016 }
3017
3018 #[gpui::test]
3019 async fn test_helix_select_end_of_line(cx: &mut gpui::TestAppContext) {
3020 let mut cx = VimTestContext::new(cx, true).await;
3021 cx.enable_helix();
3022
3023 // v g l d should delete to end of line without consuming the newline
3024 cx.set_state("ˇThe quick brown\nfox jumps over", Mode::HelixNormal);
3025 cx.simulate_keystrokes("v g l d");
3026 cx.assert_state("ˇ\nfox jumps over", Mode::HelixNormal);
3027
3028 // same from the middle of a line — the cursor rests on the trailing
3029 // newline, matching Helix and the whole-line case above.
3030 cx.set_state("The ˇquick brown\nfox jumps over", Mode::HelixNormal);
3031 cx.simulate_keystrokes("v g l d");
3032 cx.assert_state("The ˇ\nfox jumps over", Mode::HelixNormal);
3033 }
3034
3035 #[gpui::test]
3036 async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) {
3037 let mut cx = VimTestContext::new(cx, true).await;
3038
3039 assert_eq!(cx.mode(), Mode::Normal);
3040 cx.enable_helix();
3041
3042 // Start with multiple cursors (no selections)
3043 cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
3044
3045 // Enter select mode and move right twice
3046 cx.simulate_keystrokes("v l l");
3047
3048 // Each cursor should independently create and extend its own selection
3049 cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
3050 }
3051
3052 #[gpui::test]
3053 async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
3054 let mut cx = VimTestContext::new(cx, true).await;
3055
3056 cx.set_state("ˇone two", Mode::Normal);
3057 cx.simulate_keystrokes("v w");
3058 cx.assert_state("«one tˇ»wo", Mode::Visual);
3059
3060 // In Vim, this selects "t". In helix selections stops just before "t"
3061
3062 cx.enable_helix();
3063 cx.set_state("ˇone two", Mode::HelixNormal);
3064 cx.simulate_keystrokes("v w");
3065 cx.assert_state("«one ˇ»two", Mode::HelixSelect);
3066 }
3067
3068 #[gpui::test]
3069 async fn test_exit_visual_mode(cx: &mut gpui::TestAppContext) {
3070 let mut cx = VimTestContext::new(cx, true).await;
3071
3072 cx.set_state("ˇone two", Mode::Normal);
3073 cx.simulate_keystrokes("v w");
3074 cx.assert_state("«one tˇ»wo", Mode::Visual);
3075 cx.simulate_keystrokes("escape");
3076 cx.assert_state("one ˇtwo", Mode::Normal);
3077
3078 cx.enable_helix();
3079 cx.set_state("ˇone two", Mode::HelixNormal);
3080 cx.simulate_keystrokes("v w");
3081 cx.assert_state("«one ˇ»two", Mode::HelixSelect);
3082 cx.simulate_keystrokes("escape");
3083 cx.assert_state("«one ˇ»two", Mode::HelixNormal);
3084 }
3085
3086 #[gpui::test]
3087 async fn test_helix_select_motion(cx: &mut gpui::TestAppContext) {
3088 let mut cx = VimTestContext::new(cx, true).await;
3089 cx.enable_helix();
3090
3091 cx.set_state("«ˇ»one two three", Mode::HelixSelect);
3092 cx.simulate_keystrokes("w");
3093 cx.assert_state("«one ˇ»two three", Mode::HelixSelect);
3094
3095 cx.set_state("«ˇ»one two three", Mode::HelixSelect);
3096 cx.simulate_keystrokes("e");
3097 cx.assert_state("«oneˇ» two three", Mode::HelixSelect);
3098 }
3099
3100 #[gpui::test]
3101 async fn test_helix_full_cursor_selection(cx: &mut gpui::TestAppContext) {
3102 let mut cx = VimTestContext::new(cx, true).await;
3103 cx.enable_helix();
3104
3105 cx.set_state("ˇone two three", Mode::HelixNormal);
3106 cx.simulate_keystrokes("l l v h h h");
3107 cx.assert_state("«ˇone» two three", Mode::HelixSelect);
3108 }
3109
3110 // Regression test for ZED-758: helix motions called
3111 // `Editor::text_layout_details` on an editor whose `style` had never
3112 // been set, panicking on `unwrap()`.
3113 #[gpui::test]
3114 async fn test_helix_motion_on_unrendered_editor(cx: &mut gpui::TestAppContext) {
3115 use editor::{Editor, EditorMode, SelectionEffects};
3116 use multi_buffer::{MultiBuffer, MultiBufferOffset};
3117
3118 VimTestContext::init(cx);
3119 cx.update(|cx| {
3120 VimTestContext::init_keybindings(true, cx);
3121 SettingsStore::update_global(cx, |store, cx| {
3122 store.update_user_settings(cx, |s| {
3123 s.vim_mode = Some(true);
3124 s.helix_mode = Some(true);
3125 });
3126 });
3127 });
3128
3129 let cx = cx.add_empty_window();
3130
3131 let editor = cx.update(|window, cx| {
3132 use gpui::AppContext as _;
3133 let buffer = MultiBuffer::build_simple("one two three", cx);
3134 cx.new(|cx| {
3135 let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx);
3136 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3137 s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)])
3138 });
3139 editor
3140 })
3141 });
3142
3143 let vim = editor
3144 .read_with(cx, |editor, _| editor.addon::<VimAddon>().cloned())
3145 .expect("VimAddon should be auto-attached to new editors when vim mode is enabled");
3146
3147 cx.update(|window, cx| {
3148 vim.entity.update(cx, |vim, cx| {
3149 vim.switch_mode(Mode::HelixNormal, true, window, cx);
3150 vim.helix_move_and_collapse(crate::motion::Motion::Left, None, window, cx);
3151 });
3152 });
3153
3154 let cursor_offset = cx.update(|_, cx| {
3155 editor.update(cx, |editor, cx| {
3156 editor
3157 .selections
3158 .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
3159 .head()
3160 })
3161 });
3162 assert_eq!(cursor_offset, MultiBufferOffset(3));
3163 }
3164
3165 #[gpui::test]
3166 async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) {
3167 let mut cx = VimTestContext::new(cx, true).await;
3168 cx.enable_helix();
3169
3170 cx.set_state("ˇone two one", Mode::HelixNormal);
3171 cx.simulate_keystrokes("x");
3172 cx.assert_state("«one two oneˇ»", Mode::HelixNormal);
3173 cx.simulate_keystrokes("s o n e");
3174 cx.run_until_parked();
3175 cx.simulate_keystrokes("enter");
3176 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
3177
3178 cx.simulate_keystrokes("x");
3179 cx.simulate_keystrokes("s");
3180 cx.run_until_parked();
3181 cx.simulate_keystrokes("enter");
3182 cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
3183
3184 // TODO: change "search_in_selection" to not perform any search when in helix select mode with no selection
3185 // cx.set_state("ˇstuff one two one", Mode::HelixNormal);
3186 // cx.simulate_keystrokes("s o n e enter");
3187 // cx.assert_state("ˇstuff one two one", Mode::HelixNormal);
3188 }
3189
3190 #[gpui::test]
3191 async fn test_helix_select_next_match(cx: &mut gpui::TestAppContext) {
3192 let mut cx = VimTestContext::new(cx, true).await;
3193
3194 cx.set_state("ˇhello two one two one two one", Mode::Visual);
3195 cx.simulate_keystrokes("/ o n e");
3196 cx.simulate_keystrokes("enter");
3197 cx.simulate_keystrokes("n n");
3198 cx.assert_state("«hello two one two one two oˇ»ne", Mode::Visual);
3199
3200 cx.set_state("ˇhello two one two one two one", Mode::Normal);
3201 cx.simulate_keystrokes("/ o n e");
3202 cx.simulate_keystrokes("enter");
3203 cx.simulate_keystrokes("n n");
3204 cx.assert_state("hello two one two one two ˇone", Mode::Normal);
3205
3206 cx.set_state("ˇhello two one two one two one", Mode::Normal);
3207 cx.simulate_keystrokes("/ o n e");
3208 cx.simulate_keystrokes("enter");
3209 cx.simulate_keystrokes("n g n g n");
3210 cx.assert_state("hello two one two «one two oneˇ»", Mode::Visual);
3211
3212 cx.enable_helix();
3213
3214 cx.set_state("ˇhello two one two one two one", Mode::HelixNormal);
3215 cx.simulate_keystrokes("/ o n e");
3216 cx.simulate_keystrokes("enter");
3217 cx.simulate_keystrokes("n n");
3218 cx.assert_state("hello two one two one two «oneˇ»", Mode::HelixNormal);
3219
3220 cx.set_state("ˇhello two one two one two one", Mode::HelixSelect);
3221 cx.simulate_keystrokes("/ o n e");
3222 cx.simulate_keystrokes("enter");
3223 cx.simulate_keystrokes("n n");
3224 cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect);
3225 }
3226
3227 #[gpui::test]
3228 async fn test_helix_select_next_match_wrapping(cx: &mut gpui::TestAppContext) {
3229 let mut cx = VimTestContext::new(cx, true).await;
3230 cx.enable_helix();
3231
3232 // Three occurrences of "one". After selecting all three with `n n`,
3233 // pressing `n` again wraps the search to the first occurrence.
3234 // The prior selections (at higher offsets) are chained before the
3235 // wrapped selection (at a lower offset), producing unsorted anchors
3236 // that cause `rope::Cursor::summary` to panic with
3237 // "cannot summarize backward".
3238 cx.set_state("ˇhello two one two one two one", Mode::HelixSelect);
3239 cx.simulate_keystrokes("/ o n e");
3240 cx.simulate_keystrokes("enter");
3241 cx.simulate_keystrokes("n n n");
3242 // Should not panic; all three occurrences should remain selected.
3243 cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect);
3244 }
3245
3246 #[gpui::test]
3247 async fn test_helix_select_next_match_wrapping_from_normal(cx: &mut gpui::TestAppContext) {
3248 let mut cx = VimTestContext::new(cx, true).await;
3249 cx.enable_helix();
3250
3251 // Exact repro for #51573: start in HelixNormal, search, then `v` to
3252 // enter HelixSelect, then `n` past last match.
3253 //
3254 // In HelixNormal, search collapses the cursor to the match start.
3255 // Pressing `v` expands by only one character, creating a partial
3256 // selection that overlaps the full match range when the search wraps.
3257 // The overlapping ranges must be merged (not just deduped) to avoid
3258 // a backward-seeking rope cursor panic.
3259 cx.set_state(
3260 indoc! {"
3261 searˇch term
3262 stuff
3263 search term
3264 other stuff
3265 "},
3266 Mode::HelixNormal,
3267 );
3268 cx.simulate_keystrokes("/ t e r m");
3269 cx.simulate_keystrokes("enter");
3270 cx.simulate_keystrokes("v");
3271 cx.simulate_keystrokes("n");
3272 cx.simulate_keystrokes("n");
3273 // Should not panic when wrapping past last match.
3274 cx.assert_state(
3275 indoc! {"
3276 search «termˇ»
3277 stuff
3278 search «termˇ»
3279 other stuff
3280 "},
3281 Mode::HelixSelect,
3282 );
3283 }
3284
3285 #[gpui::test]
3286 async fn test_helix_select_star_then_match(cx: &mut gpui::TestAppContext) {
3287 let mut cx = VimTestContext::new(cx, true).await;
3288 cx.enable_helix();
3289
3290 // Repro attempts for #52852: `*` searches for word under cursor,
3291 // `v` enters select, `n` accumulates matches, `m` triggers match mode.
3292 // Try multiple cursor positions and match counts.
3293
3294 // Cursor on first occurrence, 3 more occurrences to select through
3295 cx.set_state(
3296 indoc! {"
3297 ˇone two one three one four one
3298 "},
3299 Mode::HelixNormal,
3300 );
3301 cx.simulate_keystrokes("*");
3302 cx.simulate_keystrokes("v");
3303 cx.simulate_keystrokes("n n n");
3304 // Should not panic on wrapping `n`.
3305
3306 // Cursor in the middle of text before matches
3307 cx.set_state(
3308 indoc! {"
3309 heˇllo one two one three one
3310 "},
3311 Mode::HelixNormal,
3312 );
3313 cx.simulate_keystrokes("*");
3314 cx.simulate_keystrokes("v");
3315 cx.simulate_keystrokes("n");
3316 // Should not panic.
3317
3318 // The original #52852 sequence: * v n n n then m m
3319 cx.set_state(
3320 indoc! {"
3321 fn ˇfoo() { bar(foo()) }
3322 fn baz() { foo() }
3323 "},
3324 Mode::HelixNormal,
3325 );
3326 cx.simulate_keystrokes("*");
3327 cx.simulate_keystrokes("v");
3328 cx.simulate_keystrokes("n n n");
3329 cx.simulate_keystrokes("m m");
3330 // Should not panic.
3331 }
3332
3333 #[gpui::test]
3334 async fn test_helix_substitute(cx: &mut gpui::TestAppContext) {
3335 let mut cx = VimTestContext::new(cx, true).await;
3336
3337 cx.set_state("ˇone two", Mode::HelixNormal);
3338 cx.simulate_keystrokes("c");
3339 cx.assert_state("ˇne two", Mode::Insert);
3340
3341 cx.set_state("«oneˇ» two", Mode::HelixNormal);
3342 cx.simulate_keystrokes("c");
3343 cx.assert_state("ˇ two", Mode::Insert);
3344
3345 cx.set_state(
3346 indoc! {"
3347 oneˇ two
3348 three
3349 "},
3350 Mode::HelixNormal,
3351 );
3352 cx.simulate_keystrokes("x c");
3353 cx.assert_state(
3354 indoc! {"
3355 ˇ
3356 three
3357 "},
3358 Mode::Insert,
3359 );
3360
3361 cx.set_state(
3362 indoc! {"
3363 one twoˇ
3364 three
3365 "},
3366 Mode::HelixNormal,
3367 );
3368 cx.simulate_keystrokes("c");
3369 cx.assert_state(
3370 indoc! {"
3371 one twoˇthree
3372 "},
3373 Mode::Insert,
3374 );
3375
3376 // Helix doesn't set the cursor to the first non-blank one when
3377 // replacing lines: it uses language-dependent indent queries instead.
3378 cx.set_state(
3379 indoc! {"
3380 one two
3381 « indented
3382 three not indentedˇ»
3383 "},
3384 Mode::HelixNormal,
3385 );
3386 cx.simulate_keystrokes("c");
3387 cx.set_state(
3388 indoc! {"
3389 one two
3390 ˇ
3391 "},
3392 Mode::Insert,
3393 );
3394 }
3395
3396 #[gpui::test]
3397 async fn test_g_l_end_of_line(cx: &mut gpui::TestAppContext) {
3398 let mut cx = VimTestContext::new(cx, true).await;
3399 cx.enable_helix();
3400
3401 // Test g l moves to last character, not after it
3402 cx.set_state("hello ˇworld!", Mode::HelixNormal);
3403 cx.simulate_keystrokes("g l");
3404 cx.assert_state("hello worldˇ!", Mode::HelixNormal);
3405
3406 // Test with Chinese characters, test if work with UTF-8?
3407 cx.set_state("ˇ你好世界", Mode::HelixNormal);
3408 cx.simulate_keystrokes("g l");
3409 cx.assert_state("你好世ˇ界", Mode::HelixNormal);
3410
3411 // Test with end of line
3412 cx.set_state("endˇ", Mode::HelixNormal);
3413 cx.simulate_keystrokes("g l");
3414 cx.assert_state("enˇd", Mode::HelixNormal);
3415
3416 // Test with empty line
3417 cx.set_state(
3418 indoc! {"
3419 hello
3420 ˇ
3421 world"},
3422 Mode::HelixNormal,
3423 );
3424 cx.simulate_keystrokes("g l");
3425 cx.assert_state(
3426 indoc! {"
3427 hello
3428 ˇ
3429 world"},
3430 Mode::HelixNormal,
3431 );
3432
3433 // Test with multiple lines
3434 cx.set_state(
3435 indoc! {"
3436 ˇfirst line
3437 second line
3438 third line"},
3439 Mode::HelixNormal,
3440 );
3441 cx.simulate_keystrokes("g l");
3442 cx.assert_state(
3443 indoc! {"
3444 first linˇe
3445 second line
3446 third line"},
3447 Mode::HelixNormal,
3448 );
3449 }
3450
3451 #[gpui::test]
3452 async fn test_helix_jump_starts_operator(cx: &mut gpui::TestAppContext) {
3453 let mut cx = VimTestContext::new(cx, true).await;
3454 cx.enable_helix();
3455 cx.set_state("ˇhello world\njump labels", Mode::HelixNormal);
3456
3457 cx.simulate_keystrokes("g w");
3458
3459 assert!(
3460 matches!(cx.active_operator(), Some(Operator::HelixJump { .. })),
3461 "expected HelixJump operator to be active"
3462 )
3463 }
3464
3465 #[gpui::test]
3466 async fn test_helix_jump_cancels_on_escape(cx: &mut gpui::TestAppContext) {
3467 let mut cx = VimTestContext::new(cx, true).await;
3468 cx.enable_helix();
3469 cx.set_state("ˇhello world\njump labels", Mode::HelixNormal);
3470 let overlay_counts = active_helix_jump_overlay_counts(&mut cx);
3471
3472 cx.simulate_keystrokes("g w");
3473 cx.simulate_keystrokes("escape");
3474
3475 cx.assert_state("ˇhello world\njump labels", Mode::HelixNormal);
3476 assert_helix_jump_cleared(&mut cx, overlay_counts);
3477 }
3478
3479 #[gpui::test]
3480 async fn test_helix_jump_cancels_on_invalid_first_char(cx: &mut gpui::TestAppContext) {
3481 let mut cx = VimTestContext::new(cx, true).await;
3482 cx.enable_helix();
3483 cx.set_state("ˇalpha beta gamma", Mode::HelixNormal);
3484 let overlay_counts = active_helix_jump_overlay_counts(&mut cx);
3485
3486 cx.simulate_keystrokes("g w");
3487 cx.simulate_keystrokes("z");
3488
3489 cx.assert_state("ˇalpha beta gamma", Mode::HelixNormal);
3490 assert_helix_jump_cleared(&mut cx, overlay_counts);
3491 }
3492
3493 #[gpui::test]
3494 async fn test_helix_jump_cancels_on_invalid_second_char(cx: &mut gpui::TestAppContext) {
3495 let mut cx = VimTestContext::new(cx, true).await;
3496 cx.enable_helix();
3497 cx.set_state("ˇalpha beta gamma", Mode::HelixNormal);
3498 let overlay_counts = active_helix_jump_overlay_counts(&mut cx);
3499
3500 cx.simulate_keystrokes("g w");
3501 cx.simulate_keystrokes("a z");
3502
3503 cx.assert_state("ˇalpha beta gamma", Mode::HelixNormal);
3504 assert_helix_jump_cleared(&mut cx, overlay_counts);
3505 }
3506
3507 #[gpui::test]
3508 async fn test_helix_jump_keeps_full_overlay_after_first_key(cx: &mut gpui::TestAppContext) {
3509 let mut cx = VimTestContext::new(cx, true).await;
3510 cx.enable_helix();
3511 let text = format!(
3512 "ˇ{}",
3513 (0..28)
3514 .map(|index| format!("w{index:02}"))
3515 .collect::<Vec<_>>()
3516 .join(" ")
3517 );
3518 cx.set_state(&text, Mode::HelixNormal);
3519
3520 cx.simulate_keystrokes("g w");
3521 let labels = active_helix_jump_labels(&mut cx);
3522 let initial_overlay_counts = active_helix_jump_overlay_counts(&mut cx);
3523 let first_group = labels
3524 .first()
3525 .and_then(|(label, _)| label.chars().next())
3526 .expect("expected at least one helix jump label");
3527 let next_group = labels
3528 .iter()
3529 .filter_map(|(label, _)| label.chars().next())
3530 .find(|ch| *ch != first_group)
3531 .expect("expected labels spanning more than one first-character group");
3532
3533 cx.simulate_keystrokes(&next_group.to_string());
3534
3535 assert_eq!(
3536 active_helix_jump_overlay_counts(&mut cx),
3537 initial_overlay_counts
3538 );
3539 assert!(
3540 matches!(
3541 cx.active_operator(),
3542 Some(Operator::HelixJump {
3543 first_char: Some(ch),
3544 ..
3545 }) if ch == next_group
3546 ),
3547 "expected HelixJump operator to keep the first typed label character"
3548 );
3549 }
3550
3551 #[gpui::test]
3552 async fn test_helix_jump_includes_word_before_cursor_boundary(cx: &mut gpui::TestAppContext) {
3553 let mut cx = VimTestContext::new(cx, true).await;
3554 cx.enable_helix();
3555 cx.set_state("oneˇ two three", Mode::HelixNormal);
3556
3557 jump_to_word(&mut cx, "one");
3558
3559 cx.assert_state("«oneˇ» two three", Mode::HelixNormal);
3560 assert_eq!(cx.active_operator(), None);
3561 }
3562
3563 #[gpui::test]
3564 async fn test_helix_jump_skips_single_char_words(cx: &mut gpui::TestAppContext) {
3565 let mut cx = VimTestContext::new(cx, true).await;
3566 cx.enable_helix();
3567 cx.set_state("ˇa bb c dd e", Mode::HelixNormal);
3568
3569 let words = helix_jump_labels_for_full_buffer(&mut cx)
3570 .into_iter()
3571 .map(|(_, word)| word)
3572 .collect::<Vec<_>>();
3573
3574 assert_eq!(words, vec!["bb".to_string(), "dd".to_string()]);
3575 }
3576
3577 #[gpui::test]
3578 async fn test_helix_jump_handles_underscored_words(cx: &mut gpui::TestAppContext) {
3579 let mut cx = VimTestContext::new(cx, true).await;
3580 cx.enable_helix();
3581 cx.set_state("baz quxˇ foo_bar _private", Mode::HelixNormal);
3582
3583 let words = helix_jump_labels_for_full_buffer(&mut cx)
3584 .into_iter()
3585 .map(|(_, word)| word)
3586 .collect::<Vec<_>>();
3587
3588 assert!(words.iter().any(|word| word == "foo_bar"));
3589 assert!(words.iter().any(|word| word == "_private"));
3590 assert!(!words.iter().any(|word| word == "foo"));
3591 assert!(!words.iter().any(|word| word == "bar"));
3592 }
3593
3594 #[gpui::test]
3595 async fn test_helix_jump_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
3596 let mut cx = VimTestContext::new(cx, true).await;
3597 cx.enable_helix();
3598 cx.set_state("alpha beta gammaˇ", Mode::HelixNormal);
3599
3600 jump_to_word(&mut cx, "gamma");
3601
3602 cx.assert_state("alpha beta «gammaˇ»", Mode::HelixNormal);
3603 assert_eq!(cx.active_operator(), None);
3604 }
3605
3606 #[gpui::test]
3607 async fn test_helix_jump_moves_to_target_word(cx: &mut gpui::TestAppContext) {
3608 let mut cx = VimTestContext::new(cx, true).await;
3609 cx.enable_helix();
3610 cx.set_state("ˇone two three", Mode::HelixNormal);
3611
3612 jump_to_word(&mut cx, "three");
3613
3614 cx.assert_state("one two «threeˇ»", Mode::HelixNormal);
3615 assert_eq!(cx.active_operator(), None);
3616 }
3617
3618 #[gpui::test]
3619 async fn test_helix_jump_includes_line_selection_targets(cx: &mut gpui::TestAppContext) {
3620 let mut cx = VimTestContext::new(cx, true).await;
3621 cx.enable_helix();
3622 cx.set_state("alpha beta\nˇfoo bar baz\nqux quux", Mode::HelixNormal);
3623
3624 cx.simulate_keystrokes("x");
3625 jump_to_word(&mut cx, "bar");
3626
3627 cx.assert_state("alpha beta\nfoo «barˇ» baz\nqux quux", Mode::HelixNormal);
3628 assert_eq!(cx.active_operator(), None);
3629 }
3630
3631 #[gpui::test]
3632 async fn test_vim_jump_moves_to_target_word_start(cx: &mut gpui::TestAppContext) {
3633 let mut cx = VimTestContext::new(cx, true).await;
3634 bind_vim_jump_to_word(&mut cx, "g z");
3635 cx.set_state("ˇone two three", Mode::Normal);
3636
3637 jump_to_word_with_keystrokes(&mut cx, "g z", "two");
3638
3639 cx.assert_state("one ˇtwo three", Mode::Normal);
3640 assert_eq!(cx.active_operator(), None);
3641 }
3642
3643 #[gpui::test]
3644 async fn test_vim_jump_keeps_normal_cursor_shape(cx: &mut gpui::TestAppContext) {
3645 let mut cx = VimTestContext::new(cx, true).await;
3646 bind_vim_jump_to_word(&mut cx, "g z");
3647 cx.update(|_, cx| {
3648 SettingsStore::update_global(cx, |store, cx| {
3649 store.update_user_settings(cx, |settings| {
3650 settings.vim.get_or_insert_default().cursor_shape =
3651 Some(settings::CursorShapeSettings {
3652 normal: Some(settings::CursorShape::Bar),
3653 ..Default::default()
3654 });
3655 });
3656 });
3657 });
3658 cx.set_state("ˇone two three", Mode::Normal);
3659
3660 cx.simulate_keystrokes("g z");
3661
3662 assert!(
3663 matches!(cx.active_operator(), Some(Operator::HelixJump { .. })),
3664 "expected HelixJump operator to be active"
3665 );
3666 cx.update_editor(|editor, _, _| {
3667 assert_eq!(editor.cursor_shape(), CursorShape::Bar);
3668 });
3669 }
3670
3671 #[gpui::test]
3672 async fn test_vim_visual_jump_extends_selection(cx: &mut gpui::TestAppContext) {
3673 let mut cx = VimTestContext::new(cx, true).await;
3674 bind_vim_jump_to_word(&mut cx, "g z");
3675 cx.set_state("one «twoˇ» three four", Mode::Visual);
3676
3677 jump_to_word_with_keystrokes(&mut cx, "g z", "three");
3678
3679 cx.assert_state("one «two tˇ»hree four", Mode::Visual);
3680 assert_eq!(cx.active_operator(), None);
3681 }
3682
3683 #[gpui::test]
3684 async fn test_vim_visual_jump_extends_selection_backward(cx: &mut gpui::TestAppContext) {
3685 let mut cx = VimTestContext::new(cx, true).await;
3686 bind_vim_jump_to_word(&mut cx, "g z");
3687 cx.set_state("one two «threeˇ» four", Mode::Visual);
3688
3689 jump_to_word_with_keystrokes(&mut cx, "g z", "one");
3690
3691 cx.assert_state("«ˇone two three» four", Mode::Visual);
3692 assert_eq!(cx.active_operator(), None);
3693 }
3694
3695 #[gpui::test]
3696 async fn test_helix_jump_extends_selection_forward(cx: &mut gpui::TestAppContext) {
3697 let mut cx = VimTestContext::new(cx, true).await;
3698 cx.enable_helix();
3699 cx.set_state("one «twoˇ» three four", Mode::HelixSelect);
3700
3701 jump_to_word(&mut cx, "four");
3702
3703 cx.assert_state("one «two three fourˇ»", Mode::HelixSelect);
3704 assert_eq!(cx.active_operator(), None);
3705 }
3706
3707 #[gpui::test]
3708 async fn test_helix_jump_extends_selection_backward_from_forward_selection(
3709 cx: &mut gpui::TestAppContext,
3710 ) {
3711 let mut cx = VimTestContext::new(cx, true).await;
3712 cx.enable_helix();
3713 cx.set_state("one «twoˇ» three four", Mode::HelixSelect);
3714
3715 jump_to_word(&mut cx, "one");
3716
3717 cx.assert_state("«ˇone two» three four", Mode::HelixSelect);
3718 assert_eq!(cx.active_operator(), None);
3719 }
3720
3721 #[gpui::test]
3722 async fn test_helix_jump_extends_reversed_selection_backward(cx: &mut gpui::TestAppContext) {
3723 let mut cx = VimTestContext::new(cx, true).await;
3724 cx.enable_helix();
3725 cx.set_state("one two «ˇthree» four", Mode::HelixSelect);
3726
3727 jump_to_word(&mut cx, "one");
3728
3729 cx.assert_state("«ˇone two three» four", Mode::HelixSelect);
3730 assert_eq!(cx.active_operator(), None);
3731 }
3732
3733 #[gpui::test]
3734 async fn test_helix_jump_prioritizes_nearby_targets_before_truncating(
3735 cx: &mut gpui::TestAppContext,
3736 ) {
3737 let mut cx = VimTestContext::new(cx, true).await;
3738 cx.enable_helix();
3739
3740 let cursor_index = 850usize;
3741 let target_word = format!("w{:03}", cursor_index + 1);
3742 let early_word = "w010".to_string();
3743 let text = (0..900usize)
3744 .map(|index| {
3745 let word = format!("w{index:03}");
3746 if index == cursor_index {
3747 format!("ˇ{word}")
3748 } else {
3749 word
3750 }
3751 })
3752 .collect::<Vec<_>>()
3753 .join(" ");
3754 cx.set_state(&text, Mode::HelixNormal);
3755
3756 let labels = helix_jump_labels_for_full_buffer(&mut cx);
3757
3758 assert_eq!(labels.len(), HELIX_JUMP_LABEL_LIMIT);
3759 assert!(
3760 labels.iter().any(|(_, word)| word == &target_word),
3761 "expected nearby target {target_word:?} to survive truncation"
3762 );
3763 assert!(
3764 !labels.iter().any(|(_, word)| word == &early_word),
3765 "expected distant early target {early_word:?} to be truncated first"
3766 );
3767 }
3768
3769 #[gpui::test]
3770 async fn test_helix_jump_label_ordering_alternates_directions(cx: &mut gpui::TestAppContext) {
3771 let mut cx = VimTestContext::new(cx, true).await;
3772 cx.enable_helix();
3773 cx.set_state("aaa bbb ccc ˇddd eee fff ggg", Mode::HelixNormal);
3774
3775 let first_labels = helix_jump_labels_for_full_buffer(&mut cx)
3776 .into_iter()
3777 .take(6)
3778 .collect::<Vec<_>>();
3779
3780 assert_eq!(
3781 first_labels,
3782 vec![
3783 ("aa".to_string(), "eee".to_string()),
3784 ("ab".to_string(), "ccc".to_string()),
3785 ("ac".to_string(), "fff".to_string()),
3786 ("ad".to_string(), "bbb".to_string()),
3787 ("ae".to_string(), "ggg".to_string()),
3788 ("af".to_string(), "aaa".to_string()),
3789 ]
3790 );
3791 }
3792
3793 #[gpui::test]
3794 async fn test_helix_jump_uses_theme_label_color(cx: &mut gpui::TestAppContext) {
3795 let mut cx = VimTestContext::new(cx, true).await;
3796 cx.enable_helix();
3797 cx.update(|_, cx| {
3798 SettingsStore::update_global(cx, |store, cx| {
3799 store.update_user_settings(cx, |settings| {
3800 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
3801 colors: ThemeColorsContent {
3802 vim_helix_jump_label_foreground: Some("#00ff00".to_string()),
3803 ..Default::default()
3804 },
3805 ..Default::default()
3806 });
3807 });
3808 });
3809 });
3810 cx.executor().advance_clock(Duration::from_millis(200));
3811 cx.run_until_parked();
3812
3813 let configured_label_color =
3814 cx.update(|_, cx| cx.theme().colors().vim_helix_jump_label_foreground);
3815 assert_ne!(
3816 configured_label_color,
3817 cx.update(|_, cx| cx.theme().status().error)
3818 );
3819 cx.set_state("ˇalpha beta gamma", Mode::HelixNormal);
3820
3821 let label_colors = cx.update_editor(|editor, window, cx| {
3822 let snapshot = editor.snapshot(window, cx);
3823 let display_snapshot = &snapshot.display_snapshot;
3824 let buffer_snapshot = display_snapshot.buffer_snapshot();
3825 let selections = editor.selections.all::<Point>(display_snapshot);
3826 let skip_data = Vim::selection_skip_offsets(buffer_snapshot, &selections, false);
3827 let cursor_offset = selections
3828 .first()
3829 .map(|selection| buffer_snapshot.point_to_offset(selection.head()))
3830 .unwrap_or(MultiBufferOffset(0));
3831 let style = editor.style(cx);
3832 let font = style.text.font();
3833 let font_size = style.text.font_size.to_pixels(window.rem_size());
3834 let data = Vim::build_helix_jump_ui_data(
3835 buffer_snapshot,
3836 MultiBufferOffset(0),
3837 buffer_snapshot.len(),
3838 cursor_offset,
3839 configured_label_color,
3840 &skip_data,
3841 window.text_system(),
3842 font,
3843 font_size,
3844 );
3845
3846 data.overlays
3847 .into_iter()
3848 .map(|overlay| overlay.label.text_color)
3849 .collect::<Vec<_>>()
3850 });
3851
3852 assert!(!label_colors.is_empty());
3853 assert!(
3854 label_colors
3855 .into_iter()
3856 .all(|color| color == configured_label_color)
3857 );
3858 }
3859
3860 #[gpui::test]
3861 async fn test_helix_jump_input_is_case_insensitive(cx: &mut gpui::TestAppContext) {
3862 let mut cx = VimTestContext::new(cx, true).await;
3863 cx.enable_helix();
3864 cx.set_state("ˇone two three", Mode::HelixNormal);
3865
3866 cx.simulate_keystrokes("g w");
3867 let label = helix_jump_label_for_word(&mut cx, "three");
3868 let mut chars = label.chars();
3869 let first = chars
3870 .next()
3871 .expect("jump labels are two characters long")
3872 .to_ascii_uppercase();
3873 let second = chars
3874 .next()
3875 .expect("jump labels are two characters long")
3876 .to_ascii_uppercase();
3877
3878 cx.simulate_keystrokes(&format!("{first} {second}"));
3879
3880 cx.assert_state("one two «threeˇ»", Mode::HelixNormal);
3881 assert_eq!(cx.active_operator(), None);
3882 }
3883
3884 #[gpui::test]
3885 async fn test_helix_jump_with_unicode_words(cx: &mut gpui::TestAppContext) {
3886 let mut cx = VimTestContext::new(cx, true).await;
3887 cx.enable_helix();
3888 cx.set_state("ˇcafé résumé naïve", Mode::HelixNormal);
3889
3890 jump_to_word(&mut cx, "naïve");
3891
3892 cx.assert_state("café résumé «naïveˇ»", Mode::HelixNormal);
3893 assert_eq!(cx.active_operator(), None);
3894 }
3895
3896 #[gpui::test]
3897 async fn test_project_search_opens_in_normal_mode(cx: &mut gpui::TestAppContext) {
3898 VimTestContext::init(cx);
3899
3900 let fs = FakeFs::new(cx.background_executor.clone());
3901 fs.insert_tree(
3902 path!("/dir"),
3903 json!({
3904 "file_a.rs": "// File A.",
3905 "file_b.rs": "// File B.",
3906 }),
3907 )
3908 .await;
3909
3910 let project = project::Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3911 let window_handle =
3912 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3913 let workspace = window_handle
3914 .read_with(cx, |mw, _| mw.workspace().clone())
3915 .unwrap();
3916
3917 cx.update(|cx| {
3918 VimTestContext::init_keybindings(true, cx);
3919 SettingsStore::update_global(cx, |store, cx| {
3920 store.update_user_settings(cx, |store| store.helix_mode = Some(true));
3921 })
3922 });
3923
3924 let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
3925
3926 workspace.update_in(cx, |workspace, window, cx| {
3927 ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx)
3928 });
3929
3930 let search_view = workspace.update_in(cx, |workspace, _, cx| {
3931 workspace
3932 .active_pane()
3933 .read(cx)
3934 .items()
3935 .find_map(|item| item.downcast::<ProjectSearchView>())
3936 .expect("Project search view should be active")
3937 });
3938
3939 project_search::perform_project_search(&search_view, "File A", cx);
3940
3941 search_view.update(cx, |search_view, cx| {
3942 let vim_mode = search_view
3943 .results_editor()
3944 .read(cx)
3945 .addon::<VimAddon>()
3946 .map(|addon| addon.entity.read(cx).mode);
3947
3948 assert_eq!(vim_mode, Some(Mode::HelixNormal));
3949 });
3950 }
3951
3952 #[gpui::test]
3953 async fn test_scroll_with_selection(cx: &mut gpui::TestAppContext) {
3954 let mut cx = VimTestContext::new(cx, true).await;
3955 cx.enable_helix();
3956
3957 // Start with a selection
3958 cx.set_state(
3959 indoc! {"
3960 «lineˇ» one
3961 line two
3962 line three
3963 line four
3964 line five"},
3965 Mode::HelixNormal,
3966 );
3967
3968 // Scroll down, selection should collapse
3969 cx.simulate_keystrokes("ctrl-d");
3970 cx.assert_state(
3971 indoc! {"
3972 line one
3973 line two
3974 line three
3975 line four
3976 line fiveˇ"},
3977 Mode::HelixNormal,
3978 );
3979
3980 // Make a new selection
3981 cx.simulate_keystroke("b");
3982 cx.assert_state(
3983 indoc! {"
3984 line one
3985 line two
3986 line three
3987 line four
3988 line «ˇfive»"},
3989 Mode::HelixNormal,
3990 );
3991
3992 // And scroll up, once again collapsing the selection.
3993 cx.simulate_keystroke("ctrl-u");
3994 cx.assert_state(
3995 indoc! {"
3996 line one
3997 line two
3998 line three
3999 line ˇfour
4000 line five"},
4001 Mode::HelixNormal,
4002 );
4003
4004 // Enter select mode
4005 cx.simulate_keystroke("v");
4006 cx.assert_state(
4007 indoc! {"
4008 line one
4009 line two
4010 line three
4011 line «fˇ»our
4012 line five"},
4013 Mode::HelixSelect,
4014 );
4015
4016 // And now the selection should be kept/expanded.
4017 cx.simulate_keystroke("ctrl-d");
4018 cx.assert_state(
4019 indoc! {"
4020 line one
4021 line two
4022 line three
4023 line «four
4024 line fiveˇ»"},
4025 Mode::HelixSelect,
4026 );
4027 }
4028
4029 #[gpui::test]
4030 async fn test_helix_insert_end_of_line(cx: &mut gpui::TestAppContext) {
4031 let mut cx = VimTestContext::new(cx, true).await;
4032 cx.enable_helix();
4033
4034 // Ensure that, when lines are selected using `x`, pressing `shift-a`
4035 // actually puts the cursor at the end of the selected lines and not at
4036 // the end of the line below.
4037 cx.set_state(
4038 indoc! {"
4039 line oˇne
4040 line two"},
4041 Mode::HelixNormal,
4042 );
4043
4044 cx.simulate_keystrokes("x");
4045 cx.assert_state(
4046 indoc! {"
4047 «line one
4048 ˇ»line two"},
4049 Mode::HelixNormal,
4050 );
4051
4052 cx.simulate_keystrokes("shift-a");
4053 cx.assert_state(
4054 indoc! {"
4055 line oneˇ
4056 line two"},
4057 Mode::Insert,
4058 );
4059
4060 cx.set_state(
4061 indoc! {"
4062 line «one
4063 lineˇ» two"},
4064 Mode::HelixNormal,
4065 );
4066
4067 cx.simulate_keystrokes("shift-a");
4068 cx.assert_state(
4069 indoc! {"
4070 line one
4071 line twoˇ"},
4072 Mode::Insert,
4073 );
4074 }
4075
4076 #[gpui::test]
4077 async fn test_helix_replace_uses_graphemes(cx: &mut gpui::TestAppContext) {
4078 let mut cx = VimTestContext::new(cx, true).await;
4079 cx.enable_helix();
4080
4081 cx.set_state("«Hällöˇ» Wörld", Mode::HelixNormal);
4082 cx.simulate_keystrokes("r 1");
4083 cx.assert_state("«11111ˇ» Wörld", Mode::HelixNormal);
4084
4085 cx.set_state("«e\u{301}ˇ»", Mode::HelixNormal);
4086 cx.simulate_keystrokes("r 1");
4087 cx.assert_state("«1ˇ»", Mode::HelixNormal);
4088
4089 cx.set_state("«🙂ˇ»", Mode::HelixNormal);
4090 cx.simulate_keystrokes("r 1");
4091 cx.assert_state("«1ˇ»", Mode::HelixNormal);
4092 }
4093
4094 #[gpui::test]
4095 async fn test_helix_start_of_document(cx: &mut gpui::TestAppContext) {
4096 let mut cx = VimTestContext::new(cx, true).await;
4097 cx.enable_helix();
4098
4099 // gg lands at column 0 of the first line, regardless of current column
4100 cx.set_state(
4101 indoc! {"
4102 foo
4103 barˇbaz"},
4104 Mode::HelixNormal,
4105 );
4106 cx.simulate_keystrokes("g g");
4107 cx.assert_state(
4108 indoc! {"
4109 ˇfoo
4110 barbaz"},
4111 Mode::HelixNormal,
4112 );
4113
4114 // gg with an active selection collapses to column 0 of the first line
4115 cx.set_state(
4116 indoc! {"
4117 foo
4118 «bar bazˇ»"},
4119 Mode::HelixNormal,
4120 );
4121 cx.simulate_keystrokes("g g");
4122 cx.assert_state(
4123 indoc! {"
4124 ˇfoo
4125 bar baz"},
4126 Mode::HelixNormal,
4127 );
4128
4129 // a count goes to that line number at column 0
4130 cx.set_state(
4131 indoc! {"
4132 line one
4133 line two
4134 line threeˇ"},
4135 Mode::HelixNormal,
4136 );
4137 cx.simulate_keystrokes("2 g g");
4138 cx.assert_state(
4139 indoc! {"
4140 line one
4141 ˇline two
4142 line three"},
4143 Mode::HelixNormal,
4144 );
4145
4146 // a count larger than the number of lines clips to the last line
4147 cx.set_state(
4148 indoc! {"
4149 line one
4150 line two
4151 ˇline three"},
4152 Mode::HelixNormal,
4153 );
4154 cx.simulate_keystrokes("9 9 9 g g");
4155 cx.assert_state(
4156 indoc! {"
4157 line one
4158 line two
4159 ˇline three"},
4160 Mode::HelixNormal,
4161 );
4162
4163 // v gg extends the selection backward to col 0 of the first line
4164 cx.set_state(
4165 indoc! {"
4166 line one
4167 ˇline two
4168 line three"},
4169 Mode::HelixNormal,
4170 );
4171 cx.simulate_keystrokes("v g g");
4172 cx.assert_state(
4173 indoc! {"
4174 «ˇline one
4175 l»ine two
4176 line three"},
4177 Mode::HelixSelect,
4178 );
4179
4180 // gg in select mode with a reversed selection extends further backward
4181 cx.set_state(
4182 indoc! {"
4183 line one
4184 line «ˇtwo»
4185 line three"},
4186 Mode::HelixSelect,
4187 );
4188 cx.simulate_keystrokes("g g");
4189 cx.assert_state(
4190 indoc! {"
4191 «ˇline one
4192 line two»
4193 line three"},
4194 Mode::HelixSelect,
4195 );
4196 }
4197
4198 #[gpui::test]
4199 async fn test_helix_end_of_document(cx: &mut gpui::TestAppContext) {
4200 let mut cx = VimTestContext::new(cx, true).await;
4201 cx.enable_helix();
4202
4203 // ge lands at column 0 of the last line, regardless of current column
4204 cx.set_state(
4205 indoc! {"
4206 fooˇbar
4207 baz"},
4208 Mode::HelixNormal,
4209 );
4210 cx.simulate_keystrokes("g e");
4211 cx.assert_state(
4212 indoc! {"
4213 foobar
4214 ˇbaz"},
4215 Mode::HelixNormal,
4216 );
4217
4218 // ge with an active selection collapses to column 0 of the last line
4219 cx.set_state(
4220 indoc! {"
4221 «foo barˇ»
4222 baz"},
4223 Mode::HelixNormal,
4224 );
4225 cx.simulate_keystrokes("g e");
4226 cx.assert_state(
4227 indoc! {"
4228 foo bar
4229 ˇbaz"},
4230 Mode::HelixNormal,
4231 );
4232
4233 // a count is ignored; ge always goes to the last line
4234 cx.set_state(
4235 indoc! {"
4236 line oneˇ
4237 line two
4238 line three"},
4239 Mode::HelixNormal,
4240 );
4241 cx.simulate_keystrokes("2 g e");
4242 cx.assert_state(
4243 indoc! {"
4244 line one
4245 line two
4246 ˇline three"},
4247 Mode::HelixNormal,
4248 );
4249
4250 // v ge extends the selection to col 0 of the last line
4251 cx.set_state(
4252 indoc! {"
4253 ˇline one
4254 line two
4255 line three"},
4256 Mode::HelixNormal,
4257 );
4258 cx.simulate_keystrokes("v g e");
4259 cx.assert_state(
4260 indoc! {"
4261 «line one
4262 line two
4263 lˇ»ine three"},
4264 Mode::HelixSelect,
4265 );
4266
4267 // ge in select mode with a reversed selection extends forward to the last line
4268 cx.set_state(
4269 indoc! {"
4270 line one
4271 line «ˇtwo»
4272 line three"},
4273 Mode::HelixSelect,
4274 );
4275 cx.simulate_keystrokes("g e");
4276 cx.assert_state(
4277 indoc! {"
4278 line one
4279 line tw«o
4280 lˇ»ine three"},
4281 Mode::HelixSelect,
4282 );
4283 }
4284
4285 #[gpui::test]
4286 async fn test_helix_go_to_hunk(cx: &mut gpui::TestAppContext) {
4287 let mut cx = VimTestContext::new(cx, true).await;
4288 cx.enable_helix();
4289
4290 cx.set_state(
4291 indoc! {"
4292 ˇone
4293 two
4294 three"},
4295 Mode::HelixNormal,
4296 );
4297 cx.set_head_text(indoc! {"
4298 one
4299 CHANGED
4300 three"});
4301 cx.run_until_parked();
4302
4303 cx.simulate_keystrokes("]");
4304 assert_eq!(
4305 cx.active_operator(),
4306 Some(Operator::HelixNext { around: true })
4307 );
4308
4309 cx.simulate_keystrokes("g");
4310 cx.assert_state(
4311 indoc! {"
4312 one
4313 ˇtwo
4314 three"},
4315 Mode::HelixNormal,
4316 );
4317 assert_eq!(cx.active_operator(), None);
4318
4319 cx.set_state(
4320 indoc! {"
4321 one
4322 two
4323 ˇthree"},
4324 Mode::HelixNormal,
4325 );
4326 cx.set_head_text(indoc! {"
4327 one
4328 CHANGED
4329 three"});
4330 cx.run_until_parked();
4331
4332 cx.simulate_keystrokes("[");
4333 assert_eq!(
4334 cx.active_operator(),
4335 Some(Operator::HelixPrevious { around: true })
4336 );
4337
4338 cx.simulate_keystrokes("g");
4339 cx.assert_state(
4340 indoc! {"
4341 one
4342 ˇtwo
4343 three"},
4344 Mode::HelixNormal,
4345 );
4346 assert_eq!(cx.active_operator(), None);
4347 }
4348
4349 #[gpui::test]
4350 async fn test_helix_rename_uses_visible_cursor_position(cx: &mut gpui::TestAppContext) {
4351 let mut cx = VimTestContext::new_typescript(cx).await;
4352 cx.enable_helix();
4353
4354 cx.set_state(
4355 "const before = 2; console.log(«beforeˇ»)",
4356 Mode::HelixNormal,
4357 );
4358
4359 let expected_position = cx.to_lsp(MultiBufferOffset(
4360 "const before = 2; console.log(befor".len(),
4361 ));
4362 let def_range = cx.lsp_range("const «beforeˇ» = 2; console.log(before)");
4363 let tgt_range = cx.lsp_range("const before = 2; console.log(«beforeˇ»)");
4364 let mut prepare_request = cx
4365 .set_request_handler::<lsp::request::PrepareRenameRequest, _, _>(
4366 move |_, params, _| async move {
4367 assert_eq!(params.position, expected_position);
4368 Ok(Some(lsp::PrepareRenameResponse::Range(tgt_range)))
4369 },
4370 );
4371 let mut rename_request = cx.set_request_handler::<lsp::request::Rename, _, _>(
4372 move |url, params, _| async move {
4373 Ok(Some(lsp::WorkspaceEdit {
4374 changes: Some(
4375 [(
4376 url.clone(),
4377 vec![
4378 lsp::TextEdit::new(def_range, params.new_name.clone()),
4379 lsp::TextEdit::new(tgt_range, params.new_name),
4380 ],
4381 )]
4382 .into(),
4383 ),
4384 ..Default::default()
4385 }))
4386 },
4387 );
4388
4389 cx.simulate_keystrokes("space r");
4390 prepare_request.next().await.unwrap();
4391 cx.simulate_input("after");
4392 cx.simulate_keystrokes("enter");
4393 rename_request.next().await.unwrap();
4394
4395 cx.assert_state("const after = 2; console.log(afterˇ)", Mode::HelixNormal);
4396 }
4397}
4398