logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
co-authored by Claude Fable 5
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions
+737
View File
@@ -0,0 +1,737 @@
use crate::{
Vim,
motion::{self, Motion, MotionKind},
object::Object,
state::Mode,
};
use editor::{
Bias, DisplayPoint,
display_map::{DisplaySnapshot, ToDisplayPoint},
movement::TextLayoutDetails,
};
use gpui::{Context, Window};
use language::Selection;
impl Vim {
pub fn change_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Some motions ignore failure when switching to normal mode
let mut motion_kind = if matches!(
motion,
Motion::Left
| Motion::Right
| Motion::EndOfLine { .. }
| Motion::WrappingLeft
| Motion::StartOfLine { .. }
) {
Some(MotionKind::Exclusive)
} else {
None
};
self.update_editor(cx, |vim, editor, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
// We are swapping to insert mode anyway. Just set the line end clipping behavior now
editor.set_clip_at_line_ends(false, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let kind = match motion {
Motion::NextWordStart { ignore_punctuation }
| Motion::NextSubwordStart { ignore_punctuation } => {
expand_changed_word_selection(
map,
selection,
times,
ignore_punctuation,
&text_layout_details,
motion == Motion::NextSubwordStart { ignore_punctuation },
!matches!(motion, Motion::NextWordStart { .. }),
)
}
_ => {
let kind = motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
if matches!(
motion,
Motion::CurrentLine | Motion::Down { .. } | Motion::Up { .. }
) {
let mut start_offset =
selection.start.to_offset(map, Bias::Left);
let classifier = map
.buffer_snapshot()
.char_classifier_at(selection.start.to_point(map));
for (ch, offset) in map.buffer_chars_at(start_offset) {
if ch == '\n' || !classifier.is_whitespace(ch) {
break;
}
start_offset = offset + ch.len_utf8();
}
selection.start = start_offset.to_display_point(map);
}
kind
}
};
if let Some(kind) = kind {
motion_kind.get_or_insert(kind);
}
});
});
if let Some(kind) = motion_kind {
vim.copy_selections_content(editor, kind, window, cx);
editor.delete_selections_with_linked_edits(window, cx);
editor.refresh_edit_prediction(true, false, window, cx);
}
});
});
if motion_kind.is_some() {
self.switch_mode(Mode::Insert, false, window, cx)
} else {
self.switch_mode(Mode::Normal, false, window, cx)
}
}
pub fn change_object(
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut objects_found = false;
self.update_editor(cx, |vim, editor, cx| {
// We are swapping to insert mode anyway. Just set the line end clipping behavior now
editor.set_clip_at_line_ends(false, cx);
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
objects_found |= object.expand_selection(map, selection, around, times);
});
});
if objects_found {
let kind = match object.target_visual_mode(vim.mode, around) {
Mode::VisualLine => MotionKind::Linewise,
_ => MotionKind::Exclusive,
};
vim.copy_selections_content(editor, kind, window, cx);
editor.delete_selections_with_linked_edits(window, cx);
editor.refresh_edit_prediction(true, false, window, cx);
}
});
});
if objects_found {
self.switch_mode(Mode::Insert, false, window, cx);
} else {
self.switch_mode(Mode::Normal, false, window, cx);
}
}
}
// From the docs https://vimdoc.sourceforge.net/htmldoc/motion.html
// Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is
// on a non-blank. This is because "cw" is interpreted as change-word, and a
// word does not include the following white space. {Vi: "cw" when on a blank
// followed by other blanks changes only the first blank; this is probably a
// bug, because "dw" deletes all the blanks}
fn expand_changed_word_selection(
map: &DisplaySnapshot,
selection: &mut Selection<DisplayPoint>,
times: Option<usize>,
ignore_punctuation: bool,
text_layout_details: &TextLayoutDetails,
use_subword: bool,
always_advance: bool,
) -> Option<MotionKind> {
let is_in_word = || {
let classifier = map
.buffer_snapshot()
.char_classifier_at(selection.start.to_point(map));
map.buffer_chars_at(selection.head().to_offset(map, Bias::Left))
.next()
.map(|(c, _)| !classifier.is_whitespace(c))
.unwrap_or_default()
};
if (times.is_none() || times.unwrap() == 1) && is_in_word() {
let next_char = map
.buffer_chars_at(
motion::next_char(map, selection.end, false).to_offset(map, Bias::Left),
)
.next();
match next_char {
Some((' ', _)) => selection.end = motion::next_char(map, selection.end, false),
_ => {
if use_subword {
selection.end =
motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false);
} else {
selection.end = motion::next_word_end(
map,
selection.end,
ignore_punctuation,
1,
false,
always_advance,
);
}
selection.end = motion::next_char(map, selection.end, false);
}
}
Some(MotionKind::Inclusive)
} else {
let motion = if use_subword {
Motion::NextSubwordStart { ignore_punctuation }
} else {
Motion::NextWordStart { ignore_punctuation }
};
motion.expand_selection(map, selection, times, text_layout_details, false)
}
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::state::Mode;
use crate::test::{NeovimBackedTestContext, VimTestContext};
#[gpui::test]
async fn test_change_h(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c h", "Teˇst").await.assert_matches();
cx.simulate("c h", "Tˇest").await.assert_matches();
cx.simulate("c h", "ˇTest").await.assert_matches();
cx.simulate(
"c h",
indoc! {"
Test
ˇtest"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_backspace(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c backspace", "Teˇst").await.assert_matches();
cx.simulate("c backspace", "Tˇest").await.assert_matches();
cx.simulate("c backspace", "ˇTest").await.assert_matches();
cx.simulate(
"c backspace",
indoc! {"
Test
ˇtest"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_l(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c l", "Teˇst").await.assert_matches();
cx.simulate("c l", "Tesˇt").await.assert_matches();
}
#[gpui::test]
async fn test_change_w(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c w", "Teˇst").await.assert_matches();
cx.simulate("c w", "Tˇest test").await.assert_matches();
cx.simulate("c w", "Testˇ test").await.assert_matches();
cx.simulate("c w", "Tesˇt test").await.assert_matches();
cx.simulate(
"c w",
indoc! {"
Test teˇst
test"},
)
.await
.assert_matches();
cx.simulate(
"c w",
indoc! {"
Test tesˇt
test"},
)
.await
.assert_matches();
cx.simulate(
"c w",
indoc! {"
Test test
ˇ
test"},
)
.await
.assert_matches();
cx.simulate("c shift-w", "Test teˇst-test test")
.await
.assert_matches();
// on last character of word, `cw` doesn't eat subsequent punctuation
// see https://github.com/zed-industries/zed/issues/35269
cx.simulate("c w", "tesˇt-test").await.assert_matches();
}
#[gpui::test]
async fn test_change_e(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c e", "Teˇst Test").await.assert_matches();
cx.simulate("c e", "Tˇest test").await.assert_matches();
cx.simulate(
"c e",
indoc! {"
Test teˇst
test"},
)
.await
.assert_matches();
cx.simulate(
"c e",
indoc! {"
Test tesˇt
test"},
)
.await
.assert_matches();
cx.simulate(
"c e",
indoc! {"
Test test
ˇ
test"},
)
.await
.assert_matches();
cx.simulate("c shift-e", "Test teˇst-test test")
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_b(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("c b", "Teˇst Test").await.assert_matches();
cx.simulate("c b", "Test ˇtest").await.assert_matches();
cx.simulate("c b", "Test1 test2 ˇtest3")
.await
.assert_matches();
cx.simulate(
"c b",
indoc! {"
Test test
ˇtest"},
)
.await
.assert_matches();
cx.simulate(
"c b",
indoc! {"
Test test
ˇ
test"},
)
.await
.assert_matches();
cx.simulate("c shift-b", "Test test-test ˇtest")
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c $",
indoc! {"
The qˇuick
brown fox"},
)
.await
.assert_matches();
cx.simulate(
"c $",
indoc! {"
The quick
ˇ
brown fox"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_0(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c 0",
indoc! {"
The qˇuick
brown fox"},
)
.await
.assert_matches();
cx.simulate(
"c 0",
indoc! {"
The quick
ˇ
brown fox"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_k(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c k",
indoc! {"
The quick
brown ˇfox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"c k",
indoc! {"
The quick
brown fox
jumps ˇover"},
)
.await
.assert_matches();
cx.simulate(
"c k",
indoc! {"
The qˇuick
brown fox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"c k",
indoc! {"
ˇ
brown fox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"c k",
indoc! {"
The quick
brown fox
ˇjumps over"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_j(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c j",
indoc! {"
The quick
brown ˇfox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"c j",
indoc! {"
The quick
brown fox
jumps ˇover"},
)
.await
.assert_matches();
cx.simulate(
"c j",
indoc! {"
The qˇuick
brown fox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"c j",
indoc! {"
The quick
brown fox
ˇ"},
)
.await
.assert_matches();
cx.simulate(
"c j",
indoc! {"
The quick
ˇbrown fox
jumps over"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c shift-g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c shift-g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c shift-g",
indoc! {"
The quick
brown fox
jumps over
the lˇazy"},
)
.await
.assert_matches();
cx.simulate(
"c shift-g",
indoc! {"
The quick
brown fox
jumps over
ˇ"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_cc(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c c",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c c",
indoc! {"
ˇThe quick
brown fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c c",
indoc! {"
The quick
broˇwn fox
jumps over
the lazy"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_change_gg(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"c g g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c g g",
indoc! {"
The quick
brown fox
jumps over
the lˇazy"},
)
.await
.assert_matches();
cx.simulate(
"c g g",
indoc! {"
The qˇuick
brown fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"c g g",
indoc! {"
ˇ
brown fox
jumps over
the lazy"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
for count in 1..=5 {
cx.simulate_at_each_offset(
&format!("c {count} j"),
indoc! {"
ˇThe quˇickˇ browˇn
ˇ
ˇfox ˇjumpsˇ-ˇoˇver
ˇthe lazy dog
"},
)
.await
.assert_matches();
}
}
#[gpui::test]
async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
for count in 1..=5 {
cx.simulate_at_each_offset(
&format!("c {count} l"),
indoc! {"
ˇThe quˇickˇ browˇn
ˇ
ˇfox ˇjumpsˇ-ˇoˇver
ˇthe lazy dog
"},
)
.await
.assert_matches();
}
}
#[gpui::test]
async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
for count in 1..=5 {
cx.simulate_at_each_offset(
&format!("c {count} b"),
indoc! {"
ˇThe quˇickˇ browˇn
ˇ
ˇfox ˇjumpsˇ-ˇoˇver
ˇthe lazy dog
"},
)
.await
.assert_matches()
}
}
#[gpui::test]
async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
for count in 1..=5 {
cx.simulate_at_each_offset(
&format!("c {count} e"),
indoc! {"
ˇThe quˇickˇ browˇn
ˇ
ˇfox ˇjumpsˇ-ˇoˇver
ˇthe lazy dog
"},
)
.await
.assert_matches();
}
}
#[gpui::test]
async fn test_change_with_selection_spanning_expanded_diff_hunk(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
let diff_base = indoc! {"
fn main() {
println!(\"old\");
}
"};
cx.set_state(
indoc! {"
fn main() {
ˇprintln!(\"new\");
}
"},
Mode::Normal,
);
cx.set_head_text(diff_base);
cx.update_editor(|editor, window, cx| {
editor.expand_all_diff_hunks(&editor::actions::ExpandAllDiffHunks, window, cx);
});
// Enter visual mode and move up so the selection spans from the
// insertion (current line) into the deletion (diff base line).
// Then press `c` which in visual mode dispatches `vim::Substitute`,
// performing the change operation across the insertion/deletion boundary.
cx.simulate_keystrokes("v k c");
}
}
+469
View File
@@ -0,0 +1,469 @@
use collections::HashMap;
use editor::{SelectionEffects, display_map::ToDisplayPoint};
use gpui::{Context, Window};
use language::{Bias, Point, SelectionGoal};
use multi_buffer::MultiBufferRow;
use crate::{
Vim,
motion::Motion,
normal::{ChangeCase, ConvertToLowerCase, ConvertToRot13, ConvertToRot47, ConvertToUpperCase},
object::Object,
state::Mode,
};
pub enum ConvertTarget {
LowerCase,
UpperCase,
OppositeCase,
Rot13,
Rot47,
}
impl Vim {
pub fn convert_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
mode: ConvertTarget,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
editor.set_clip_at_line_ends(false, cx);
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
let mut selection_starts: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Left);
selection_starts.insert(selection.id, anchor);
motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
});
});
match mode {
ConvertTarget::LowerCase => {
editor.convert_to_lower_case(&Default::default(), window, cx)
}
ConvertTarget::UpperCase => {
editor.convert_to_upper_case(&Default::default(), window, cx)
}
ConvertTarget::OppositeCase => {
editor.convert_to_opposite_case(&Default::default(), window, cx)
}
ConvertTarget::Rot13 => {
editor.convert_to_rot13(&Default::default(), window, cx)
}
ConvertTarget::Rot47 => {
editor.convert_to_rot47(&Default::default(), window, cx)
}
}
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = selection_starts.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
});
editor.set_clip_at_line_ends(true, cx);
});
}
pub fn convert_object(
&mut self,
object: Object,
around: bool,
mode: ConvertTarget,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
object.expand_selection(map, selection, around, times);
original_positions.insert(
selection.id,
map.display_point_to_anchor(selection.start, Bias::Left),
);
});
});
match mode {
ConvertTarget::LowerCase => {
editor.convert_to_lower_case(&Default::default(), window, cx)
}
ConvertTarget::UpperCase => {
editor.convert_to_upper_case(&Default::default(), window, cx)
}
ConvertTarget::OppositeCase => {
editor.convert_to_opposite_case(&Default::default(), window, cx)
}
ConvertTarget::Rot13 => {
editor.convert_to_rot13(&Default::default(), window, cx)
}
ConvertTarget::Rot47 => {
editor.convert_to_rot47(&Default::default(), window, cx)
}
}
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = original_positions.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
editor.set_clip_at_line_ends(true, cx);
});
});
}
pub fn change_case(&mut self, _: &ChangeCase, window: &mut Window, cx: &mut Context<Self>) {
self.manipulate_text(window, cx, |c| {
if c.is_lowercase() {
c.to_uppercase().collect::<Vec<char>>()
} else {
c.to_lowercase().collect::<Vec<char>>()
}
})
}
pub fn convert_to_upper_case(
&mut self,
_: &ConvertToUpperCase,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.manipulate_text(window, cx, |c| c.to_uppercase().collect::<Vec<char>>())
}
pub fn convert_to_lower_case(
&mut self,
_: &ConvertToLowerCase,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.manipulate_text(window, cx, |c| c.to_lowercase().collect::<Vec<char>>())
}
pub fn convert_to_rot13(
&mut self,
_: &ConvertToRot13,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.manipulate_text(window, cx, |c| {
vec![match c {
'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char,
'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char,
_ => c,
}]
})
}
pub fn convert_to_rot47(
&mut self,
_: &ConvertToRot47,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.manipulate_text(window, cx, |c| {
let code_point = c as u32;
if code_point >= 33 && code_point <= 126 {
return vec![char::from_u32(33 + ((code_point + 14) % 94)).unwrap()];
}
vec![c]
})
}
fn manipulate_text<F>(&mut self, window: &mut Window, cx: &mut Context<Self>, transform: F)
where
F: Fn(char) -> Vec<char> + Copy,
{
self.record_current_action(cx);
self.store_visual_marks(window, cx);
let count = Vim::take_count(cx).unwrap_or(1) as u32;
Vim::take_forced_motion(cx);
self.update_editor(cx, |vim, editor, cx| {
let mut ranges = Vec::new();
let mut cursor_positions = Vec::new();
let snapshot = editor.buffer().read(cx).snapshot(cx);
for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) {
match vim.mode {
Mode::Visual | Mode::VisualLine => {
ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start);
}
Mode::VisualBlock => {
ranges.push(selection.start..selection.end);
if cursor_positions.is_empty() {
cursor_positions.push(selection.start..selection.start);
}
}
Mode::HelixNormal | Mode::HelixSelect => {
if selection.is_empty() {
// Handle empty selection by operating on single character
let start = selection.start;
let end = snapshot.clip_point(start + Point::new(0, 1), Bias::Right);
ranges.push(start..end);
cursor_positions.push(selection.start..selection.start);
} else {
ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.end);
}
}
Mode::Insert | Mode::Normal | Mode::Replace => {
let start = selection.start;
let mut end = start;
for _ in 0..count {
end = snapshot.clip_point(end + Point::new(0, 1), Bias::Right);
}
ranges.push(start..end);
if end.column == snapshot.line_len(MultiBufferRow(end.row))
&& end.column > 0
{
end = snapshot.clip_point(end - Point::new(0, 1), Bias::Left);
}
cursor_positions.push(end..end)
}
}
}
editor.transact(window, cx, |editor, window, cx| {
for range in ranges.into_iter().rev() {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let text = snapshot
.text_for_range(range.start..range.end)
.flat_map(|s| s.chars())
.flat_map(transform)
.collect::<String>();
editor.edit([(range, text)], cx)
}
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(cursor_positions)
})
});
});
if self.mode != Mode::HelixNormal {
self.switch_mode(Mode::Normal, true, window, cx)
}
}
}
#[cfg(test)]
mod test {
use crate::test::VimTestContext;
use crate::{state::Mode, test::NeovimBackedTestContext};
#[gpui::test]
async fn test_change_case(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇabC\n").await;
cx.simulate_shared_keystrokes("~").await;
cx.shared_state().await.assert_eq("AˇbC\n");
cx.simulate_shared_keystrokes("2 ~").await;
cx.shared_state().await.assert_eq("ABˇc\n");
// works in visual mode
cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
cx.simulate_shared_keystrokes("~").await;
cx.shared_state().await.assert_eq("a😀CˇDé1*F\n");
// works with multibyte characters
cx.simulate_shared_keystrokes("~").await;
cx.set_shared_state("aˇC😀é1*F\n").await;
cx.simulate_shared_keystrokes("4 ~").await;
cx.shared_state().await.assert_eq("ac😀É1ˇ*F\n");
// works with line selections
cx.set_shared_state("abˇC\n").await;
cx.simulate_shared_keystrokes("shift-v ~").await;
cx.shared_state().await.assert_eq("ˇABc\n");
// works in visual block mode
cx.set_shared_state("ˇaa\nbb\ncc").await;
cx.simulate_shared_keystrokes("ctrl-v j ~").await;
cx.shared_state().await.assert_eq("ˇAa\nBb\ncc");
// works with multiple cursors (zed only)
cx.set_state("aˇßcdˇe\n", Mode::Normal);
cx.simulate_keystrokes("~");
cx.assert_state("aSSˇcdˇE\n", Mode::Normal);
}
#[gpui::test]
async fn test_convert_to_upper_case(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
// works in visual mode
cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
cx.simulate_shared_keystrokes("shift-u").await;
cx.shared_state().await.assert_eq("a😀CˇDÉ1*F\n");
// works with line selections
cx.set_shared_state("abˇC\n").await;
cx.simulate_shared_keystrokes("shift-v shift-u").await;
cx.shared_state().await.assert_eq("ˇABC\n");
// works in visual block mode
cx.set_shared_state("ˇaa\nbb\ncc").await;
cx.simulate_shared_keystrokes("ctrl-v j shift-u").await;
cx.shared_state().await.assert_eq("ˇAa\nBb\ncc");
}
#[gpui::test]
async fn test_convert_to_lower_case(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
// works in visual mode
cx.set_shared_state("A😀c«DÉ1*fˇ»\n").await;
cx.simulate_shared_keystrokes("u").await;
cx.shared_state().await.assert_eq("A😀cˇdé1*f\n");
// works with line selections
cx.set_shared_state("ABˇc\n").await;
cx.simulate_shared_keystrokes("shift-v u").await;
cx.shared_state().await.assert_eq("ˇabc\n");
// works in visual block mode
cx.set_shared_state("ˇAa\nBb\nCc").await;
cx.simulate_shared_keystrokes("ctrl-v j u").await;
cx.shared_state().await.assert_eq("ˇaa\nbb\nCc");
}
#[gpui::test]
async fn test_change_case_motion(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇabc def").await;
cx.simulate_shared_keystrokes("g shift-u w").await;
cx.shared_state().await.assert_eq("ˇABC def");
cx.simulate_shared_keystrokes("g u w").await;
cx.shared_state().await.assert_eq("ˇabc def");
cx.simulate_shared_keystrokes("g ~ w").await;
cx.shared_state().await.assert_eq("ˇABC def");
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq("ˇabc def");
cx.set_shared_state("abˇc def").await;
cx.simulate_shared_keystrokes("g ~ i w").await;
cx.shared_state().await.assert_eq("ˇABC def");
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq("ˇabc def");
cx.simulate_shared_keystrokes("g shift-u $").await;
cx.shared_state().await.assert_eq("ˇABC DEF");
}
#[gpui::test]
async fn test_change_case_motion_object(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("abc dˇef\n").await;
cx.simulate_shared_keystrokes("g shift-u i w").await;
cx.shared_state().await.assert_eq("abc ˇDEF\n");
}
#[gpui::test]
async fn test_convert_to_rot13(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
// works in visual mode
cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
cx.simulate_shared_keystrokes("g ?").await;
cx.shared_state().await.assert_eq("a😀CˇqÉ1*s\n");
// works with line selections
cx.set_shared_state("abˇC\n").await;
cx.simulate_shared_keystrokes("shift-v g ?").await;
cx.shared_state().await.assert_eq("ˇnoP\n");
// works in visual block mode
cx.set_shared_state("ˇaa\nbb\ncc").await;
cx.simulate_shared_keystrokes("ctrl-v j g ?").await;
cx.shared_state().await.assert_eq("ˇna\nob\ncc");
}
#[gpui::test]
async fn test_change_rot13_motion(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇabc def").await;
cx.simulate_shared_keystrokes("g ? w").await;
cx.shared_state().await.assert_eq("ˇnop def");
cx.simulate_shared_keystrokes("g ? w").await;
cx.shared_state().await.assert_eq("ˇabc def");
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq("ˇnop def");
cx.set_shared_state("abˇc def").await;
cx.simulate_shared_keystrokes("g ? i w").await;
cx.shared_state().await.assert_eq("ˇnop def");
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq("ˇabc def");
cx.simulate_shared_keystrokes("g ? $").await;
cx.shared_state().await.assert_eq("ˇnop qrs");
}
#[gpui::test]
async fn test_change_rot13_object(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
.await;
cx.simulate_shared_keystrokes("g ? i w").await;
cx.shared_state()
.await
.assert_eq("ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM");
}
#[gpui::test]
async fn test_change_case_helix_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// Explicit selection
cx.set_state("«hello worldˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("~");
cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal);
// Cursor-only (empty) selection - switch case
cx.set_state("The ˇquick brown", Mode::HelixNormal);
cx.simulate_keystrokes("~");
cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
cx.simulate_keystrokes("~");
cx.assert_state("The ˇquick brown", Mode::HelixNormal);
// Cursor-only (empty) selection - switch to uppercase and lowercase explicitly
cx.set_state("The ˇquick brown", Mode::HelixNormal);
cx.simulate_keystrokes("alt-`");
cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
cx.simulate_keystrokes("`");
cx.assert_state("The ˇquick brown", Mode::HelixNormal);
// With `e` motion (which extends selection to end of word in Helix)
cx.set_state("The ˇquick brown fox", Mode::HelixNormal);
cx.simulate_keystrokes("e");
cx.simulate_keystrokes("~");
cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal);
// Cursor-only
}
}
+763
View File
@@ -0,0 +1,763 @@
use crate::{
Vim,
motion::{Motion, MotionKind},
object::Object,
state::Mode,
};
use collections::{HashMap, HashSet};
use editor::{
Bias, DisplayPoint,
display_map::{DisplaySnapshot, ToDisplayPoint},
};
use gpui::{Context, Window};
use language::{Point, Selection};
use multi_buffer::MultiBufferRow;
impl Vim {
pub fn delete_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |vim, editor, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_columns: HashMap<_, _> = Default::default();
let mut motion_kind = None;
let mut ranges_to_copy = Vec::new();
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let original_head = selection.head();
original_columns.insert(selection.id, original_head.column());
let kind = motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
ranges_to_copy
.push(selection.start.to_point(map)..selection.end.to_point(map));
// When deleting line-wise, we always want to delete a newline.
// If there is one after the current line, it goes; otherwise we
// pick the one before.
if kind == Some(MotionKind::Linewise) {
let start = selection.start.to_point(map);
let end = selection.end.to_point(map);
if end.row < map.buffer_snapshot().max_point().row {
selection.end = Point::new(end.row + 1, 0).to_display_point(map)
} else if start.row > 0 {
selection.start = Point::new(
start.row - 1,
map.buffer_snapshot()
.line_len(MultiBufferRow(start.row - 1)),
)
.to_display_point(map)
}
}
if let Some(kind) = kind {
motion_kind.get_or_insert(kind);
}
});
});
let Some(kind) = motion_kind else { return };
vim.copy_ranges(editor, kind, false, ranges_to_copy, window, cx);
editor.delete_selections_with_linked_edits(window, cx);
// Fixup cursor position after the deletion
editor.set_clip_at_line_ends(true, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let mut cursor = selection.head();
if kind.linewise()
&& let Some(column) = original_columns.get(&selection.id)
{
*cursor.column_mut() = *column
}
cursor = map.clip_point(cursor, Bias::Left);
selection.collapse_to(cursor, selection.goal)
});
});
editor.refresh_edit_prediction(true, false, window, cx);
});
});
}
pub fn delete_object(
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |vim, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
// Emulates behavior in vim where if we expanded backwards to include a newline
// the cursor gets set back to the start of the line
let mut should_move_to_start: HashSet<_> = Default::default();
// Emulates behavior in vim where after deletion the cursor should try to move
// to the same column it was before deletion if the line is not empty or only
// contains whitespace
let mut column_before_move: HashMap<_, _> = Default::default();
let target_mode = object.target_visual_mode(vim.mode, around);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let cursor_point = selection.head().to_point(map);
if target_mode == Mode::VisualLine {
column_before_move.insert(selection.id, cursor_point.column);
}
object.expand_selection(map, selection, around, times);
let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
let mut move_selection_start_to_previous_line =
|map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>| {
let start = selection.start.to_offset(map, Bias::Left);
if selection.start.row().0 > 0 {
should_move_to_start.insert(selection.id);
selection.start =
(start - '\n'.len_utf8()).to_display_point(map);
}
};
let range = selection.start.to_offset(map, Bias::Left)
..selection.end.to_offset(map, Bias::Right);
let contains_only_newlines = map
.buffer_chars_at(range.start)
.take_while(|(_, p)| p < &range.end)
.all(|(char, _)| char == '\n')
&& !offset_range.is_empty();
let end_at_newline = map
.buffer_chars_at(range.end)
.next()
.map(|(c, _)| c == '\n')
.unwrap_or(false);
// If expanded range contains only newlines and
// the object is around or sentence, expand to include a newline
// at the end or start
if (around || object == Object::Sentence) && contains_only_newlines {
if end_at_newline {
move_selection_end_to_next_line(map, selection);
} else {
move_selection_start_to_previous_line(map, selection);
}
}
// Does post-processing for the trailing newline and EOF
// when not cancelled.
let cancelled = around && selection.start == selection.end;
if object == Object::Paragraph && !cancelled {
// EOF check should be done before including a trailing newline.
if ends_at_eof(map, selection) {
move_selection_start_to_previous_line(map, selection);
}
if end_at_newline {
move_selection_end_to_next_line(map, selection);
}
}
});
});
vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
editor.delete_selections_with_linked_edits(window, cx);
// Fixup cursor position after the deletion
editor.set_clip_at_line_ends(true, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let mut cursor = selection.head();
if should_move_to_start.contains(&selection.id) {
*cursor.column_mut() = 0;
} else if let Some(column) = column_before_move.get(&selection.id)
&& *column > 0
{
let mut cursor_point = cursor.to_point(map);
cursor_point.column = *column;
cursor = map
.buffer_snapshot()
.clip_point(cursor_point, Bias::Left)
.to_display_point(map);
}
cursor = map.clip_point(cursor, Bias::Left);
selection.collapse_to(cursor, selection.goal)
});
});
editor.refresh_edit_prediction(true, false, window, cx);
});
});
}
}
fn move_selection_end_to_next_line(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
let end = selection.end.to_offset(map, Bias::Left);
selection.end = (end + '\n'.len_utf8()).to_display_point(map);
}
fn ends_at_eof(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) -> bool {
selection.end.to_point(map) == map.buffer_snapshot().max_point()
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::{
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
#[gpui::test]
async fn test_delete_h(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("d h", "Teˇst").await.assert_matches();
cx.simulate("d h", "Tˇest").await.assert_matches();
cx.simulate("d h", "ˇTest").await.assert_matches();
cx.simulate(
"d h",
indoc! {"
Test
ˇtest"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_l(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("d l", "ˇTest").await.assert_matches();
cx.simulate("d l", "Teˇst").await.assert_matches();
cx.simulate("d l", "Tesˇt").await.assert_matches();
cx.simulate(
"d l",
indoc! {"
Tesˇt
test"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_w(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d w",
indoc! {"
Test tesˇt
test"},
)
.await
.assert_matches();
cx.simulate("d w", "Teˇst").await.assert_matches();
cx.simulate("d w", "Tˇest test").await.assert_matches();
cx.simulate(
"d w",
indoc! {"
Test teˇst
test"},
)
.await
.assert_matches();
cx.simulate(
"d w",
indoc! {"
Test tesˇt
test"},
)
.await
.assert_matches();
cx.simulate(
"d w",
indoc! {"
Test test
ˇ
test"},
)
.await
.assert_matches();
cx.simulate("d shift-w", "Test teˇst-test test")
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_next_word_end(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("d e", "Teˇst Test\n").await.assert_matches();
cx.simulate("d e", "Tˇest test\n").await.assert_matches();
cx.simulate(
"d e",
indoc! {"
Test teˇst
test"},
)
.await
.assert_matches();
cx.simulate(
"d e",
indoc! {"
Test tesˇt
test"},
)
.await
.assert_matches();
cx.simulate("d e", "Test teˇst-test test")
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_b(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("d b", "Teˇst Test").await.assert_matches();
cx.simulate("d b", "Test ˇtest").await.assert_matches();
cx.simulate("d b", "Test1 test2 ˇtest3")
.await
.assert_matches();
cx.simulate(
"d b",
indoc! {"
Test test
ˇtest"},
)
.await
.assert_matches();
cx.simulate(
"d b",
indoc! {"
Test test
ˇ
test"},
)
.await
.assert_matches();
cx.simulate("d shift-b", "Test test-test ˇtest")
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_end_of_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d $",
indoc! {"
The qˇuick
brown fox"},
)
.await
.assert_matches();
cx.simulate(
"d $",
indoc! {"
The quick
ˇ
brown fox"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_end_of_paragraph(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d }",
indoc! {"
ˇhello world.
hello world."},
)
.await
.assert_matches();
cx.simulate(
"d }",
indoc! {"
ˇhello world.
hello world."},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_0(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d 0",
indoc! {"
The qˇuick
brown fox"},
)
.await
.assert_matches();
cx.simulate(
"d 0",
indoc! {"
The quick
ˇ
brown fox"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_k(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d k",
indoc! {"
The quick
brown ˇfox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"d k",
indoc! {"
The quick
brown fox
jumps ˇover"},
)
.await
.assert_matches();
cx.simulate(
"d k",
indoc! {"
The qˇuick
brown fox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"d k",
indoc! {"
ˇbrown fox
jumps over"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_j(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d j",
indoc! {"
The quick
brown ˇfox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"d j",
indoc! {"
The quick
brown fox
jumps ˇover"},
)
.await
.assert_matches();
cx.simulate(
"d j",
indoc! {"
The qˇuick
brown fox
jumps over"},
)
.await
.assert_matches();
cx.simulate(
"d j",
indoc! {"
The quick
brown fox
ˇ"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d shift-g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"d shift-g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"d shift-g",
indoc! {"
The quick
brown fox
jumps over
the lˇazy"},
)
.await
.assert_matches();
cx.simulate(
"d shift-g",
indoc! {"
The quick
brown fox
jumps over
ˇ"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_to_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d 3 shift-g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"d 3 shift-g",
indoc! {"
The quick
brown fox
jumps over
the lˇazy"},
)
.await
.assert_matches();
cx.simulate(
"d 2 shift-g",
indoc! {"
The quick
brown fox
jumps over
ˇ"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_delete_gg(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"d g g",
indoc! {"
The quick
brownˇ fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"d g g",
indoc! {"
The quick
brown fox
jumps over
the lˇazy"},
)
.await
.assert_matches();
cx.simulate(
"d g g",
indoc! {"
The qˇuick
brown fox
jumps over
the lazy"},
)
.await
.assert_matches();
cx.simulate(
"d g g",
indoc! {"
ˇ
brown fox
jumps over
the lazy"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_cancel_delete_operator(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
indoc! {"
The quick brown
fox juˇmps over
the lazy dog"},
Mode::Normal,
);
// Canceling operator twice reverts to normal mode with no active operator
cx.simulate_keystrokes("d escape k");
assert_eq!(cx.active_operator(), None);
assert_eq!(cx.mode(), Mode::Normal);
cx.assert_editor_state(indoc! {"
The quˇick brown
fox jumps over
the lazy dog"});
}
#[gpui::test]
async fn test_unbound_command_cancels_pending_operator(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
indoc! {"
The quick brown
fox juˇmps over
the lazy dog"},
Mode::Normal,
);
// Canceling operator twice reverts to normal mode with no active operator
cx.simulate_keystrokes("d y");
assert_eq!(cx.active_operator(), None);
assert_eq!(cx.mode(), Mode::Normal);
}
#[gpui::test]
async fn test_delete_with_counts(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
The ˇquick brown
fox jumps over
the lazy dog"})
.await;
cx.simulate_shared_keystrokes("d 2 d").await;
cx.shared_state().await.assert_eq(indoc! {"
the ˇlazy dog"});
cx.set_shared_state(indoc! {"
The ˇquick brown
fox jumps over
the lazy dog"})
.await;
cx.simulate_shared_keystrokes("2 d d").await;
cx.shared_state().await.assert_eq(indoc! {"
the ˇlazy dog"});
cx.set_shared_state(indoc! {"
The ˇquick brown
fox jumps over
the moon,
a star, and
the lazy dog"})
.await;
cx.simulate_shared_keystrokes("2 d 2 d").await;
cx.shared_state().await.assert_eq(indoc! {"
the ˇlazy dog"});
}
#[gpui::test]
async fn test_delete_to_adjacent_character(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("d t x", "ˇax").await.assert_matches();
cx.simulate("d t x", "aˇx").await.assert_matches();
}
#[gpui::test]
async fn test_delete_sentence(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
// cx.simulate(
// "d )",
// indoc! {"
// Fiˇrst. Second. Third.
// Fourth.
// "},
// )
// .await
// .assert_matches();
// cx.simulate(
// "d )",
// indoc! {"
// First. Secˇond. Third.
// Fourth.
// "},
// )
// .await
// .assert_matches();
// // Two deletes
// cx.simulate(
// "d ) d )",
// indoc! {"
// First. Second. Thirˇd.
// Fourth.
// "},
// )
// .await
// .assert_matches();
// Should delete whole line if done on first column
cx.simulate(
"d )",
indoc! {"
ˇFirst.
Fourth.
"},
)
.await
.assert_matches();
// Backwards it should also delete the whole first line
cx.simulate(
"d (",
indoc! {"
First.
ˇSecond.
Fourth.
"},
)
.await
.assert_matches();
}
}
+913
View File
@@ -0,0 +1,913 @@
use editor::{Editor, MultiBufferSnapshot, ToOffset, ToPoint};
use gpui::{Action, Context, Window};
use language::{Bias, Point};
use schemars::JsonSchema;
use serde::Deserialize;
use std::ops::Range;
use crate::{Vim, state::Mode};
const BOOLEAN_PAIRS: &[(&str, &str)] = &[("true", "false"), ("yes", "no"), ("on", "off")];
/// Increments the number under the cursor or toggles boolean values.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct Increment {
#[serde(default)]
step: bool,
}
/// Decrements the number under the cursor or toggles boolean values.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct Decrement {
#[serde(default)]
step: bool,
}
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, action: &Increment, window, cx| {
vim.record_current_action(cx);
let count = Vim::take_count(cx).unwrap_or(1);
Vim::take_forced_motion(cx);
let step = if action.step { count as i32 } else { 0 };
vim.increment(count as i64, step, window, cx)
});
Vim::action(editor, cx, |vim, action: &Decrement, window, cx| {
vim.record_current_action(cx);
let count = Vim::take_count(cx).unwrap_or(1);
Vim::take_forced_motion(cx);
let step = if action.step { -1 * (count as i32) } else { 0 };
vim.increment(-(count as i64), step, window, cx)
});
}
impl Vim {
fn increment(
&mut self,
mut delta: i64,
step: i32,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.store_visual_marks(window, cx);
self.update_editor(cx, |vim, editor, cx| {
let mut edits = Vec::new();
let mut new_anchors = Vec::new();
let snapshot = editor.buffer().read(cx).snapshot(cx);
for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) {
if !selection.is_empty()
&& (vim.mode != Mode::VisualBlock || new_anchors.is_empty())
{
new_anchors.push((true, snapshot.anchor_before(selection.start)))
}
for row in selection.start.row..=selection.end.row {
let start = if row == selection.start.row {
selection.start
} else {
Point::new(row, 0)
};
let end = if row == selection.end.row {
selection.end
} else {
Point::new(row, snapshot.line_len(multi_buffer::MultiBufferRow(row)))
};
let find_result = if !selection.is_empty() {
find_target(&snapshot, start, end, true)
} else {
find_target(&snapshot, start, end, false)
};
if let Some((range, target, radix)) = find_result {
let replace = match radix {
10 => increment_decimal_string(&target, delta),
16 => increment_hex_string(&target, delta),
2 => increment_binary_string(&target, delta),
0 => increment_toggle_string(&target),
_ => unreachable!(),
};
delta += step as i64;
edits.push((range.clone(), replace));
if selection.is_empty() {
new_anchors.push((false, snapshot.anchor_after(range.end)))
}
} else if selection.is_empty() {
new_anchors.push((true, snapshot.anchor_after(start)))
}
}
}
editor.transact(window, cx, |editor, window, cx| {
editor.edit(edits, cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor.change_selections(Default::default(), window, cx, |s| {
let mut new_ranges = Vec::new();
for (visual, anchor) in new_anchors.iter() {
let mut point = anchor.to_point(&snapshot);
if !*visual && point.column > 0 {
point.column -= 1;
point = snapshot.clip_point(point, Bias::Left)
}
new_ranges.push(point..point);
}
s.select_ranges(new_ranges)
})
});
});
self.switch_mode(Mode::Normal, true, window, cx)
}
}
fn increment_decimal_string(num: &str, delta: i64) -> String {
let (negative, delta, num_str) = match num.strip_prefix('-') {
Some(n) => (true, -delta, n),
None => (false, delta, num),
};
let num_length = num_str.len();
let leading_zero = num_str.starts_with('0');
let (result, new_negative) = match u64::from_str_radix(num_str, 10) {
Ok(value) => {
let wrapped = value.wrapping_add_signed(delta);
if delta < 0 && wrapped > value {
((u64::MAX - wrapped).wrapping_add(1), !negative)
} else if delta > 0 && wrapped < value {
(u64::MAX - wrapped, !negative)
} else {
(wrapped, negative)
}
}
Err(_) => (u64::MAX, negative),
};
let formatted = format!("{}", result);
let new_significant_digits = formatted.len();
let padding = if leading_zero {
num_length.saturating_sub(new_significant_digits)
} else {
0
};
if new_negative && result != 0 {
format!("-{}{}", "0".repeat(padding), formatted)
} else {
format!("{}{}", "0".repeat(padding), formatted)
}
}
fn increment_hex_string(num: &str, delta: i64) -> String {
let result = if let Ok(val) = u64::from_str_radix(num, 16) {
val.wrapping_add_signed(delta)
} else {
u64::MAX
};
if should_use_lowercase(num) {
format!("{:0width$x}", result, width = num.len())
} else {
format!("{:0width$X}", result, width = num.len())
}
}
fn should_use_lowercase(num: &str) -> bool {
let mut use_uppercase = false;
for ch in num.chars() {
if ch.is_ascii_lowercase() {
return true;
}
if ch.is_ascii_uppercase() {
use_uppercase = true;
}
}
!use_uppercase
}
fn increment_binary_string(num: &str, delta: i64) -> String {
let result = if let Ok(val) = u64::from_str_radix(num, 2) {
val.wrapping_add_signed(delta)
} else {
u64::MAX
};
format!("{:0width$b}", result, width = num.len())
}
fn find_target(
snapshot: &MultiBufferSnapshot,
start: Point,
end: Point,
need_range: bool,
) -> Option<(Range<Point>, String, u32)> {
let start_offset = start.to_offset(snapshot);
let end_offset = end.to_offset(snapshot);
let mut first_char_is_num = snapshot
.chars_at(start_offset)
.next()
.map_or(false, |ch| ch.is_ascii_hexdigit());
let mut pre_char = String::new();
let next_offset = start_offset
+ snapshot
.chars_at(start_offset)
.next()
.map_or(0, |ch| ch.len_utf8());
// Backward scan to find the start of the number, but stop at start_offset.
// We track `offset` as the start position of the current character. Initialize
// to `next_offset` and decrement at the start of each iteration so that `offset`
// always lands on a valid character boundary (not in the middle of a multibyte char).
let mut offset = next_offset;
for ch in snapshot.reversed_chars_at(next_offset) {
offset -= ch.len_utf8();
// Search boundaries
if offset.0 == 0 || ch.is_whitespace() || (need_range && offset <= start_offset) {
break;
}
// Avoid the influence of hexadecimal letters
if first_char_is_num
&& !ch.is_ascii_hexdigit()
&& (ch != 'b' && ch != 'B')
&& (ch != 'x' && ch != 'X')
&& ch != '-'
{
// Used to determine if the initial character is a number.
if is_numeric_string(&pre_char) {
break;
} else {
first_char_is_num = false;
}
}
pre_char.insert(0, ch);
}
// The backward scan breaks on whitespace, including newlines. Without this
// skip, the forward scan would start on the newline and immediately break
// (since it also breaks on newlines), finding nothing on the current line.
if let Some(ch) = snapshot.chars_at(offset).next() {
if ch == '\n' {
offset += ch.len_utf8();
}
}
let mut begin = None;
let mut end = None;
let mut target = String::new();
let mut radix = 10;
let mut is_num = false;
let mut chars = snapshot.chars_at(offset).peekable();
while let Some(ch) = chars.next() {
if need_range && offset >= end_offset {
break; // stop at end of selection
}
if target == "0"
&& (ch == 'b' || ch == 'B')
&& chars.peek().is_some()
&& chars.peek().unwrap().is_digit(2)
{
radix = 2;
begin = None;
target = String::new();
} else if target == "0"
&& (ch == 'x' || ch == 'X')
&& chars.peek().is_some()
&& chars.peek().unwrap().is_ascii_hexdigit()
{
radix = 16;
begin = None;
target = String::new();
} else if ch == '.' {
// When the cursor is on a number followed by a dot and a non-digit
// (`ˇ1. item`), terminate the match so the number is incrementable.
// Without this, the dot unconditionally resets the scan and the
// number is skipped. We only do this when the cursor is on the
// number, when it's past (`111.ˇ.2`), we still reset so the forward
// scan can find the number after the dots.
let next_is_non_digit = chars.peek().map_or(true, |char| !char.is_digit(radix));
let on_number =
is_num && begin.is_some_and(|begin| begin >= start_offset || start_offset < offset);
if on_number && next_is_non_digit {
end = Some(offset);
break;
}
is_num = false;
begin = None;
target = String::new();
} else if ch.is_digit(radix)
|| ((begin.is_none() || !is_num)
&& ch == '-'
&& chars.peek().is_some()
&& chars.peek().unwrap().is_digit(radix))
{
if !is_num {
is_num = true;
begin = Some(offset);
target = String::new();
} else if begin.is_none() {
begin = Some(offset);
}
target.push(ch);
} else if ch.is_ascii_alphabetic() && !is_num {
if begin.is_none() {
begin = Some(offset);
}
target.push(ch);
} else if begin.is_some() && (is_num || !is_num && is_toggle_word(&target)) {
// End of matching
end = Some(offset);
break;
} else if ch == '\n' {
break;
} else {
// To match the next word
is_num = false;
begin = None;
target = String::new();
}
offset += ch.len_utf8();
}
if let Some(begin) = begin
&& (is_num || !is_num && is_toggle_word(&target))
{
if !is_num {
radix = 0;
}
let end = end.unwrap_or(offset);
Some((
begin.to_point(snapshot)..end.to_point(snapshot),
target,
radix,
))
} else {
None
}
}
fn is_numeric_string(s: &str) -> bool {
if s.is_empty() {
return false;
}
let (_, rest) = if let Some(r) = s.strip_prefix('-') {
(true, r)
} else {
(false, s)
};
if rest.is_empty() {
return false;
}
if let Some(digits) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
digits.is_empty() || digits.chars().all(|c| c == '0' || c == '1')
} else if let Some(digits) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
digits.is_empty() || digits.chars().all(|c| c.is_ascii_hexdigit())
} else {
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
}
}
fn is_toggle_word(word: &str) -> bool {
let lower = word.to_lowercase();
BOOLEAN_PAIRS
.iter()
.any(|(a, b)| lower == *a || lower == *b)
}
fn increment_toggle_string(boolean: &str) -> String {
let lower = boolean.to_lowercase();
let target = BOOLEAN_PAIRS
.iter()
.find_map(|(a, b)| {
if lower == *a {
Some(b)
} else if lower == *b {
Some(a)
} else {
None
}
})
.unwrap_or(&boolean);
if boolean.chars().all(|c| c.is_uppercase()) {
// Upper case
target.to_uppercase()
} else if boolean.chars().next().unwrap_or(' ').is_uppercase() {
// Title case
let mut chars = target.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
} else {
target.to_string()
}
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::{
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
#[gpui::test]
async fn test_increment(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
1ˇ2
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
1ˇ3
"});
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
1ˇ2
"});
cx.simulate_shared_keystrokes("9 9 ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
11ˇ1
"});
cx.simulate_shared_keystrokes("1 1 1 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇ0
"});
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq(indoc! {"
-11ˇ1
"});
}
#[gpui::test]
async fn test_increment_with_dot(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
1ˇ.2
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
1.ˇ3
"});
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
1.ˇ2
"});
}
#[gpui::test]
async fn test_increment_with_leading_zeros(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
000ˇ9
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
001ˇ0
"});
cx.simulate_shared_keystrokes("2 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
000ˇ8
"});
}
#[gpui::test]
async fn test_increment_with_leading_zeros_and_zero(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
01ˇ1
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
01ˇ2
"});
cx.simulate_shared_keystrokes("1 2 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
00ˇ0
"});
}
#[gpui::test]
async fn test_increment_with_changing_leading_zeros(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
099ˇ9
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
100ˇ0
"});
cx.simulate_shared_keystrokes("2 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
99ˇ8
"});
}
#[gpui::test]
async fn test_increment_with_two_dots(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
111.ˇ.2
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
111..ˇ3
"});
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
111..ˇ2
"});
}
#[gpui::test]
async fn test_increment_sign_change(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
ˇ0
"})
.await;
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
-ˇ1
"});
cx.simulate_shared_keystrokes("2 ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇ1
"});
}
#[gpui::test]
async fn test_increment_sign_change_with_leading_zeros(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
00ˇ1
"})
.await;
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
00ˇ0
"});
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
-00ˇ1
"});
cx.simulate_shared_keystrokes("2 ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
00ˇ1
"});
}
#[gpui::test]
async fn test_increment_bin_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
0b111111111111111111111111111111111111111111111111111111111111111111111ˇ1
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0b000000000000000000000000000000000000000000000000000000000000000000000ˇ0
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0b000000000000000000000000000000000000000000000000000000000000000000000ˇ1
"});
cx.simulate_shared_keystrokes("2 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
"});
}
#[gpui::test]
async fn test_increment_hex_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
0xfffffffffffffffffffˇf
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0x0000fffffffffffffffˇf
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0x0000000000000000000ˇ0
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0x0000000000000000000ˇ1
"});
cx.simulate_shared_keystrokes("2 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
0x0000fffffffffffffffˇf
"});
}
#[gpui::test]
async fn test_increment_wrapping(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
1844674407370955161ˇ9
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
1844674407370955161ˇ5
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
-1844674407370955161ˇ5
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
-1844674407370955161ˇ4
"});
cx.simulate_shared_keystrokes("3 ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
1844674407370955161ˇ4
"});
cx.simulate_shared_keystrokes("2 ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
-1844674407370955161ˇ5
"});
}
#[gpui::test]
async fn test_increment_inline(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
inline0x3ˇ9u32
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
inline0x3ˇau32
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
inline0x3ˇbu32
"});
cx.simulate_shared_keystrokes("l l l ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
inline0x3bu3ˇ3
"});
}
#[gpui::test]
async fn test_increment_hex_casing(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
0xFˇa
"})
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0xfˇb
"});
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
0xfˇc
"});
}
#[gpui::test]
async fn test_increment_radix(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate("ctrl-a", "ˇ total: 0xff")
.await
.assert_matches();
cx.simulate("ctrl-x", "ˇ total: 0xff")
.await
.assert_matches();
cx.simulate("ctrl-x", "ˇ total: 0xFF")
.await
.assert_matches();
cx.simulate("ctrl-a", "(ˇ0b10f)").await.assert_matches();
cx.simulate("ctrl-a", "ˇ-1").await.assert_matches();
cx.simulate("ctrl-a", "-ˇ1").await.assert_matches();
cx.simulate("ctrl-a", "banˇana").await.assert_matches();
}
#[gpui::test]
async fn test_increment_steps(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
ˇ1
1
1 2
1
1"})
.await;
cx.simulate_shared_keystrokes("j v shift-g g ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
1
ˇ2
3 2
4
5"});
cx.simulate_shared_keystrokes("shift-g ctrl-v g g").await;
cx.shared_state().await.assert_eq(indoc! {"
«1ˇ»
«2ˇ»
«3ˇ» 2
«4ˇ»
«5ˇ»"});
cx.simulate_shared_keystrokes("g ctrl-x").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇ0
0
0 2
0
0"});
cx.simulate_shared_keystrokes("v shift-g g ctrl-a").await;
cx.simulate_shared_keystrokes("v shift-g 5 g ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇ6
12
18 2
24
30"});
}
#[gpui::test]
async fn test_increment_toggle(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("let enabled = trˇue;", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("let enabled = falsˇe;", Mode::Normal);
cx.simulate_keystrokes("0 ctrl-a");
cx.assert_state("let enabled = truˇe;", Mode::Normal);
cx.set_state(
indoc! {"
ˇlet enabled = TRUE;
let enabled = TRUE;
let enabled = TRUE;
"},
Mode::Normal,
);
cx.simulate_keystrokes("shift-v j j ctrl-x");
cx.assert_state(
indoc! {"
ˇlet enabled = FALSE;
let enabled = FALSE;
let enabled = FALSE;
"},
Mode::Normal,
);
cx.set_state(
indoc! {"
let enabled = ˇYes;
let enabled = Yes;
let enabled = Yes;
"},
Mode::Normal,
);
cx.simulate_keystrokes("ctrl-v j j e ctrl-x");
cx.assert_state(
indoc! {"
let enabled = ˇNo;
let enabled = No;
let enabled = No;
"},
Mode::Normal,
);
cx.set_state("ˇlet enabled = True;", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("let enabled = Falsˇe;", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("let enabled = Truˇe;", Mode::Normal);
cx.set_state("let enabled = Onˇ;", Mode::Normal);
cx.simulate_keystrokes("v b ctrl-a");
cx.assert_state("let enabled = ˇOff;", Mode::Normal);
}
#[gpui::test]
async fn test_increment_order(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state("aaˇa false 1 2 3", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("aaa truˇe 1 2 3", Mode::Normal);
cx.set_state("aaˇa 1 false 2 3", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("aaa ˇ2 false 2 3", Mode::Normal);
cx.set_state("trueˇ 1 2 3", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("true ˇ2 2 3", Mode::Normal);
cx.set_state("falseˇ", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("truˇe", Mode::Normal);
cx.set_state("⚡️ˇ⚡️", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("⚡️ˇ⚡️", Mode::Normal);
}
#[gpui::test]
async fn test_increment_visual_partial_number(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇ123").await;
cx.simulate_shared_keystrokes("v l ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"ˇ133"});
cx.simulate_shared_keystrokes("l v l ctrl-a").await;
cx.shared_state().await.assert_eq(indoc! {"1ˇ34"});
cx.simulate_shared_keystrokes("shift-v y p p ctrl-v k k l ctrl-a")
.await;
cx.shared_state().await.assert_eq(indoc! {"ˇ144\n144\n144"});
}
#[gpui::test]
async fn test_increment_markdown_list_markers_multiline(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("# Title\nˇ1. item\n2. item\n3. item")
.await;
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state()
.await
.assert_eq("# Title\nˇ2. item\n2. item\n3. item");
cx.simulate_shared_keystrokes("j").await;
cx.shared_state()
.await
.assert_eq("# Title\n2. item\nˇ2. item\n3. item");
cx.simulate_shared_keystrokes("ctrl-a").await;
cx.shared_state()
.await
.assert_eq("# Title\n2. item\nˇ3. item\n3. item");
cx.simulate_shared_keystrokes("ctrl-x").await;
cx.shared_state()
.await
.assert_eq("# Title\n2. item\nˇ2. item\n3. item");
}
#[gpui::test]
async fn test_increment_with_multibyte_characters(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// Test cursor after a multibyte character - this would panic before the fix
// because the backward scan would land in the middle of the Korean character
cx.set_state("지ˇ1", Mode::Normal);
cx.simulate_keystrokes("ctrl-a");
cx.assert_state("지ˇ2", Mode::Normal);
}
}
+465
View File
@@ -0,0 +1,465 @@
use std::{ops::Range, path::Path, sync::Arc};
use editor::{
Anchor, Bias, DisplayPoint, Editor, MultiBuffer,
display_map::{DisplaySnapshot, ToDisplayPoint},
movement,
};
use gpui::{Context, Entity, EntityId, TaskExt, UpdateGlobal, Window};
use language::SelectionGoal;
use text::Point;
use ui::App;
use workspace::OpenOptions;
use crate::{
Vim,
motion::{self, Motion},
state::{Mark, Mode, VimGlobals},
};
impl Vim {
pub fn create_mark(&mut self, text: Arc<str>, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |vim, editor, cx| {
let anchors = editor
.selections
.disjoint_anchors_arc()
.iter()
.map(|s| s.head())
.collect::<Vec<_>>();
vim.set_mark(text.to_string(), anchors, editor.buffer(), window, cx);
});
self.clear_operator(window, cx);
}
// When handling an action, you must create visual marks if you will switch to normal
// mode without the default selection behavior.
pub(crate) fn store_visual_marks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_visual() {
self.create_visual_marks(self.mode, window, cx);
}
}
pub(crate) fn create_visual_marks(
&mut self,
mode: Mode,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut starts = vec![];
let mut ends = vec![];
let mut reversed = vec![];
self.update_editor(cx, |vim, editor, cx| {
let display_map = editor.display_snapshot(cx);
let selections = editor.selections.all_display(&display_map);
for selection in selections {
let end = movement::saturating_left(&display_map, selection.end);
ends.push(
display_map
.buffer_snapshot()
.anchor_before(end.to_offset(&display_map, Bias::Left)),
);
starts.push(
display_map
.buffer_snapshot()
.anchor_before(selection.start.to_offset(&display_map, Bias::Left)),
);
reversed.push(selection.reversed)
}
vim.set_mark("<".to_string(), starts, editor.buffer(), window, cx);
vim.set_mark(">".to_string(), ends, editor.buffer(), window, cx);
});
self.stored_visual_mode.replace((mode, reversed));
}
fn open_buffer_mark(
&mut self,
line: bool,
entity_id: EntityId,
anchors: Vec<Anchor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace(window, cx) else {
return;
};
workspace.update(cx, |workspace, cx| {
let item = workspace.items(cx).find(|item| {
item.act_as::<Editor>(cx)
.is_some_and(|editor| editor.read(cx).buffer().entity_id() == entity_id)
});
let Some(item) = item.cloned() else {
return;
};
if let Some(pane) = workspace.pane_for(item.as_ref()) {
pane.update(cx, |pane, cx| {
if let Some(index) = pane.index_for_item(item.as_ref()) {
pane.activate_item(index, true, true, window, cx);
}
});
};
item.act_as::<Editor>(cx).unwrap().update(cx, |editor, cx| {
let map = editor.snapshot(window, cx);
let mut ranges: Vec<Range<Anchor>> = Vec::new();
for mut anchor in anchors {
if line {
let mut point = anchor.to_display_point(&map.display_snapshot);
point = motion::first_non_whitespace(&map.display_snapshot, false, point);
anchor = map
.display_snapshot
.buffer_snapshot()
.anchor_before(point.to_point(&map.display_snapshot));
}
if ranges.last() != Some(&(anchor..anchor)) {
ranges.push(anchor..anchor);
}
}
editor.change_selections(Default::default(), window, cx, |s| {
s.select_anchor_ranges(ranges)
});
})
});
}
fn open_path_mark(
&mut self,
line: bool,
path: Arc<Path>,
points: Vec<Point>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace(window, cx) else {
return;
};
let task = workspace.update(cx, |workspace, cx| {
workspace.open_abs_path(
path.to_path_buf(),
OpenOptions {
visible: Some(workspace::OpenVisible::All),
focus: Some(true),
..Default::default()
},
window,
cx,
)
});
cx.spawn_in(window, async move |this, cx| {
let editor = task.await?;
this.update_in(cx, |_, window, cx| {
if let Some(editor) = editor.act_as::<Editor>(cx) {
editor.update(cx, |editor, cx| {
let map = editor.snapshot(window, cx);
let points: Vec<_> = points
.into_iter()
.map(|p| {
if line {
let point = p.to_display_point(&map.display_snapshot);
motion::first_non_whitespace(
&map.display_snapshot,
false,
point,
)
.to_point(&map.display_snapshot)
} else {
p
}
})
.collect();
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(points.into_iter().map(|p| p..p))
})
})
}
})
})
.detach_and_log_err(cx);
}
pub fn jump(
&mut self,
text: Arc<str>,
line: bool,
should_pop_operator: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if should_pop_operator {
self.pop_operator(window, cx);
}
let mark = self
.update_editor(cx, |vim, editor, cx| {
vim.get_mark(&text, editor, window, cx)
})
.flatten();
let anchors = match mark {
None => None,
Some(Mark::Local(anchors)) => Some(anchors),
Some(Mark::Buffer(entity_id, anchors)) => {
self.open_buffer_mark(line, entity_id, anchors, window, cx);
return;
}
Some(Mark::Path(path, points)) => {
self.open_path_mark(line, path, points, window, cx);
return;
}
};
let Some(mut anchors) = anchors else { return };
self.update_editor(cx, |_, editor, cx| {
editor.create_nav_history_entry(cx);
});
let is_active_operator = self.active_operator().is_some();
if is_active_operator {
if let Some(anchor) = anchors.last() {
self.motion(
Motion::Jump {
anchor: *anchor,
line,
},
window,
cx,
)
}
} else {
// Save the last anchor so as to jump to it later.
let anchor: Option<Anchor> = anchors.last_mut().map(|anchor| *anchor);
let should_jump = self.mode == Mode::Visual
|| self.mode == Mode::VisualLine
|| self.mode == Mode::VisualBlock;
self.update_editor(cx, |_, editor, cx| {
let map = editor.snapshot(window, cx);
let mut ranges: Vec<Range<Anchor>> = Vec::new();
for mut anchor in anchors {
if line {
let mut point = anchor.to_display_point(&map.display_snapshot);
point = motion::first_non_whitespace(&map.display_snapshot, false, point);
anchor = map
.display_snapshot
.buffer_snapshot()
.anchor_before(point.to_point(&map.display_snapshot));
}
if ranges.last() != Some(&(anchor..anchor)) {
ranges.push(anchor..anchor);
}
}
if !should_jump && !ranges.is_empty() {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_anchor_ranges(ranges)
});
}
});
if should_jump && let Some(anchor) = anchor {
self.motion(Motion::Jump { anchor, line }, window, cx)
}
}
}
pub fn set_mark(
&mut self,
mut name: String,
anchors: Vec<Anchor>,
buffer_entity: &Entity<MultiBuffer>,
window: &mut Window,
cx: &mut App,
) {
let Some(workspace) = self.workspace(window, cx) else {
return;
};
if name == "`" {
name = "'".to_string();
}
if matches!(&name[..], "-" | " ") {
// Not allowed marks
return;
}
let entity_id = workspace.entity_id();
Vim::update_globals(cx, |vim_globals, cx| {
let Some(marks_state) = vim_globals.marks.get(&entity_id) else {
return;
};
marks_state.update(cx, |ms, cx| {
ms.set_mark(name.clone(), buffer_entity, anchors, cx);
});
});
}
pub fn get_mark(
&self,
mut name: &str,
editor: &mut Editor,
window: &mut Window,
cx: &mut App,
) -> Option<Mark> {
if name == "`" {
name = "'";
}
if matches!(name, "{" | "}" | "(" | ")") {
let display_map = editor.display_snapshot(cx);
let selections = editor.selections.all_display(&display_map);
let anchors = selections
.into_iter()
.map(|selection| {
let point = match name {
"{" => movement::start_of_paragraph(&display_map, selection.head(), 1),
"}" => movement::end_of_paragraph(&display_map, selection.head(), 1),
"(" => motion::sentence_backwards(&display_map, selection.head(), 1),
")" => motion::sentence_forwards(&display_map, selection.head(), 1),
_ => unreachable!(),
};
display_map
.buffer_snapshot()
.anchor_before(point.to_offset(&display_map, Bias::Left))
})
.collect::<Vec<Anchor>>();
return Some(Mark::Local(anchors));
}
VimGlobals::update_global(cx, |globals, cx| {
let workspace_id = self.workspace(window, cx)?.entity_id();
globals
.marks
.get_mut(&workspace_id)?
.update(cx, |ms, cx| ms.get_mark(name, editor.buffer(), cx))
})
}
pub fn delete_mark(
&self,
name: String,
editor: &mut Editor,
window: &mut Window,
cx: &mut App,
) {
let Some(workspace) = self.workspace(window, cx) else {
return;
};
if name == "`" || name == "'" {
return;
}
let entity_id = workspace.entity_id();
Vim::update_globals(cx, |vim_globals, cx| {
let Some(marks_state) = vim_globals.marks.get(&entity_id) else {
return;
};
marks_state.update(cx, |ms, cx| {
ms.delete_mark(name.clone(), editor.buffer(), cx);
});
});
}
}
pub fn jump_motion(
map: &DisplaySnapshot,
anchor: Anchor,
line: bool,
) -> (DisplayPoint, SelectionGoal) {
let mut point = anchor.to_display_point(map);
if line {
point = motion::first_non_whitespace(map, false, point)
}
(point, SelectionGoal::None)
}
#[cfg(test)]
mod test {
use crate::test::{NeovimBackedTestContext, VimTestContext};
use editor::Editor;
use gpui::TestAppContext;
use std::path::Path;
use util::path;
use workspace::{CloseActiveItem, OpenOptions};
#[gpui::test]
async fn test_quote_mark(cx: &mut TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇHello, world!").await;
cx.simulate_shared_keystrokes("w m o").await;
cx.shared_state().await.assert_eq("Helloˇ, world!");
cx.simulate_shared_keystrokes("$ ` o").await;
cx.shared_state().await.assert_eq("Helloˇ, world!");
cx.simulate_shared_keystrokes("` `").await;
cx.shared_state().await.assert_eq("Hello, worldˇ!");
cx.simulate_shared_keystrokes("` `").await;
cx.shared_state().await.assert_eq("Helloˇ, world!");
cx.simulate_shared_keystrokes("$ m '").await;
cx.shared_state().await.assert_eq("Hello, worldˇ!");
cx.simulate_shared_keystrokes("^ ` `").await;
cx.shared_state().await.assert_eq("Hello, worldˇ!");
}
#[gpui::test]
async fn test_global_mark_overwrite(cx: &mut TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
let path = Path::new(path!("/first.rs"));
let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake().insert_file(path, "one".into()).await;
let path = Path::new(path!("/second.rs"));
fs.as_fake().insert_file(path, "two".into()).await;
let _ = cx
.workspace(|workspace, window, cx| {
workspace.open_abs_path(
path!("/first.rs").into(),
OpenOptions::default(),
window,
cx,
)
})
.await;
cx.simulate_keystrokes("m A");
let _ = cx
.workspace(|workspace, window, cx| {
workspace.open_abs_path(
path!("/second.rs").into(),
OpenOptions::default(),
window,
cx,
)
})
.await;
cx.simulate_keystrokes("m A");
let _ = cx
.workspace(|workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_active_item(&CloseActiveItem::default(), window, cx)
})
})
.await;
cx.simulate_keystrokes("m B");
cx.simulate_keystrokes("' A");
cx.workspace(|workspace, _, cx| {
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
let buffer = active_editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.unwrap();
let file = buffer.read(cx).file().unwrap();
let file_path = file.as_local().unwrap().abs_path(cx);
assert_eq!(file_path.to_str().unwrap(), path!("/second.rs"));
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+583
View File
@@ -0,0 +1,583 @@
use crate::{Vim, state::Mode};
use editor::{
DisplayPoint, Editor, EditorSettings, SelectionEffects, display_map::DisplayRow,
scroll::ScrollAmount,
};
use gpui::{Context, Window, actions};
use language::Bias;
use settings::Settings;
use text::SelectionGoal;
actions!(
vim,
[
/// Scrolls up by one line.
LineUp,
/// Scrolls down by one line.
LineDown,
/// Scrolls right by one column.
ColumnRight,
/// Scrolls left by one column.
ColumnLeft,
/// Scrolls up by half a page.
ScrollUp,
/// Scrolls down by half a page.
ScrollDown,
/// Scrolls up by one page.
PageUp,
/// Scrolls down by one page.
PageDown,
/// Scrolls right by half a page's width.
HalfPageRight,
/// Scrolls left by half a page's width.
HalfPageLeft,
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &LineDown, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Line(c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &LineUp, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Line(-c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &ColumnRight, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Column(c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &ColumnLeft, window, cx| {
vim.scroll(false, window, cx, |c| {
ScrollAmount::Column(-c.unwrap_or(1.))
})
});
Vim::action(editor, cx, |vim, _: &PageDown, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Page(c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &PageUp, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Page(-c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &HalfPageRight, window, cx| {
vim.scroll(false, window, cx, |c| {
ScrollAmount::PageWidth(c.unwrap_or(0.5))
})
});
Vim::action(editor, cx, |vim, _: &HalfPageLeft, window, cx| {
vim.scroll(false, window, cx, |c| {
ScrollAmount::PageWidth(-c.unwrap_or(0.5))
})
});
Vim::action(editor, cx, |vim, _: &ScrollDown, window, cx| {
vim.scroll(true, window, cx, |c| {
if let Some(c) = c {
ScrollAmount::Line(c)
} else {
ScrollAmount::Page(0.5)
}
})
});
Vim::action(editor, cx, |vim, _: &ScrollUp, window, cx| {
vim.scroll(true, window, cx, |c| {
if let Some(c) = c {
ScrollAmount::Line(-c)
} else {
ScrollAmount::Page(-0.5)
}
})
});
}
impl Vim {
fn scroll(
&mut self,
preserve_cursor_position: bool,
window: &mut Window,
cx: &mut Context<Self>,
by: fn(c: Option<f32>) -> ScrollAmount,
) {
let amount = by(Vim::take_count(cx).map(|c| c as f32));
Vim::take_forced_motion(cx);
self.exit_temporary_normal(window, cx);
self.scroll_editor(preserve_cursor_position, amount, window, cx);
}
fn scroll_editor(
&mut self,
preserve_cursor_position: bool,
amount: ScrollAmount,
window: &mut Window,
cx: &mut Context<Vim>,
) {
self.update_editor(cx, |vim, editor, cx| {
let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
let old_top = editor.scroll_top_display_point(&display_snapshot, cx);
if editor.scroll_hover(amount, window, cx) {
return;
}
let full_page_up = amount.is_full_page() && amount.direction().is_upwards();
let amount = match (amount.is_full_page(), editor.visible_line_count()) {
(true, Some(visible_line_count)) => {
if amount.direction().is_upwards() {
ScrollAmount::Line((amount.lines(visible_line_count) + 1.0) as f32)
} else {
ScrollAmount::Line((amount.lines(visible_line_count) - 1.0) as f32)
}
}
_ => amount,
};
editor.scroll_screen(&amount, window, cx);
if !should_move_cursor {
return;
}
let Some(visible_line_count) = editor.visible_line_count() else {
return;
};
let Some(visible_column_count) = editor.visible_column_count() else {
return;
};
let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
let top = editor.scroll_top_display_point(&display_snapshot, cx);
let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin;
let mut move_cursor = |map: &editor::display_map::DisplaySnapshot,
mut head: DisplayPoint,
goal: SelectionGoal| {
// TODO: Improve the logic and function calls below to be dependent on
// the `amount`. If the amount is vertical, we don't care about
// columns, while if it's horizontal, we don't care about rows,
// so we don't need to calculate both and deal with logic for
// both.
let max_point = map.max_point();
let starting_column = head.column();
let vertical_scroll_margin =
(vertical_scroll_margin as u32).min(visible_line_count as u32 / 2);
if preserve_cursor_position {
let new_row =
if old_top.row() == top.row() {
DisplayRow(
head.row()
.0
.saturating_add_signed(amount.lines(visible_line_count) as i32),
)
} else {
DisplayRow(top.row().0.saturating_add_signed(
head.row().0 as i32 - old_top.row().0 as i32,
))
};
head = map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left)
}
let min_row = if top.row().0 == 0 {
DisplayRow(0)
} else {
DisplayRow(top.row().0 + vertical_scroll_margin)
};
let max_visible_row = top.row().0.saturating_add(
(visible_line_count as u32).saturating_sub(1 + vertical_scroll_margin),
);
// scroll off the end.
let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
max_point.row()
} else {
DisplayRow(
(top.row().0 + visible_line_count as u32)
.saturating_sub(1 + vertical_scroll_margin),
)
};
let new_row = if full_page_up {
// Special-casing ctrl-b/page-up, which is special-cased by Vim, it seems
// to always put the cursor on the last line of the page, even if the cursor
// was before that.
DisplayRow(max_visible_row)
} else if head.row() < min_row {
min_row
} else if head.row() > max_row {
max_row
} else {
head.row()
};
// The minimum column position that the cursor position can be
// at is either the scroll manager's anchor column, which is the
// left-most column in the visible area, or the scroll manager's
// old anchor column, in case the cursor position is being
// preserved. This is necessary for motions like `ctrl-d` in
// case there's not enough content to scroll half page down, in
// which case the scroll manager's anchor column will be the
// maximum column for the current line, so the minimum column
// would end up being the same as the maximum column.
let min_column = match preserve_cursor_position {
true => old_top.column(),
false => top.column(),
};
// As for the maximum column position, that should be either the
// right-most column in the visible area, which we can easily
// calculate by adding the visible column count to the minimum
// column position, or the right-most column in the current
// line, seeing as the cursor might be in a short line, in which
// case we don't want to go past its last column.
let max_row_column = if new_row <= map.max_point().row() {
map.line_len(new_row)
} else {
0
};
let max_column = match min_column + visible_column_count as u32 {
max_column if max_column >= max_row_column => max_row_column,
max_column => max_column,
};
// Ensure that the cursor's column stays within the visible
// area, otherwise clip it at either the left or right edge of
// the visible area.
let new_column = match (min_column, max_column) {
(min_column, _) if starting_column < min_column => min_column,
(_, max_column) if starting_column > max_column => max_column,
_ => starting_column,
};
let new_head = map.clip_point(DisplayPoint::new(new_row, new_column), Bias::Left);
let goal = match amount {
ScrollAmount::Column(_) | ScrollAmount::PageWidth(_) => SelectionGoal::None,
_ => goal,
};
Some((new_head, goal))
};
if vim.mode == Mode::VisualBlock {
vim.visual_block_motion(true, editor, window, cx, &mut move_cursor);
} else {
editor.change_selections(
SelectionEffects::no_scroll().nav_history(false),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
if let Some((new_head, goal)) =
move_cursor(map, selection.head(), selection.goal)
{
if selection.is_empty() || !vim.mode.is_visual() {
selection.collapse_to(new_head, goal)
} else {
selection.set_head(new_head, goal)
}
}
})
},
);
}
});
}
}
#[cfg(test)]
mod test {
use crate::{
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
use editor::ScrollBeyondLastLine;
use gpui::{AppContext as _, point, px, size};
use indoc::indoc;
use language::Point;
use settings::SettingsStore;
pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String {
let mut text = String::new();
for row in 0..rows {
let c: char = (start_char as u32 + row as u32) as u8 as char;
let mut line = c.to_string().repeat(cols);
if row < rows - 1 {
line.push('\n');
}
text += &line;
}
text
}
#[gpui::test]
async fn test_scroll(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| {
(
editor
.style(cx)
.text
.line_height_in_pixels(window.rem_size()),
editor.visible_line_count().unwrap(),
)
});
let window = cx.window;
let margin = cx
.update_window(window, |_, window, _cx| {
window.viewport_size().height - line_height * visible_line_count as f32
})
.unwrap();
cx.simulate_window_resize(
cx.window,
size(px(1000.), margin + 8. * line_height - px(1.0)),
);
cx.set_state(
indoc!(
"ˇone
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
"
),
Mode::Normal,
);
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
});
cx.simulate_keystrokes("ctrl-e");
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 1.))
});
cx.simulate_keystrokes("2 ctrl-e");
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 3.))
});
cx.simulate_keystrokes("ctrl-y");
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 2.))
});
// does not select in normal mode
cx.simulate_keystrokes("g g");
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
});
cx.simulate_keystrokes("ctrl-d");
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
point(0., 3.0)
);
assert_eq!(
editor
.selections
.newest(&editor.display_snapshot(cx))
.range(),
Point::new(6, 0)..Point::new(6, 0)
)
});
// does select in visual mode
cx.simulate_keystrokes("g g");
cx.update_editor(|editor, window, cx| {
assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
});
cx.simulate_keystrokes("v ctrl-d");
cx.update_editor(|editor, window, cx| {
assert_eq!(
editor.snapshot(window, cx).scroll_position(),
point(0., 3.0)
);
assert_eq!(
editor
.selections
.newest(&editor.display_snapshot(cx))
.range(),
Point::new(0, 0)..Point::new(6, 1)
)
});
}
#[gpui::test]
async fn test_ctrl_d_u(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(10).await;
let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
cx.set_shared_state(&content).await;
// skip over the scrolloff at the top
// test ctrl-d
cx.simulate_shared_keystrokes("4 j ctrl-d").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-d").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("g g ctrl-d").await;
cx.shared_state().await.assert_matches();
// test ctrl-u
cx.simulate_shared_keystrokes("ctrl-u").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-d ctrl-d 4 j ctrl-u ctrl-u")
.await;
cx.shared_state().await.assert_matches();
// test returning to top
cx.simulate_shared_keystrokes("g g ctrl-d ctrl-u ctrl-u")
.await;
cx.shared_state().await.assert_matches();
}
#[gpui::test]
async fn test_ctrl_f_b(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let visible_lines = 10;
cx.set_scroll_height(visible_lines).await;
// First test without vertical scroll margin
cx.neovim.set_option(&format!("scrolloff={}", 0)).await;
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |s| s.editor.vertical_scroll_margin = Some(0.0));
});
let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
cx.set_shared_state(&content).await;
// scroll down: ctrl-f
cx.simulate_shared_keystrokes("ctrl-f").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-f").await;
cx.shared_state().await.assert_matches();
// scroll up: ctrl-b
cx.simulate_shared_keystrokes("ctrl-b").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-b").await;
cx.shared_state().await.assert_matches();
// Now go back to start of file, and test with vertical scroll margin
cx.simulate_shared_keystrokes("g g").await;
cx.shared_state().await.assert_matches();
cx.neovim.set_option(&format!("scrolloff={}", 3)).await;
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |s| s.editor.vertical_scroll_margin = Some(3.0));
});
// scroll down: ctrl-f
cx.simulate_shared_keystrokes("ctrl-f").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-f").await;
cx.shared_state().await.assert_matches();
// scroll up: ctrl-b
cx.simulate_shared_keystrokes("ctrl-b").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-b").await;
cx.shared_state().await.assert_matches();
}
#[gpui::test]
async fn test_scroll_beyond_last_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(10).await;
let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
cx.set_shared_state(&content).await;
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |s| {
s.editor.scroll_beyond_last_line = Some(ScrollBeyondLastLine::Off);
});
});
// ctrl-d can reach the end and the cursor stays in the first column
cx.simulate_shared_keystrokes("shift-g k").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-d").await;
cx.shared_state().await.assert_matches();
// ctrl-u from the last line
cx.simulate_shared_keystrokes("shift-g").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-u").await;
cx.shared_state().await.assert_matches();
}
#[gpui::test]
async fn test_ctrl_y_e(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(10).await;
let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
cx.set_shared_state(&content).await;
for _ in 0..8 {
cx.simulate_shared_keystrokes("ctrl-e").await;
cx.shared_state().await.assert_matches();
}
for _ in 0..8 {
cx.simulate_shared_keystrokes("ctrl-y").await;
cx.shared_state().await.assert_matches();
}
}
#[gpui::test]
async fn test_scroll_jumps(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(20).await;
let content = "ˇ".to_owned() + &sample_text(52, 2, 'a');
cx.set_shared_state(&content).await;
cx.simulate_shared_keystrokes("shift-g g g").await;
cx.simulate_shared_keystrokes("ctrl-d ctrl-d ctrl-o").await;
cx.shared_state().await.assert_matches();
cx.simulate_shared_keystrokes("ctrl-o").await;
cx.shared_state().await.assert_matches();
}
#[gpui::test]
async fn test_horizontal_scroll(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(20).await;
cx.set_shared_wrap(12).await;
cx.set_neovim_option("nowrap").await;
let content = "ˇ01234567890123456789";
cx.set_shared_state(content).await;
cx.simulate_shared_keystrokes("z shift-l").await;
cx.shared_state().await.assert_eq("012345ˇ67890123456789");
// At this point, `z h` should not move the cursor as it should still be
// visible within the 12 column width.
cx.simulate_shared_keystrokes("z h").await;
cx.shared_state().await.assert_eq("012345ˇ67890123456789");
let content = "ˇ01234567890123456789";
cx.set_shared_state(content).await;
cx.simulate_shared_keystrokes("z l").await;
cx.shared_state().await.assert_eq("0ˇ1234567890123456789");
}
}
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
use std::sync::Arc;
use crate::{
Mode, Vim,
motion::{Motion, MotionKind},
};
use editor::{Editor, SelectionEffects, movement};
use gpui::{Context, Window, actions};
use language::Point;
actions!(
vim,
[
/// Substitutes characters in the current selection.
Substitute,
/// Substitutes the entire line.
SubstituteLine
]
);
pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &Substitute, window, cx| {
vim.start_recording(cx);
let count = Vim::take_count(cx);
Vim::take_forced_motion(cx);
vim.substitute(count, vim.mode == Mode::VisualLine, window, cx);
});
Vim::action(editor, cx, |vim, _: &SubstituteLine, window, cx| {
vim.start_recording(cx);
if matches!(vim.mode, Mode::VisualBlock | Mode::Visual) {
vim.switch_mode(Mode::VisualLine, false, window, cx)
}
let count = Vim::take_count(cx);
Vim::take_forced_motion(cx);
vim.substitute(count, true, window, cx)
});
}
impl Vim {
pub fn substitute(
&mut self,
count: Option<usize>,
line_mode: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.store_visual_marks(window, cx);
self.update_editor(cx, |vim, editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(window, cx, |editor, window, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
if selection.start == selection.end {
Motion::Right.expand_selection(
map,
selection,
count,
&text_layout_details,
false,
);
}
if line_mode {
// in Visual mode when the selection contains the newline at the end
// of the line, we should exclude it.
if !selection.is_empty() && selection.end.column() == 0 {
selection.end = movement::left(map, selection.end);
}
Motion::CurrentLine.expand_selection(
map,
selection,
None,
&text_layout_details,
false,
);
if let Some((point, _)) = (Motion::FirstNonWhitespace {
display_lines: false,
})
.move_point(
map,
selection.start,
selection.goal,
None,
&text_layout_details,
) {
selection.start = point;
}
}
})
});
let kind = if line_mode {
MotionKind::Linewise
} else {
MotionKind::Exclusive
};
vim.copy_selections_content(editor, kind, window, cx);
let linked_edits = editor.linked_edits_for_selections(Arc::from(""), 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);
linked_edits.apply(cx);
});
});
self.switch_mode(Mode::Insert, true, window, cx);
}
}
#[cfg(test)]
mod test {
use crate::{
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
use indoc::indoc;
#[gpui::test]
async fn test_substitute(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// supports a single cursor
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("s x");
cx.assert_editor_state("xˇbc\n");
// supports a selection
cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual);
cx.assert_editor_state("a«bcˇ»\n");
cx.simulate_keystrokes("s x");
cx.assert_editor_state("axˇ\n");
// supports counts
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("2 s x");
cx.assert_editor_state("xˇc\n");
// supports multiple cursors
cx.set_state(indoc! {"a«bcˇ»deˇffg\n"}, Mode::Normal);
cx.simulate_keystrokes("2 s x");
cx.assert_editor_state("axˇdexˇg\n");
// does not read beyond end of line
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("5 s x");
cx.assert_editor_state("xˇ\n");
// it handles multibyte characters
cx.set_state(indoc! {"ˇcàfé\n"}, Mode::Normal);
cx.simulate_keystrokes("4 s");
cx.assert_editor_state("ˇ\n");
// should transactionally undo selection changes
cx.simulate_keystrokes("escape u");
cx.assert_editor_state("ˇcàfé\n");
// it handles visual line mode
cx.set_state(
indoc! {"
alpha
beˇta
gamma"},
Mode::Normal,
);
cx.simulate_keystrokes("shift-v s");
cx.assert_editor_state(indoc! {"
alpha
ˇ
gamma"});
}
#[gpui::test]
async fn test_visual_change(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("The quick ˇbrown").await;
cx.simulate_shared_keystrokes("v w c").await;
cx.shared_state().await.assert_eq("The quick ˇ");
cx.set_shared_state(indoc! {"
The ˇquick brown
fox jumps over
the lazy dog"})
.await;
cx.simulate_shared_keystrokes("v w j c").await;
cx.shared_state().await.assert_eq(indoc! {"
The ˇver
the lazy dog"});
cx.simulate_at_each_offset(
"v w j c",
indoc! {"
The ˇquick brown
fox jumps ˇover
the ˇlazy dog"},
)
.await
.assert_matches();
cx.simulate_at_each_offset(
"v w k c",
indoc! {"
The ˇquick brown
fox jumps ˇover
the ˇlazy dog"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"shift-v c",
indoc! {"
The quˇick brown
fox jumps over
the lazy dog"},
)
.await
.assert_matches();
// Test pasting code copied on change
cx.simulate_shared_keystrokes("escape j p").await;
cx.shared_state().await.assert_matches();
cx.simulate_at_each_offset(
"shift-v c",
indoc! {"
The quick brown
fox juˇmps over
the laˇzy dog"},
)
.await
.assert_matches();
cx.simulate(
"shift-v j c",
indoc! {"
The quˇick brown
fox jumps over
the lazy dog"},
)
.await
.assert_matches();
// Test pasting code copied on delete
cx.simulate_shared_keystrokes("escape j p").await;
cx.shared_state().await.assert_matches();
cx.simulate_at_each_offset(
"shift-v j c",
indoc! {"
The quick brown
fox juˇmps over
the laˇzy dog"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_substitute_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {"
The quick brown
fox juˇmps over
the lazy dog
"};
// normal mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
// visual mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("v k shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
oˇ
the lazy dog
"});
// visual block mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("ctrl-v j shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
"});
// visual mode including newline
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("v $ shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
// indentation
cx.set_neovim_option("shiftwidth=4").await;
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("> > shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
}
}
+145
View File
@@ -0,0 +1,145 @@
use crate::{Vim, motion::Motion, object::Object};
use collections::HashMap;
use editor::{Bias, SelectionEffects, display_map::ToDisplayPoint};
use gpui::{Context, Window};
use language::SelectionGoal;
impl Vim {
pub fn toggle_comments_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
let mut selection_starts: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
selection_starts.insert(selection.id, anchor);
motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
});
});
editor.toggle_comments(&Default::default(), window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = selection_starts.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
});
});
}
pub fn toggle_comments_object(
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
let mut original_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
original_positions.insert(selection.id, anchor);
object.expand_selection(map, selection, around, times);
});
});
editor.toggle_comments(&Default::default(), window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = original_positions.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
});
});
}
pub fn toggle_block_comments_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut selection_starts: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
selection_starts.insert(selection.id, anchor);
motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
});
});
editor.toggle_block_comments(&Default::default(), window, cx);
editor.set_clip_at_line_ends(true, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = selection_starts.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
});
});
}
pub fn toggle_block_comments_object(
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.stop_recording(cx);
self.update_editor(cx, |_, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
original_positions.insert(selection.id, anchor);
object.expand_selection(map, selection, around, times);
});
});
editor.toggle_block_comments(&Default::default(), window, cx);
editor.set_clip_at_line_ends(true, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let anchor = original_positions.remove(&selection.id).unwrap();
selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
});
});
});
});
}
}
+245
View File
@@ -0,0 +1,245 @@
use std::{ops::Range, time::Duration};
use crate::{
Vim, VimSettings,
motion::{Motion, MotionKind},
object::Object,
state::{Mode, Register},
};
use collections::HashMap;
use editor::{ClipboardSelection, Editor, HighlightKey, SelectionEffects};
use gpui::Context;
use gpui::Window;
use language::Point;
use settings::Settings;
impl Vim {
pub fn yank_motion(
&mut self,
motion: Motion,
times: Option<usize>,
forced_motion: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(cx, |vim, editor, cx| {
let text_layout_details = editor.text_layout_details(window, cx);
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_positions: HashMap<_, _> = Default::default();
let mut kind = None;
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let original_position = (selection.head(), selection.goal);
kind = motion.expand_selection(
map,
selection,
times,
&text_layout_details,
forced_motion,
);
if kind == Some(MotionKind::Exclusive) {
original_positions
.insert(selection.id, (selection.start, selection.goal));
} else {
original_positions.insert(selection.id, original_position);
}
})
});
let Some(kind) = kind else { return };
vim.yank_selections_content(editor, kind, window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |_, selection| {
let (head, goal) = original_positions.remove(&selection.id).unwrap();
selection.collapse_to(head, goal);
});
});
});
});
self.exit_temporary_normal(window, cx);
}
pub fn yank_object(
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_editor(cx, |vim, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut start_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |map, selection| {
object.expand_selection(map, selection, around, times);
let start_position = (selection.start, selection.goal);
start_positions.insert(selection.id, start_position);
});
});
let kind = match object.target_visual_mode(vim.mode, around) {
Mode::VisualLine => MotionKind::Linewise,
_ => MotionKind::Exclusive,
};
vim.yank_selections_content(editor, kind, window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |_, selection| {
let (head, goal) = start_positions.remove(&selection.id).unwrap();
selection.collapse_to(head, goal);
});
});
});
});
self.exit_temporary_normal(window, cx);
}
pub fn yank_selections_content(
&mut self,
editor: &mut Editor,
kind: MotionKind,
window: &mut Window,
cx: &mut Context<Editor>,
) {
self.copy_ranges(
editor,
kind,
true,
editor
.selections
.all_adjusted(&editor.display_snapshot(cx))
.iter()
.map(|s| s.range())
.collect(),
window,
cx,
)
}
pub fn copy_selections_content(
&mut self,
editor: &mut Editor,
kind: MotionKind,
window: &mut Window,
cx: &mut Context<Editor>,
) {
self.copy_ranges(
editor,
kind,
false,
editor
.selections
.all_adjusted(&editor.display_snapshot(cx))
.iter()
.map(|s| s.range())
.collect(),
window,
cx,
)
}
pub(crate) fn copy_ranges(
&mut self,
editor: &mut Editor,
kind: MotionKind,
is_yank: bool,
selections: Vec<Range<Point>>,
window: &mut Window,
cx: &mut Context<Editor>,
) {
let buffer = editor.buffer().read(cx).snapshot(cx);
self.set_mark(
"[".to_string(),
selections
.iter()
.map(|s| buffer.anchor_before(s.start))
.collect(),
editor.buffer(),
window,
cx,
);
self.set_mark(
"]".to_string(),
selections
.iter()
.map(|s| buffer.anchor_after(s.end))
.collect(),
editor.buffer(),
window,
cx,
);
let mut text = String::new();
let mut clipboard_selections = Vec::with_capacity(selections.len());
let mut ranges_to_highlight = Vec::new();
{
let mut is_first = true;
for selection in selections.iter() {
let start = selection.start;
let end = selection.end;
if is_first {
is_first = false;
} else {
text.push('\n');
}
let initial_len = text.len();
let start_anchor = buffer.anchor_after(start);
let end_anchor = buffer.anchor_before(end);
ranges_to_highlight.push(start_anchor..end_anchor);
for chunk in buffer.text_for_range(start..end) {
text.push_str(chunk);
}
if kind.linewise() {
text.push('\n');
}
clipboard_selections.push(ClipboardSelection::for_buffer(
text.len() - initial_len,
false,
start..end,
&buffer,
editor.project(),
cx,
));
}
}
let selected_register = self.selected_register.take();
Vim::update_globals(cx, |globals, cx| {
globals.write_registers(
Register {
text: text.into(),
clipboard_selections: Some(clipboard_selections),
},
selected_register,
is_yank,
kind,
cx,
)
});
let highlight_duration = VimSettings::get_global(cx).highlight_on_yank_duration;
if !is_yank || self.mode == Mode::Visual || highlight_duration == 0 {
return;
}
editor.highlight_background(
HighlightKey::HighlightOnYank,
&ranges_to_highlight,
|_, colors| colors.colors().vim_yank_background,
cx,
);
cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(highlight_duration))
.await;
this.update(cx, |editor, cx| {
editor.clear_background_highlights(HighlightKey::HighlightOnYank, cx)
})
.ok();
})
.detach();
}
}