feat: add counted cc and dd line actions
This commit is contained in:
+285
-9
@@ -246,6 +246,10 @@ enum EditStart {
|
||||
change: bool,
|
||||
selector: SelectionIntent,
|
||||
},
|
||||
Lines {
|
||||
change: bool,
|
||||
count: usize,
|
||||
},
|
||||
Delete,
|
||||
Join,
|
||||
Paste {
|
||||
@@ -303,6 +307,8 @@ enum Action {
|
||||
Normal,
|
||||
Newline,
|
||||
Backspace,
|
||||
DeleteLine,
|
||||
ChangeLine,
|
||||
Submit,
|
||||
Search,
|
||||
SearchNext,
|
||||
@@ -407,6 +413,8 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
("core.copy_system", Action::CopySystem, &[Mode::Visual]),
|
||||
("core.cut", Action::Cut, &[Mode::Visual]),
|
||||
("core.change", Action::Change, &[Mode::Visual]),
|
||||
("core.delete_line", Action::DeleteLine, &[Mode::Normal]),
|
||||
("core.change_line", Action::ChangeLine, &[Mode::Normal]),
|
||||
("core.paste_before", Action::PasteBefore, &[Mode::Normal]),
|
||||
("core.paste_after", Action::PasteAfter, &[Mode::Normal]),
|
||||
(
|
||||
@@ -1003,6 +1011,7 @@ pub struct Editor {
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
g_pending: bool,
|
||||
line_pending: Option<char>,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
completion: Option<CompletionState>,
|
||||
@@ -1069,6 +1078,7 @@ impl Editor {
|
||||
leader_suffix: Vec::new(),
|
||||
leader_display: String::new(),
|
||||
g_pending: false,
|
||||
line_pending: None,
|
||||
palette: None,
|
||||
hover: None,
|
||||
completion: None,
|
||||
@@ -2132,14 +2142,19 @@ impl Editor {
|
||||
self.g_pending
|
||||
}
|
||||
|
||||
pub fn line_operator_pending(&self) -> Option<char> {
|
||||
self.line_pending
|
||||
}
|
||||
|
||||
fn clear_leader(&mut self) {
|
||||
self.g_pending = false;
|
||||
self.line_pending = None;
|
||||
self.leader_suffix.clear();
|
||||
self.leader_display.clear();
|
||||
}
|
||||
|
||||
fn cancel_leader(&mut self) {
|
||||
if self.leader_pending().is_some() || self.g_pending {
|
||||
if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() {
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
}
|
||||
@@ -2546,6 +2561,8 @@ impl Editor {
|
||||
.collect();
|
||||
let native = match action {
|
||||
Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"),
|
||||
Some(Action::DeleteLine) if self.active.mode == Mode::Normal => Some("dd"),
|
||||
Some(Action::ChangeLine) if self.active.mode == Mode::Normal => Some("cc"),
|
||||
Some(Action::DocumentStart)
|
||||
if matches!(self.active.mode, Mode::Normal | Mode::Visual) =>
|
||||
{
|
||||
@@ -2558,14 +2575,16 @@ impl Editor {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(native) = native
|
||||
&& self.leader != Input::Char('g')
|
||||
&& !self
|
||||
.bindings
|
||||
.get(&self.active.mode)
|
||||
.is_some_and(|keys| keys.contains_key(&Input::Char('g')))
|
||||
{
|
||||
bindings.push(native.into());
|
||||
if let Some(native) = native {
|
||||
let prefix = Input::Char(native.as_bytes()[0] as char);
|
||||
if self.leader != prefix
|
||||
&& !self
|
||||
.bindings
|
||||
.get(&self.active.mode)
|
||||
.is_some_and(|keys| keys.contains_key(&prefix))
|
||||
{
|
||||
bindings.push(native.into());
|
||||
}
|
||||
}
|
||||
if !self
|
||||
.bindings
|
||||
@@ -2877,6 +2896,19 @@ impl Editor {
|
||||
if self.palette.is_some() {
|
||||
return self.handle_palette(input);
|
||||
}
|
||||
if let Some(operator) = self.line_pending.take() {
|
||||
if self.active.mode == Mode::Normal && input == Input::Char(operator) {
|
||||
return self.act(if operator == 'd' {
|
||||
Action::DeleteLine
|
||||
} else {
|
||||
Action::ChangeLine
|
||||
});
|
||||
}
|
||||
self.count = None;
|
||||
if input == Input::Escape {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Paste is literal input, never a configurable key or command.
|
||||
if let Input::Paste(text) = input {
|
||||
self.count = None;
|
||||
@@ -2942,6 +2974,12 @@ impl Editor {
|
||||
self.g_pending = true;
|
||||
return None;
|
||||
}
|
||||
if self.active.mode == Mode::Normal
|
||||
&& let Input::Char(operator @ ('c' | 'd')) = input
|
||||
{
|
||||
self.line_pending = Some(operator);
|
||||
return None;
|
||||
}
|
||||
self.count = None;
|
||||
match (self.active.mode, input) {
|
||||
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
||||
@@ -3031,6 +3069,9 @@ impl Editor {
|
||||
}
|
||||
Action::JumpBack => self.traverse_jumps(false, count),
|
||||
Action::JumpForward => self.traverse_jumps(true, count),
|
||||
Action::DeleteLine | Action::ChangeLine => {
|
||||
self.edit_lines(matches!(action, Action::ChangeLine), count);
|
||||
}
|
||||
Action::LspDefinition => return self.execute("lsp.definition"),
|
||||
Action::LspHover => return self.execute("lsp.hover"),
|
||||
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
|
||||
@@ -4198,6 +4239,72 @@ impl Editor {
|
||||
self.message.clear();
|
||||
}
|
||||
|
||||
fn line_selections(&mut self, count: usize) -> Vec<Selection> {
|
||||
self.active.mode = Mode::Visual;
|
||||
self.active.linewise = true;
|
||||
self.active.visual_motions = None;
|
||||
let last = self.active.lines.len() - 1;
|
||||
for target in &mut self.active.targets {
|
||||
target.anchor = Cursor {
|
||||
row: target.active.row,
|
||||
column: 0,
|
||||
};
|
||||
target.active = Cursor {
|
||||
row: target
|
||||
.anchor
|
||||
.row
|
||||
.saturating_add(count.saturating_sub(1))
|
||||
.min(last),
|
||||
column: 0,
|
||||
};
|
||||
target.preferred_columns = None;
|
||||
}
|
||||
self.normalize_targets();
|
||||
self.selections()
|
||||
}
|
||||
|
||||
fn edit_lines(&mut self, change: bool, count: usize) {
|
||||
let targets = self.active.targets.clone();
|
||||
let primary = self.active.primary;
|
||||
let ranges = self.line_selections(count);
|
||||
let fragments = self.selection_fragments(&ranges);
|
||||
if let Err(error) = self.store_clipboard(
|
||||
fragments,
|
||||
self.clipboard_mode == ClipboardMode::System,
|
||||
true,
|
||||
) {
|
||||
self.active.targets = targets;
|
||||
self.active.primary = primary;
|
||||
self.active.mode = Mode::Normal;
|
||||
self.active.linewise = false;
|
||||
self.set_message(error);
|
||||
return;
|
||||
}
|
||||
let before = Snapshot {
|
||||
lines: self.active.lines.clone(),
|
||||
targets,
|
||||
primary,
|
||||
};
|
||||
let start = EditStart::Lines { change, count };
|
||||
if change {
|
||||
self.start_insert(before, start);
|
||||
self.edit_ranges(Action::Change, &ranges);
|
||||
} else {
|
||||
self.edit_ranges(Action::Cut, &ranges);
|
||||
self.active.mode = Mode::Normal;
|
||||
self.normalize_targets();
|
||||
self.record_native(
|
||||
before,
|
||||
EditRecipe {
|
||||
start,
|
||||
steps: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.message.clear();
|
||||
self.reveal_caret();
|
||||
}
|
||||
|
||||
fn edit_selection(&mut self, action: Action) {
|
||||
let ranges = self.selections();
|
||||
let fragments = self.selection_fragments(&ranges);
|
||||
@@ -4803,6 +4910,15 @@ impl Editor {
|
||||
let mut clipboard = None;
|
||||
match &recipe.start {
|
||||
EditStart::Insert(action) => self.place_insert(*action),
|
||||
EditStart::Lines { change, count } => {
|
||||
let ranges = self.line_selections(*count);
|
||||
clipboard = Some((self.selection_fragments(&ranges), true));
|
||||
self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges);
|
||||
if !*change {
|
||||
self.active.mode = Mode::Normal;
|
||||
self.normalize_targets();
|
||||
}
|
||||
}
|
||||
EditStart::Selection { change, selector } => {
|
||||
let ranges = self.replay_ranges(selector);
|
||||
clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise()));
|
||||
@@ -6059,6 +6175,137 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
|
||||
let mut editor = editor();
|
||||
let source = "zero\n αone\n\tlast\nend";
|
||||
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
|
||||
type_text(&mut editor, "jll2dd");
|
||||
assert_eq!(editor.context().text, "zero\nend");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
|
||||
type_text(&mut editor, "UP");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() {
|
||||
let mut editor = editor();
|
||||
let source = "keep\n one\ntwo\nend";
|
||||
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
|
||||
type_text(&mut editor, "j2cc");
|
||||
assert_eq!(editor.context().text, "keep\n\nend");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
type_text(&mut editor, "new");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
type_text(&mut editor, "Uj.");
|
||||
assert_eq!(editor.context().text, "keep\nnew\nnew");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "keep\nnew\nend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() {
|
||||
for (source, keys, expected) in [
|
||||
("a\nb", "jdd", "a"),
|
||||
("a\nb\n", "jdd", "a\n"),
|
||||
("a\nb\n", "gbdd", "a\nb"),
|
||||
("a", "dd", ""),
|
||||
("", "dd", ""),
|
||||
("a\nb\nc", "2dd", "c"),
|
||||
("a\nb", "jcc", "a\n"),
|
||||
] {
|
||||
let mut editor = editor();
|
||||
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
|
||||
type_text(&mut editor, keys);
|
||||
assert_eq!(editor.context().text, expected, "{keys} on {source:?}");
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
if source != expected {
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
}
|
||||
let directory = Directory::new();
|
||||
let target = directory.path("repeat");
|
||||
fs::write(&target, "1\n2\n3\n4\n5").unwrap();
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "gb3dd");
|
||||
assert_eq!(editor.context().text, "a\nb");
|
||||
editor.execute(&format!("edit {}", target.display()));
|
||||
type_text(&mut editor, ".");
|
||||
assert_eq!(editor.context().text, "4\n5");
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "1\n2\n3\n4\n5");
|
||||
type_text(&mut editor, &format!("{}dd", usize::MAX));
|
||||
assert_eq!(editor.context().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() {
|
||||
let mut editor = editor();
|
||||
let source = "aa\nbb\ncc\ndd\nee";
|
||||
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
type_text(&mut editor, "2dd");
|
||||
assert_eq!(editor.context().text, "dd\nee");
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
type_text(&mut editor, "UP");
|
||||
assert_eq!(editor.context().text, source);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
type_text(&mut editor, "ccX");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee");
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
type_text(&mut editor, "2d");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.context().text, "a\nb\nc\nd");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "gtdj");
|
||||
assert_eq!(editor.cursor().row, 1);
|
||||
editor.bind("normal", "d", None).unwrap();
|
||||
type_text(&mut editor, "dd");
|
||||
assert_eq!(editor.context().text, "a\nb\nc\nd");
|
||||
editor
|
||||
.bind("normal", "z", Some("core.delete_line".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "2z");
|
||||
assert_eq!(editor.context().text, "a\nd");
|
||||
editor
|
||||
.bind("normal", "q", Some("core.change_line".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "qccdd");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "a\nccdd");
|
||||
}
|
||||
fn project_editor(directory: &Directory) -> Editor {
|
||||
let mut editor = editor();
|
||||
editor.project_root = directory.0.clone();
|
||||
@@ -7237,6 +7484,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.external("keep");
|
||||
let mut editor = fixture.editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("one\ntwo\nthree".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "iX");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
let revision = editor.text_revision();
|
||||
fixture.fail("fail-write", true);
|
||||
for keys in ["2dd", "cc"] {
|
||||
type_text(&mut editor, keys);
|
||||
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(fixture.bytes("state"), b"keep");
|
||||
}
|
||||
type_text(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "one\ntwo\nthree");
|
||||
type_text(&mut editor, "U.");
|
||||
assert_eq!(editor.context().text, "XXone\ntXwo\nthree");
|
||||
}
|
||||
|
||||
// Includes every replay-owned state, not the intentionally changed error message.
|
||||
fn state(editor: &Editor) -> String {
|
||||
format!(
|
||||
|
||||
Reference in New Issue
Block a user