ZIm/crates/vim/src/helix.rs
Pablo Ramón Guevara 7cdd808db2
helix: Fix replace in helix mode (#34789)
Closes https://github.com/zed-industries/zed/issues/33076

Release Notes:

- Fixed replace command on helix mode: now it actually replaces what was
selected and keeps the replaced text selected to better match helix
2025-07-24 08:29:58 -06:00

706 lines
23 KiB
Rust

use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
use gpui::{Action, actions};
use gpui::{Context, Window};
use language::{CharClassifier, CharKind};
use text::{Bias, SelectionGoal};
use crate::{
Vim,
motion::{Motion, right},
state::Mode,
};
actions!(
vim,
[
/// Switches to normal mode after the cursor (Helix-style).
HelixNormalAfter,
/// Inserts at the beginning of the selection.
HelixInsert,
/// Appends at the end of the selection.
HelixAppend,
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_normal_after);
Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append);
}
impl Vim {
pub fn helix_normal_after(
&mut self,
action: &HelixNormalAfter,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.active_operator().is_some() {
self.operator_stack.clear();
self.sync_vim_settings(window, cx);
return;
}
self.stop_recording_immediately(action.boxed_clone(), cx);
self.switch_mode(Mode::HelixNormal, false, window, cx);
return;
}
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);
}
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,
) {
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let times = times.unwrap_or(1);
let new_goal = SelectionGoal::None;
let mut head = selection.head();
let mut tail = selection.tail();
if head == map.max_point() {
return;
}
// collapse to block cursor
if tail < head {
tail = movement::left(map, head);
} else {
tail = head;
head = movement::right(map, head);
}
// create a classifier
let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
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;
}
}
selection.set_tail(tail, new_goal);
selection.set_head(head, new_goal);
});
});
});
}
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,
) {
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let times = times.unwrap_or(1);
let new_goal = SelectionGoal::None;
let mut head = selection.head();
let mut tail = selection.tail();
if head == DisplayPoint::zero() {
return;
}
// collapse to block cursor
if tail < head {
tail = movement::left(map, head);
} else {
tail = head;
head = movement::right(map, head);
}
selection.set_head(head, new_goal);
selection.set_tail(tail, new_goal);
// flip the selection
selection.swap_head_tail();
head = selection.head();
tail = selection.tail();
// create a classifier
let classifier = map.buffer_snapshot.char_classifier_at(head.to_point(map));
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;
}
}
selection.set_tail(tail, new_goal);
selection.set_head(head, new_goal);
});
})
});
}
pub fn helix_move_and_collapse(
&mut self,
motion: Motion,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(window, cx, |_, editor, window, 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');
let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
|| at_newline;
found
})
}
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');
let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
|| at_newline;
found
})
}
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');
let found = (left_kind != right_kind && left_kind != CharKind::Whitespace)
|| at_newline;
found
})
}
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');
let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
|| at_newline;
found
})
}
Motion::FindForward { .. } => {
self.update_editor(window, cx, |_, editor, window, 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.set_tail(selection.head(), goal);
selection.set_head(movement::right(map, point), goal);
})
});
});
}
Motion::FindBackward { .. } => {
self.update_editor(window, cx, |_, editor, window, 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.set_tail(selection.head(), goal);
selection.set_head(point, goal);
})
});
});
}
_ => self.helix_move_and_collapse(motion, times, window, cx),
}
}
fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.update_editor(window, cx, |_, editor, window, 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_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(window, cx, |_, editor, window, 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(window, cx, |_, editor, window, cx| {
editor.transact(window, cx, |editor, window, cx| {
let (map, selections) = editor.selections.all_display(cx);
// 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(&map, Bias::Left);
let end_offset = range.end.to_offset(&map, Bias::Left);
let was_empty = range.is_empty();
let was_reversed = selection.reversed;
(
map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
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(&map, range.start);
}
let byte_range = range.start.to_offset(&map, Bias::Left)
..range.end.to_offset(&map, Bias::Left);
if !byte_range.is_empty() {
let replacement_text = text.repeat(byte_range.len());
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);
}
}
#[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.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;
// 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.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("2 T r");
cx.assert_state(
indoc! {"
The quick br«ˇown
fox jumps over
the laz»y dog."},
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_newline_char(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
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.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;
// 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;
// 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);
}
}