Files
zed/crates/vim/src/helix.rs
Lukas Wirth c98b2d6944 multi_buffer: Typed MultiBufferOffset (#42707)
This PR introduces a new `MultiBufferOffset` new type wrapping size. The
goal of this is to make it clear at the type level when we are
interacting with offsets of a multi buffer versus offsets of a language
/ text buffer. This improves readability of things quite a bit by making
it clear what kind of offsets one is working with while also reducing
accidental bugs by using the wrong kin of offset for the wrong API.

This PR also uncovered two minor bugs due to that.

Does not yet introduce the MultiBufferPoint equivalent, that is for a
follow up PR.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-11-19 22:00:58 +00:00

1497 lines
50 KiB
Rust

mod boundary;
mod duplicate;
mod object;
mod paste;
mod select;
use editor::display_map::DisplaySnapshot;
use editor::{
DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, MultiBufferOffset,
SelectionEffects, ToOffset, ToPoint, movement,
};
use gpui::actions;
use gpui::{Context, Window};
use language::{CharClassifier, CharKind, Point};
use search::{BufferSearchBar, SearchOptions};
use settings::Settings;
use text::{Bias, SelectionGoal};
use workspace::searchable::FilteredSearchRange;
use workspace::searchable::{self, Direction};
use crate::motion::{self, MotionKind};
use crate::state::SearchState;
use crate::{
Vim,
motion::{Motion, right},
state::Mode,
};
actions!(
vim,
[
/// Yanks the current selection or character if no selection.
HelixYank,
/// Inserts at the beginning of the selection.
HelixInsert,
/// Appends at the end of the selection.
HelixAppend,
/// Goes to the location of the last modification.
HelixGotoLastModification,
/// Select entire line or multiple lines, extending downwards.
HelixSelectLine,
/// Select all matches of a given pattern within the current selection.
HelixSelectRegex,
/// Removes all but the one selection that was created last.
/// `Newest` can eventually be `Primary`.
HelixKeepNewestSelection,
/// Copies all selections below.
HelixDuplicateBelow,
/// Copies all selections above.
HelixDuplicateAbove,
/// Delete the selection and enter edit mode.
HelixSubstitute,
/// Delete the selection and enter edit mode, without yanking the selection.
HelixSubstituteNoYank,
/// Delete the selection and enter edit mode.
HelixSelectNext,
/// Delete the selection and enter edit mode, without yanking the selection.
HelixSelectPrevious,
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_select_lines);
Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append);
Vim::action(editor, cx, Vim::helix_yank);
Vim::action(editor, cx, Vim::helix_goto_last_modification);
Vim::action(editor, cx, Vim::helix_paste);
Vim::action(editor, cx, Vim::helix_select_regex);
Vim::action(editor, cx, Vim::helix_keep_newest_selection);
Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| {
let times = Vim::take_count(cx);
vim.helix_duplicate_selections_below(times, window, cx);
});
Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| {
let times = Vim::take_count(cx);
vim.helix_duplicate_selections_above(times, window, cx);
});
Vim::action(editor, cx, Vim::helix_substitute);
Vim::action(editor, cx, Vim::helix_substitute_no_yank);
Vim::action(editor, cx, Vim::helix_select_next);
Vim::action(editor, cx, Vim::helix_select_previous);
}
impl Vim {
pub fn helix_normal_motion(
&mut self,
motion: Motion,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.helix_move_cursor(motion, times, window, cx);
}
pub fn helix_select_motion(
&mut self,
motion: Motion,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(cx, |_, editor, cx| {
let text_layout_details = editor.text_layout_details(window);
editor.change_selections(Default::default(), window, cx, |s| {
if let Motion::ZedSearchResult { new_selections, .. } = &motion {
s.select_anchor_ranges(new_selections.clone());
return;
};
s.move_with(|map, selection| {
let current_head = selection.head();
let Some((new_head, goal)) = motion.move_point(
map,
current_head,
selection.goal,
times,
&text_layout_details,
) else {
return;
};
selection.set_head(new_head, goal);
})
});
});
}
/// Updates all selections based on where the cursors are.
fn helix_new_selections(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
mut change: impl FnMut(
// the start of the cursor
DisplayPoint,
&DisplaySnapshot,
) -> Option<(DisplayPoint, DisplayPoint)>,
) {
self.update_editor(cx, |_, editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let cursor_start = if selection.reversed || selection.is_empty() {
selection.head()
} else {
movement::left(map, selection.head())
};
let Some((head, tail)) = change(cursor_start, map) else {
return;
};
selection.set_head_tail(head, tail, SelectionGoal::None);
});
});
});
}
fn helix_find_range_forward(
&mut self,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
) {
let times = times.unwrap_or(1);
self.helix_new_selections(window, cx, |cursor, map| {
let mut head = movement::right(map, cursor);
let mut tail = cursor;
let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
if head == map.max_point() {
return None;
}
for _ in 0..times {
let (maybe_next_tail, next_head) =
movement::find_boundary_trail(map, head, |left, right| {
is_boundary(left, right, &classifier)
});
if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
break;
}
head = next_head;
if let Some(next_tail) = maybe_next_tail {
tail = next_tail;
}
}
Some((head, tail))
});
}
fn helix_find_range_backward(
&mut self,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
) {
let times = times.unwrap_or(1);
self.helix_new_selections(window, cx, |cursor, map| {
let mut head = cursor;
// The original cursor was one character wide,
// but the search starts from the left side of it,
// so to include that space the selection must end one character to the right.
let mut tail = movement::right(map, cursor);
let classifier = map.buffer_snapshot().char_classifier_at(head.to_point(map));
if head == DisplayPoint::zero() {
return None;
}
for _ in 0..times {
let (maybe_next_tail, next_head) =
movement::find_preceding_boundary_trail(map, head, |left, right| {
is_boundary(left, right, &classifier)
});
if next_head == head && maybe_next_tail.unwrap_or(next_head) == tail {
break;
}
head = next_head;
if let Some(next_tail) = maybe_next_tail {
tail = next_tail;
}
}
Some((head, tail))
});
}
pub fn helix_move_and_collapse(
&mut self,
motion: Motion,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(cx, |_, editor, cx| {
let text_layout_details = editor.text_layout_details(window);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let goal = selection.goal;
let cursor = if selection.is_empty() || selection.reversed {
selection.head()
} else {
movement::left(map, selection.head())
};
let (point, goal) = motion
.move_point(map, cursor, selection.goal, times, &text_layout_details)
.unwrap_or((cursor, goal));
selection.collapse_to(point, goal)
})
});
});
}
pub fn helix_move_cursor(
&mut self,
motion: Motion,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match motion {
Motion::NextWordStart { ignore_punctuation } => {
self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
let left_kind = classifier.kind_with(left, ignore_punctuation);
let right_kind = classifier.kind_with(right, ignore_punctuation);
let at_newline = (left == '\n') ^ (right == '\n');
(left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
})
}
Motion::NextWordEnd { ignore_punctuation } => {
self.helix_find_range_forward(times, window, cx, |left, right, classifier| {
let left_kind = classifier.kind_with(left, ignore_punctuation);
let right_kind = classifier.kind_with(right, ignore_punctuation);
let at_newline = (left == '\n') ^ (right == '\n');
(left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
})
}
Motion::PreviousWordStart { ignore_punctuation } => {
self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
let left_kind = classifier.kind_with(left, ignore_punctuation);
let right_kind = classifier.kind_with(right, ignore_punctuation);
let at_newline = (left == '\n') ^ (right == '\n');
(left_kind != right_kind && left_kind != CharKind::Whitespace) || at_newline
})
}
Motion::PreviousWordEnd { ignore_punctuation } => {
self.helix_find_range_backward(times, window, cx, |left, right, classifier| {
let left_kind = classifier.kind_with(left, ignore_punctuation);
let right_kind = classifier.kind_with(right, ignore_punctuation);
let at_newline = (left == '\n') ^ (right == '\n');
(left_kind != right_kind && right_kind != CharKind::Whitespace) || at_newline
})
}
Motion::FindForward {
before,
char,
mode,
smartcase,
} => {
self.helix_new_selections(window, cx, |cursor, map| {
let start = cursor;
let mut last_boundary = start;
for _ in 0..times.unwrap_or(1) {
last_boundary = movement::find_boundary(
map,
movement::right(map, last_boundary),
mode,
|left, right| {
let current_char = if before { right } else { left };
motion::is_character_match(char, current_char, smartcase)
},
);
}
Some((last_boundary, start))
});
}
Motion::FindBackward {
after,
char,
mode,
smartcase,
} => {
self.helix_new_selections(window, cx, |cursor, map| {
let start = cursor;
let mut last_boundary = start;
for _ in 0..times.unwrap_or(1) {
last_boundary = movement::find_preceding_boundary_display_point(
map,
last_boundary,
mode,
|left, right| {
let current_char = if after { left } else { right };
motion::is_character_match(char, current_char, smartcase)
},
);
}
// The original cursor was one character wide,
// but the search started from the left side of it,
// so to include that space the selection must end one character to the right.
Some((last_boundary, movement::right(map, start)))
});
}
_ => self.helix_move_and_collapse(motion, times, window, cx),
}
}
pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |vim, editor, cx| {
let has_selection = editor
.selections
.all_adjusted(&editor.display_snapshot(cx))
.iter()
.any(|selection| !selection.is_empty());
if !has_selection {
// If no selection, expand to current character (like 'v' does)
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let head = selection.head();
let new_head = movement::saturating_right(map, head);
selection.set_tail(head, SelectionGoal::None);
selection.set_head(new_head, SelectionGoal::None);
});
});
vim.yank_selections_content(
editor,
crate::motion::MotionKind::Exclusive,
window,
cx,
);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|_map, selection| {
selection.collapse_to(selection.start, SelectionGoal::None);
});
});
} else {
// Yank the selection(s)
vim.yank_selections_content(
editor,
crate::motion::MotionKind::Exclusive,
window,
cx,
);
}
});
// Drop back to normal mode after yanking
self.switch_mode(Mode::HelixNormal, true, window, cx);
}
fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.update_editor(cx, |_, editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|_map, selection| {
// In helix normal mode, move cursor to start of selection and collapse
if !selection.is_empty() {
selection.collapse_to(selection.start, SelectionGoal::None);
}
});
});
});
self.switch_mode(Mode::Insert, false, window, cx);
}
fn helix_select_regex(
&mut self,
_: &HelixSelectRegex,
window: &mut Window,
cx: &mut Context<Self>,
) {
Vim::take_forced_motion(cx);
let Some(pane) = self.pane(window, cx) else {
return;
};
let prior_selections = self.editor_selections(window, cx);
pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |search_bar, cx| {
if !search_bar.show(window, cx) {
return;
}
search_bar.select_query(window, cx);
cx.focus_self(window);
search_bar.set_replacement(None, cx);
let mut options = SearchOptions::NONE;
options |= SearchOptions::REGEX;
if EditorSettings::get_global(cx).search.case_sensitive {
options |= SearchOptions::CASE_SENSITIVE;
}
search_bar.set_search_options(options, cx);
if let Some(search) = search_bar.set_search_within_selection(
Some(FilteredSearchRange::Selection),
window,
cx,
) {
cx.spawn_in(window, async move |search_bar, cx| {
if search.await.is_ok() {
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.activate_current_match(window, cx)
})
} else {
Ok(())
}
})
.detach_and_log_err(cx);
}
self.search = SearchState {
direction: searchable::Direction::Next,
count: 1,
prior_selections,
prior_operator: self.operator_stack.last().cloned(),
prior_mode: self.mode,
helix_select: true,
}
});
}
});
self.start_recording(cx);
}
fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.switch_mode(Mode::Insert, false, window, cx);
self.update_editor(cx, |_, editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let point = if selection.is_empty() {
right(map, selection.head(), 1)
} else {
selection.end
};
selection.collapse_to(point, SelectionGoal::None);
});
});
});
}
pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |_, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
let display_map = editor.display_snapshot(cx);
let selections = editor.selections.all_display(&display_map);
// Store selection info for positioning after edit
let selection_info: Vec<_> = selections
.iter()
.map(|selection| {
let range = selection.range();
let start_offset = range.start.to_offset(&display_map, Bias::Left);
let end_offset = range.end.to_offset(&display_map, Bias::Left);
let was_empty = range.is_empty();
let was_reversed = selection.reversed;
(
display_map.buffer_snapshot().anchor_before(start_offset),
end_offset - start_offset,
was_empty,
was_reversed,
)
})
.collect();
let mut edits = Vec::new();
for selection in &selections {
let mut range = selection.range();
// For empty selections, extend to replace one character
if range.is_empty() {
range.end = movement::saturating_right(&display_map, range.start);
}
let byte_range = range.start.to_offset(&display_map, Bias::Left)
..range.end.to_offset(&display_map, Bias::Left);
if !byte_range.is_empty() {
let replacement_text = text.repeat(byte_range.end - byte_range.start);
edits.push((byte_range, replacement_text));
}
}
editor.edit(edits, cx);
// Restore selections based on original info
let snapshot = editor.buffer().read(cx).snapshot(cx);
let ranges: Vec<_> = selection_info
.into_iter()
.map(|(start_anchor, original_len, was_empty, was_reversed)| {
let start_point = start_anchor.to_point(&snapshot);
if was_empty {
// For cursor-only, collapse to start
start_point..start_point
} else {
// For selections, span the replaced text
let replacement_len = text.len() * original_len;
let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
let end_point = snapshot.offset_to_point(end_offset);
if was_reversed {
end_point..start_point
} else {
start_point..end_point
}
}
})
.collect();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges(ranges);
});
});
});
self.switch_mode(Mode::HelixNormal, true, window, cx);
}
pub fn helix_goto_last_modification(
&mut self,
_: &HelixGotoLastModification,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.jump(".".into(), false, false, window, cx);
}
pub fn helix_select_lines(
&mut self,
_: &HelixSelectLine,
window: &mut Window,
cx: &mut Context<Self>,
) {
let count = Vim::take_count(cx).unwrap_or(1);
self.update_editor(cx, |_, editor, cx| {
editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
let mut selections = editor.selections.all::<Point>(&display_map);
let max_point = display_map.buffer_snapshot().max_point();
let buffer_snapshot = &display_map.buffer_snapshot();
for selection in &mut selections {
// Start always goes to column 0 of the first selected line
let start_row = selection.start.row;
let current_end_row = selection.end.row;
// Check if cursor is on empty line by checking first character
let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
let first_char = buffer_snapshot.chars_at(line_start_offset).next();
let extra_line = if first_char == Some('\n') { 1 } else { 0 };
let end_row = current_end_row + count as u32 + extra_line;
selection.start = Point::new(start_row, 0);
selection.end = if end_row > max_point.row {
max_point
} else {
Point::new(end_row, 0)
};
selection.reversed = false;
}
editor.change_selections(Default::default(), window, cx, |s| {
s.select(selections);
});
});
}
fn helix_keep_newest_selection(
&mut self,
_: &HelixKeepNewestSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(cx, |_, editor, cx| {
let newest = editor
.selections
.newest::<MultiBufferOffset>(&editor.display_snapshot(cx));
editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest]));
});
}
fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |vim, editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(|map, selection| {
if selection.start == selection.end {
selection.end = movement::right(map, selection.end);
}
// If the selection starts and ends on a newline, we exclude the last one.
if !selection.is_empty()
&& selection.start.column() == 0
&& selection.end.column() == 0
{
selection.end = movement::left(map, selection.end);
}
})
});
if yank {
vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
}
let selections = editor
.selections
.all::<Point>(&editor.display_snapshot(cx))
.into_iter();
let edits = selections.map(|selection| (selection.start..selection.end, ""));
editor.edit(edits, cx);
});
});
self.switch_mode(Mode::Insert, true, window, cx);
}
fn helix_substitute(
&mut self,
_: &HelixSubstitute,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_helix_substitute(true, window, cx);
}
fn helix_substitute_no_yank(
&mut self,
_: &HelixSubstituteNoYank,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_helix_substitute(false, window, cx);
}
fn helix_select_next(
&mut self,
_: &HelixSelectNext,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_helix_select(Direction::Next, window, cx);
}
fn helix_select_previous(
&mut self,
_: &HelixSelectPrevious,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_helix_select(Direction::Prev, window, cx);
}
fn do_helix_select(
&mut self,
direction: searchable::Direction,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(pane) = self.pane(window, cx) else {
return;
};
let count = Vim::take_count(cx).unwrap_or(1);
Vim::take_forced_motion(cx);
let prior_selections = self.editor_selections(window, cx);
let success = pane.update(cx, |pane, cx| {
let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
return false;
};
search_bar.update(cx, |search_bar, cx| {
if !search_bar.has_active_match() || !search_bar.show(window, cx) {
return false;
}
search_bar.select_match(direction, count, window, cx);
true
})
});
if !success {
return;
}
if self.mode == Mode::HelixSelect {
self.update_editor(cx, |_vim, editor, cx| {
let snapshot = editor.snapshot(window, cx);
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
s.select_anchor_ranges(
prior_selections
.iter()
.cloned()
.chain(s.all_anchors(&snapshot).iter().map(|s| s.range())),
);
})
});
}
}
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::{state::Mode, test::VimTestContext};
#[gpui::test]
async fn test_word_motions(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// «
// ˇ
// »
cx.set_state(
indoc! {"
Th«e quiˇ»ck brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("w");
cx.assert_state(
indoc! {"
The qu«ick ˇ»brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("w");
cx.assert_state(
indoc! {"
The quick «brownˇ»
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("2 b");
cx.assert_state(
indoc! {"
The «ˇquick »brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("down e up");
cx.assert_state(
indoc! {"
The quicˇk brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.set_state("aa\n «ˇbb»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal);
}
#[gpui::test]
async fn test_delete(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// test delete a selection
cx.set_state(
indoc! {"
The qu«ick ˇ»brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("d");
cx.assert_state(
indoc! {"
The quˇbrown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
// test deleting a single character
cx.simulate_keystrokes("d");
cx.assert_state(
indoc! {"
The quˇrown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
indoc! {"
The quick brownˇ
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("d");
cx.assert_state(
indoc! {"
The quick brownˇfox jumps over
the lazy dog."},
Mode::HelixNormal,
);
}
// #[gpui::test]
// async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
// let mut cx = VimTestContext::new(cx, true).await;
// cx.set_state(
// indoc! {"
// The quick brown
// fox jumps over
// the lazy dog.ˇ"},
// Mode::HelixNormal,
// );
// cx.simulate_keystrokes("d");
// cx.assert_state(
// indoc! {"
// The quick brown
// fox jumps over
// the lazy dog.ˇ"},
// Mode::HelixNormal,
// );
// }
#[gpui::test]
async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state(
indoc! {"
The quˇick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("f z");
cx.assert_state(
indoc! {"
The qu«ick brown
fox jumps over
the lazˇ»y dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("F e F e");
cx.assert_state(
indoc! {"
The quick brown
fox jumps ov«ˇer
the» lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("e 2 F e");
cx.assert_state(
indoc! {"
Th«ˇe quick brown
fox jumps over»
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("t r t r");
cx.assert_state(
indoc! {"
The quick «brown
fox jumps oveˇ»r
the lazy dog."},
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_newline_char(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("aa\n«bb ˇ»cc", Mode::HelixNormal);
cx.set_state("aa«\nˇ»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
}
#[gpui::test]
async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state(
indoc! {"
«The ˇ»quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("i");
cx.assert_state(
indoc! {"
ˇThe quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
#[gpui::test]
async fn test_append(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// test from the end of the selection
cx.set_state(
indoc! {"
«Theˇ» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
// test from the beginning of the selection
cx.set_state(
indoc! {"
«ˇThe» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
#[gpui::test]
async fn test_replace(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// No selection (single character)
cx.set_state("ˇaa", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("ˇxa", Mode::HelixNormal);
// Cursor at the beginning
cx.set_state("«ˇaa»", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("«ˇxx»", Mode::HelixNormal);
// Cursor at the end
cx.set_state("«aaˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("«xxˇ»", Mode::HelixNormal);
}
#[gpui::test]
async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Test yanking current character with no selection
cx.set_state("hello ˇworld", Mode::HelixNormal);
cx.simulate_keystrokes("y");
// Test cursor remains at the same position after yanking single character
cx.assert_state("hello ˇworld", Mode::HelixNormal);
cx.shared_clipboard().assert_eq("w");
// Move cursor and yank another character
cx.simulate_keystrokes("l");
cx.simulate_keystrokes("y");
cx.shared_clipboard().assert_eq("o");
// Test yanking with existing selection
cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
cx.simulate_keystrokes("y");
cx.shared_clipboard().assert_eq("worl");
cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
// Test yanking in select mode character by character
cx.set_state("hello ˇworld", Mode::HelixNormal);
cx.simulate_keystroke("v");
cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
cx.simulate_keystroke("y");
cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
cx.shared_clipboard().assert_eq("w");
}
#[gpui::test]
async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// First copy some text to clipboard
cx.set_state("«hello worldˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("y");
// Test paste with shift-r on single cursor
cx.set_state("foo ˇbar", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
// Test paste with shift-r on selection
cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
}
#[gpui::test]
async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
assert_eq!(cx.mode(), Mode::Normal);
cx.enable_helix();
cx.simulate_keystrokes("v");
assert_eq!(cx.mode(), Mode::HelixSelect);
cx.simulate_keystrokes("escape");
assert_eq!(cx.mode(), Mode::HelixNormal);
}
#[gpui::test]
async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Make a modification at a specific location
cx.set_state("ˇhello", Mode::HelixNormal);
assert_eq!(cx.mode(), Mode::HelixNormal);
cx.simulate_keystrokes("i");
assert_eq!(cx.mode(), Mode::Insert);
cx.simulate_keystrokes("escape");
assert_eq!(cx.mode(), Mode::HelixNormal);
}
#[gpui::test]
async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Make a modification at a specific location
cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
cx.simulate_keystrokes("i");
cx.simulate_keystrokes("escape");
cx.simulate_keystrokes("i");
cx.simulate_keystrokes("m o d i f i e d space");
cx.simulate_keystrokes("escape");
// TODO: this fails, because state is no longer helix
cx.assert_state(
"line one\nline modified ˇtwo\nline three",
Mode::HelixNormal,
);
// Move cursor away from the modification
cx.simulate_keystrokes("up");
// Use "g ." to go back to last modification
cx.simulate_keystrokes("g .");
// Verify we're back at the modification location and still in HelixNormal mode
cx.assert_state(
"line one\nline modifiedˇ two\nline three",
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
"line one\nline ˇtwo\nline three\nline four",
Mode::HelixNormal,
);
cx.simulate_keystrokes("2 x");
cx.assert_state(
"line one\n«line two\nline three\nˇ»line four",
Mode::HelixNormal,
);
// Test extending existing line selection
cx.set_state(
indoc! {"
li«ˇne one
li»ne two
line three
line four"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
«line one
line two
ˇ»line three
line four"},
Mode::HelixNormal,
);
// Pressing x in empty line, select next line (because helix considers cursor a selection)
cx.set_state(
indoc! {"
line one
ˇ
line three
line four"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
line one
«
line three
ˇ»line four"},
Mode::HelixNormal,
);
// Empty line with count selects extra + count lines
cx.set_state(
indoc! {"
line one
ˇ
line three
line four
line five"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("2 x");
cx.assert_state(
indoc! {"
line one
«
line three
line four
ˇ»line five"},
Mode::HelixNormal,
);
// Compare empty vs non-empty line behavior
cx.set_state(
indoc! {"
ˇnon-empty line
line two
line three"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
«non-empty line
ˇ»line two
line three"},
Mode::HelixNormal,
);
// Same test but with empty line - should select one extra
cx.set_state(
indoc! {"
ˇ
line two
line three"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
«
line two
ˇ»line three"},
Mode::HelixNormal,
);
// Test selecting multiple lines with count
cx.set_state(
indoc! {"
ˇline one
line two
line threeˇ
line four
line five"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
«line one
ˇ»line two
«line three
ˇ»line four
line five"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x");
cx.assert_state(
indoc! {"
«line one
line two
line three
line four
ˇ»line five"},
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
assert_eq!(cx.mode(), Mode::Normal);
cx.enable_helix();
cx.set_state("ˇhello", Mode::HelixNormal);
cx.simulate_keystrokes("l v l l");
cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
}
#[gpui::test]
async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
assert_eq!(cx.mode(), Mode::Normal);
cx.enable_helix();
// Start with multiple cursors (no selections)
cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
// Enter select mode and move right twice
cx.simulate_keystrokes("v l l");
// Each cursor should independently create and extend its own selection
cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
}
#[gpui::test]
async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("ˇone two", Mode::Normal);
cx.simulate_keystrokes("v w");
cx.assert_state("«one tˇ»wo", Mode::Visual);
// In Vim, this selects "t". In helix selections stops just before "t"
cx.enable_helix();
cx.set_state("ˇone two", Mode::HelixNormal);
cx.simulate_keystrokes("v w");
cx.assert_state("«one ˇ»two", Mode::HelixSelect);
}
#[gpui::test]
async fn test_exit_visual_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("ˇone two", Mode::Normal);
cx.simulate_keystrokes("v w");
cx.assert_state("«one tˇ»wo", Mode::Visual);
cx.simulate_keystrokes("escape");
cx.assert_state("one ˇtwo", Mode::Normal);
cx.enable_helix();
cx.set_state("ˇone two", Mode::HelixNormal);
cx.simulate_keystrokes("v w");
cx.assert_state("«one ˇ»two", Mode::HelixSelect);
cx.simulate_keystrokes("escape");
cx.assert_state("«one ˇ»two", Mode::HelixNormal);
}
#[gpui::test]
async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state("ˇone two one", Mode::HelixNormal);
cx.simulate_keystrokes("x");
cx.assert_state("«one two oneˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("s o n e");
cx.run_until_parked();
cx.simulate_keystrokes("enter");
cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("x");
cx.simulate_keystrokes("s");
cx.run_until_parked();
cx.simulate_keystrokes("enter");
cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
// TODO: change "search_in_selection" to not perform any search when in helix select mode with no selection
// cx.set_state("ˇstuff one two one", Mode::HelixNormal);
// cx.simulate_keystrokes("s o n e enter");
// cx.assert_state("ˇstuff one two one", Mode::HelixNormal);
}
#[gpui::test]
async fn test_helix_select_next_match(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("ˇhello two one two one two one", Mode::Visual);
cx.simulate_keystrokes("/ o n e");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("n n");
cx.assert_state("«hello two one two one two oˇ»ne", Mode::Visual);
cx.set_state("ˇhello two one two one two one", Mode::Normal);
cx.simulate_keystrokes("/ o n e");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("n n");
cx.assert_state("hello two one two one two ˇone", Mode::Normal);
cx.set_state("ˇhello two one two one two one", Mode::Normal);
cx.simulate_keystrokes("/ o n e");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("n g n g n");
cx.assert_state("hello two one two «one two oneˇ»", Mode::Visual);
cx.enable_helix();
cx.set_state("ˇhello two one two one two one", Mode::HelixNormal);
cx.simulate_keystrokes("/ o n e");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("n n");
cx.assert_state("hello two one two one two «oneˇ»", Mode::HelixNormal);
cx.set_state("ˇhello two one two one two one", Mode::HelixSelect);
cx.simulate_keystrokes("/ o n e");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("n n");
cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect);
}
#[gpui::test]
async fn test_helix_substitute(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("ˇone two", Mode::HelixNormal);
cx.simulate_keystrokes("c");
cx.assert_state("ˇne two", Mode::Insert);
cx.set_state("«oneˇ» two", Mode::HelixNormal);
cx.simulate_keystrokes("c");
cx.assert_state("ˇ two", Mode::Insert);
cx.set_state(
indoc! {"
oneˇ two
three
"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("x c");
cx.assert_state(
indoc! {"
ˇ
three
"},
Mode::Insert,
);
cx.set_state(
indoc! {"
one twoˇ
three
"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("c");
cx.assert_state(
indoc! {"
one twoˇthree
"},
Mode::Insert,
);
// Helix doesn't set the cursor to the first non-blank one when
// replacing lines: it uses language-dependent indent queries instead.
cx.set_state(
indoc! {"
one two
« indented
three not indentedˇ»
"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("c");
cx.set_state(
indoc! {"
one two
ˇ
"},
Mode::Insert,
);
}
}