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
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
"});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user