Add visual area repeating

This commit is contained in:
Conrad Irwin 2023-09-06 16:31:52 -06:00
parent f22d53eef9
commit 1b1d7f22cc
13 changed files with 393 additions and 64 deletions

View File

@ -446,12 +446,10 @@
],
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
"shift-r": "vim::SubstituteLine",
"c": "vim::Substitute",
"~": "vim::ChangeCase",
"shift-i": [
"vim::SwitchMode",
"Insert"
],
"shift-i": "vim::InsertBefore",
"shift-a": "vim::InsertAfter",
"r": [
"vim::PushOperator",

View File

@ -572,7 +572,7 @@ pub struct Editor {
project: Option<ModelHandle<Project>>,
focused: bool,
blink_manager: ModelHandle<BlinkManager>,
show_local_selections: bool,
pub show_local_selections: bool,
mode: EditorMode,
replica_id_mapping: Option<HashMap<ReplicaId, ReplicaId>>,
show_gutter: bool,

View File

@ -65,9 +65,9 @@ struct PreviousWordStart {
#[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Up {
pub(crate) struct Up {
#[serde(default)]
display_lines: bool,
pub(crate) display_lines: bool,
}
#[derive(Clone, Deserialize, PartialEq)]
@ -93,9 +93,9 @@ struct EndOfLine {
#[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct StartOfLine {
pub struct StartOfLine {
#[serde(default)]
display_lines: bool,
pub(crate) display_lines: bool,
}
#[derive(Clone, Deserialize, PartialEq)]

View File

@ -66,21 +66,21 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx);
delete_motion(vim, Motion::Left, times, cx);
})
});
cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx);
delete_motion(vim, Motion::Right, times, cx);
})
});
cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
let times = vim.pop_number_operator(cx);
change_motion(
vim,
@ -94,7 +94,7 @@ pub fn init(cx: &mut AppContext) {
});
cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx);
delete_motion(
vim,
@ -161,7 +161,7 @@ fn move_cursor(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut Win
fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@ -175,7 +175,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
fn insert_before(_: &mut Workspace, _: &InsertBefore, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
});
}
@ -186,7 +186,7 @@ fn insert_first_non_whitespace(
cx: &mut ViewContext<Workspace>,
) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@ -203,7 +203,7 @@ fn insert_first_non_whitespace(
fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@ -217,7 +217,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
@ -250,7 +250,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex
fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording();
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {

View File

@ -7,7 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim};
pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
vim.update_active_editor(cx, |editor, cx| {
let mut ranges = Vec::new();
@ -22,10 +22,16 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
ranges.push(start..end);
cursor_positions.push(start..start);
}
Mode::Visual | Mode::VisualBlock => {
Mode::Visual => {
ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start);
}
Mode::VisualBlock => {
ranges.push(selection.start..selection.end);
if cursor_positions.len() == 0 {
cursor_positions.push(selection.start..selection.start);
}
}
Mode::Insert | Mode::Normal => {
let start = selection.start;
let mut end = start;
@ -97,6 +103,11 @@ mod test {
cx.simulate_shared_keystrokes(["shift-v", "~"]).await;
cx.assert_shared_state("ˇABc\n").await;
// works in visual block mode
cx.set_shared_state("ˇaa\nbb\ncc").await;
cx.simulate_shared_keystrokes(["ctrl-v", "j", "~"]).await;
cx.assert_shared_state("ˇAa\nBb\ncc").await;
// works with multiple cursors (zed only)
cx.set_state("aˇßcdˇe\n", Mode::Normal);
cx.simulate_keystroke("~");

View File

@ -28,7 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) {
fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);

View File

@ -1,5 +1,7 @@
use crate::{
state::{Mode, ReplayableAction},
motion::Motion,
state::{Mode, RecordedSelection, ReplayableAction},
visual::visual_motion,
Vim,
};
use gpui::{actions, AppContext};
@ -11,47 +13,127 @@ pub(crate) fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| {
Vim::update(cx, |vim, cx| {
vim.workspace_state.replaying = false;
vim.update_active_editor(cx, |editor, _| {
editor.show_local_selections = true;
});
vim.switch_mode(Mode::Normal, false, cx)
});
});
cx.add_action(|_: &mut Workspace, _: &Repeat, cx| {
Vim::update(cx, |vim, cx| {
let actions = vim.workspace_state.repeat_actions.clone();
let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| {
let actions = vim.workspace_state.recorded_actions.clone();
let Some(editor) = vim.active_editor.clone() else {
return;
return None;
};
if let Some(new_count) = vim.pop_number_operator(cx) {
vim.workspace_state.recorded_count = Some(new_count);
}
let count = vim.pop_number_operator(cx);
vim.workspace_state.replaying = true;
let window = cx.window();
cx.app_context()
.spawn(move |mut cx| async move {
for action in actions {
match action {
ReplayableAction::Action(action) => window
.dispatch_action(editor.id(), action.as_ref(), &mut cx)
.ok_or_else(|| anyhow::anyhow!("window was closed")),
ReplayableAction::Insertion {
text,
utf16_range_to_replace,
} => editor.update(&mut cx, |editor, cx| {
editor.replay_insert_event(
&text,
utf16_range_to_replace.clone(),
cx,
)
}),
}?
let selection = vim.workspace_state.recorded_selection.clone();
match selection {
RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::Visual, false, cx)
}
RecordedSelection::VisualLine { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::VisualLine, false, cx)
}
RecordedSelection::VisualBlock { .. } => {
vim.workspace_state.recorded_count = None;
vim.switch_mode(Mode::VisualBlock, false, cx)
}
RecordedSelection::None => {
if let Some(count) = count {
vim.workspace_state.recorded_count = Some(count);
}
window
.dispatch_action(editor.id(), &EndRepeat, &mut cx)
.ok_or_else(|| anyhow::anyhow!("window was closed"))
}
}
if let Some(editor) = editor.upgrade(cx) {
editor.update(cx, |editor, _| {
editor.show_local_selections = false;
})
.detach_and_log_err(cx);
});
} else {
return None;
}
Some((actions, editor, selection))
}) else {
return;
};
match selection {
RecordedSelection::SingleLine { cols } => {
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::Visual { rows, cols } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
visual_motion(
Motion::StartOfLine {
display_lines: false,
},
None,
cx,
);
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::VisualBlock { rows, cols } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
if cols > 1 {
visual_motion(Motion::Right, Some(cols as usize - 1), cx);
}
}
RecordedSelection::VisualLine { rows } => {
visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
}
RecordedSelection::None => {}
}
let window = cx.window();
cx.app_context()
.spawn(move |mut cx| async move {
for action in actions {
match action {
ReplayableAction::Action(action) => window
.dispatch_action(editor.id(), action.as_ref(), &mut cx)
.ok_or_else(|| anyhow::anyhow!("window was closed")),
ReplayableAction::Insertion {
text,
utf16_range_to_replace,
} => editor.update(&mut cx, |editor, cx| {
editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
}),
}?
}
window
.dispatch_action(editor.id(), &EndRepeat, &mut cx)
.ok_or_else(|| anyhow::anyhow!("window was closed"))
})
.detach_and_log_err(cx);
});
}
@ -204,4 +286,128 @@ mod test {
Mode::Normal,
);
}
#[gpui::test]
async fn test_repeat_visual(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
// single-line (3 columns)
cx.set_shared_state(indoc! {
"ˇthe quick brown
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"])
.await;
cx.assert_shared_state(indoc! {
"ˇo quick brown
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["j", "w", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"o quick brown
fox ˇops over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["f", "r", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"o quick brown
fox ops oveˇothe lazy dog"
})
.await;
// visual
cx.set_shared_state(indoc! {
"the ˇquick brown
fox jumps over
fox jumps over
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["v", "j", "x"]).await;
cx.assert_shared_state(indoc! {
"the ˇumps over
fox jumps over
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"the ˇumps over
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["w", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"the umps ˇumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["j", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"the umps umps over
the ˇog"
})
.await;
// block mode (3 rows)
cx.set_shared_state(indoc! {
"ˇthe quick brown
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"])
.await;
cx.assert_shared_state(indoc! {
"ˇothe quick brown
ofox jumps over
othe lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"othe quick brown
ofoxˇo jumps over
otheo lazy dog"
})
.await;
// line mode
cx.set_shared_state(indoc! {
"ˇthe quick brown
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"])
.await;
cx.assert_shared_state(indoc! {
"ˇo
fox jumps over
the lazy dog"
})
.await;
cx.simulate_shared_keystrokes(["j", "."]).await;
deterministic.run_until_parked();
cx.assert_shared_state(indoc! {
"o
ˇo
the lazy dog"
})
.await;
}
}

View File

@ -10,6 +10,7 @@ actions!(vim, [Substitute, SubstituteLine]);
pub(crate) fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
let count = vim.pop_number_operator(cx);
substitute(vim, count, vim.state().mode == Mode::VisualLine, cx);
})
@ -17,6 +18,7 @@ pub(crate) fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| {
Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) {
vim.switch_mode(Mode::VisualLine, false, cx)
}

View File

@ -50,6 +50,26 @@ pub struct EditorState {
pub operator_stack: Vec<Operator>,
}
#[derive(Default, Clone, Debug)]
pub enum RecordedSelection {
#[default]
None,
Visual {
rows: u32,
cols: u32,
},
SingleLine {
cols: u32,
},
VisualBlock {
rows: u32,
cols: u32,
},
VisualLine {
rows: u32,
},
}
#[derive(Default, Clone)]
pub struct WorkspaceState {
pub search: SearchState,
@ -59,7 +79,8 @@ pub struct WorkspaceState {
pub stop_recording_after_next_action: bool,
pub replaying: bool,
pub recorded_count: Option<usize>,
pub repeat_actions: Vec<ReplayableAction>,
pub recorded_actions: Vec<ReplayableAction>,
pub recorded_selection: RecordedSelection,
}
#[derive(Debug)]

View File

@ -18,13 +18,13 @@ use gpui::{
actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
};
use language::{CursorShape, Selection, SelectionGoal};
use language::{CursorShape, Point, Selection, SelectionGoal};
pub use mode_indicator::ModeIndicator;
use motion::Motion;
use normal::normal_replace;
use serde::Deserialize;
use settings::{Setting, SettingsStore};
use state::{EditorState, Mode, Operator, WorkspaceState};
use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState};
use std::{ops::Range, sync::Arc};
use visual::{visual_block_motion, visual_replace};
use workspace::{self, Workspace};
@ -107,7 +107,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) {
Vim::update(cx, |vim, _| {
if vim.workspace_state.recording {
vim.workspace_state
.repeat_actions
.recorded_actions
.push(ReplayableAction::Action(handled_by.boxed_clone()));
if vim.workspace_state.stop_recording_after_next_action {
@ -204,7 +204,7 @@ impl Vim {
Vim::update(cx, |vim, _| {
if vim.workspace_state.recording {
vim.workspace_state
.repeat_actions
.recorded_actions
.push(ReplayableAction::Insertion {
text: text.clone(),
utf16_range_to_replace: range_to_replace,
@ -232,16 +232,51 @@ impl Vim {
// TODO: shift-j?
//
pub fn start_recording(&mut self) {
pub fn start_recording(&mut self, cx: &mut WindowContext) {
if !self.workspace_state.replaying {
self.workspace_state.recording = true;
self.workspace_state.repeat_actions = Default::default();
self.workspace_state.recorded_actions = Default::default();
self.workspace_state.recorded_count =
if let Some(Operator::Number(number)) = self.active_operator() {
Some(number)
} else {
None
};
let selections = self
.active_editor
.and_then(|editor| editor.upgrade(cx))
.map(|editor| {
let editor = editor.read(cx);
(
editor.selections.oldest::<Point>(cx),
editor.selections.newest::<Point>(cx),
)
});
if let Some((oldest, newest)) = selections {
self.workspace_state.recorded_selection = match self.state().mode {
Mode::Visual if newest.end.row == newest.start.row => {
RecordedSelection::SingleLine {
cols: newest.end.column - newest.start.column,
}
}
Mode::Visual => RecordedSelection::Visual {
rows: newest.end.row - newest.start.row,
cols: newest.end.column,
},
Mode::VisualLine => RecordedSelection::VisualLine {
rows: newest.end.row - newest.start.row,
},
Mode::VisualBlock => RecordedSelection::VisualBlock {
rows: newest.end.row.abs_diff(oldest.start.row),
cols: newest.end.column.abs_diff(oldest.start.column),
},
_ => RecordedSelection::None,
}
} else {
self.workspace_state.recorded_selection = RecordedSelection::None;
}
}
}
@ -251,8 +286,8 @@ impl Vim {
}
}
pub fn record_current_action(&mut self) {
self.start_recording();
pub fn record_current_action(&mut self, cx: &mut WindowContext) {
self.start_recording(cx);
self.stop_recording();
}
@ -322,7 +357,7 @@ impl Vim {
operator,
Operator::Change | Operator::Delete | Operator::Replace
) {
self.start_recording()
self.start_recording(cx)
};
self.update_state(|state| state.operator_stack.push(operator));
self.sync_vim_settings(cx);

View File

@ -277,7 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace
pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.record_current_action();
vim.record_current_action(cx);
vim.update_active_editor(cx, |editor, cx| {
let mut original_columns: HashMap<_, _> = Default::default();
let line_mode = editor.selections.line_mode;

View File

@ -16,3 +16,8 @@
{"Key":"shift-v"}
{"Key":"~"}
{"Get":{"state":"ˇABc\n","mode":"Normal"}}
{"Put":{"state":"ˇaa\nbb\ncc"}}
{"Key":"ctrl-v"}
{"Key":"j"}
{"Key":"~"}
{"Get":{"state":"ˇAa\nBb\ncc","mode":"Normal"}}

View File

@ -0,0 +1,51 @@
{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
{"Key":"s"}
{"Key":"o"}
{"Key":"escape"}
{"Get":{"state":"ˇo quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}}
{"Key":"j"}
{"Key":"w"}
{"Key":"."}
{"Get":{"state":"o quick brown\nfox ˇops over\nthe lazy dog","mode":"Normal"}}
{"Key":"f"}
{"Key":"r"}
{"Key":"."}
{"Get":{"state":"o quick brown\nfox ops oveˇothe lazy dog","mode":"Normal"}}
{"Put":{"state":"the ˇquick brown\nfox jumps over\nfox jumps over\nfox jumps over\nthe lazy dog"}}
{"Key":"v"}
{"Key":"j"}
{"Key":"x"}
{"Get":{"state":"the ˇumps over\nfox jumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}}
{"Key":"."}
{"Get":{"state":"the ˇumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}}
{"Key":"w"}
{"Key":"."}
{"Get":{"state":"the umps ˇumps over\nthe lazy dog","mode":"Normal"}}
{"Key":"j"}
{"Key":"."}
{"Get":{"state":"the umps umps over\nthe ˇog","mode":"Normal"}}
{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"ctrl-v"}
{"Key":"j"}
{"Key":"j"}
{"Key":"shift-i"}
{"Key":"o"}
{"Key":"escape"}
{"Get":{"state":"ˇothe quick brown\nofox jumps over\nothe lazy dog","mode":"Normal"}}
{"Key":"j"}
{"Key":"4"}
{"Key":"l"}
{"Key":"."}
{"Get":{"state":"othe quick brown\nofoxˇo jumps over\notheo lazy dog","mode":"Normal"}}
{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"shift-v"}
{"Key":"shift-r"}
{"Key":"o"}
{"Key":"escape"}
{"Get":{"state":"ˇo\nfox jumps over\nthe lazy dog","mode":"Normal"}}
{"Key":"j"}
{"Key":"."}
{"Get":{"state":"o\nˇo\nthe lazy dog","mode":"Normal"}}