Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
Unreleased
==========
- feat: add `.` command to repeat the last change (dot-repeat)

Released
--------

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "edtui"
version = "0.11.4"
version = "0.11.5"
edition = "2021"
repository = "https://github.com/preiter93/edtui"
keywords = ["ratatui", "tui", "editor", "text", "vim"]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ falling back to a platform-specific default if neither is set.
| `y` | Copy the selected text in visual mode |
| `yy` | Copy the current line in normal mode |
| `p` | Paste the copied text |
| `.` | Repeat the last change |
| `Home` | Move cursor to start of line |
| `End` | Move cursor to end of line |
| `ctrl+e` | Open in system editor (requires `system-editor` feature) |
Expand Down
16 changes: 15 additions & 1 deletion examples/app.tape
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Set Padding 2
Set BorderRadius 10
Set FontSize 32
Set Width 2400
Set Height 1100
Set Height 1150
Set PlaybackSpeed 1.0

Hide
Expand Down Expand Up @@ -56,6 +56,20 @@ Sleep 1.5s
Type "j0j"
Sleep 0.5s

# Repeat the last change with the dot command
Type "wwww"
Sleep 0.3s
Type "ciwbar"
Sleep 0.3s
Escape
Sleep 0.5s
Type "w."
Sleep 0.5s
Type "w."
Sleep 1.2s
Type "j0j"
Sleep 0.2s

# Search
Type "/"
Sleep 0.2s
Expand Down
2 changes: 2 additions & 0 deletions examples/app/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Select text (v), including selection between \"quotes\" (viw/vi\").

Copy and paste text:

Repeat edits with '.': foo foo foo

Built-in search using the '/' command.

Supports syntax highlighting:
Expand Down
Binary file modified resources/app.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ pub enum Action {
SelectLine(SelectLine),
Undo(Undo),
Redo(Redo),
RepeatLastChange(RepeatLastChange),
Paste(Paste),
PasteOverSelection(PasteOverSelection),
CopySelection(CopySelection),
Expand All @@ -120,6 +121,11 @@ pub enum Action {
#[enum_dispatch]
pub trait Execute {
fn execute(&mut self, state: &mut EditorState);

/// Whether this action can be replayed by the dot-repeat command.
fn is_repeatable(&self) -> bool {
false
}
}

pub trait Chainable {
Expand All @@ -137,6 +143,8 @@ pub struct SwitchMode(pub EditorMode);

impl Execute for SwitchMode {
fn execute(&mut self, state: &mut EditorState) {
let from_insert = state.mode == EditorMode::Insert;

state.clamp_column();
match self.0 {
EditorMode::Normal => {
Expand All @@ -153,6 +161,23 @@ impl Execute for SwitchMode {
EditorMode::Search => {}
}
state.mode = self.0;

if self.0 == EditorMode::Normal {
// When leaving insert mode, move the cursor one column left so it
// rests on the last typed character rather than the empty slot
// after it.
if from_insert && state.cursor.col > 0 {
state.cursor.col -= 1;
}

// Re-clamp so the cursor never lingers past the end of the line.
state.clamp_column();
}
}

fn is_repeatable(&self) -> bool {
// Entering insert mode begins a repeatable insert session (`.`).
self.0 == EditorMode::Insert
}
}

Expand All @@ -165,6 +190,26 @@ impl Execute for Undo {
}
}

/// Repeats the last buffer-changing command (dot-repeat).
#[derive(Clone, Debug)]
pub struct RepeatLastChange;

impl Execute for RepeatLastChange {
fn execute(&mut self, state: &mut EditorState) {
let Some(mut action) = state.last_change.clone() else {
return;
};
action.execute(state);

if let Some(text) = state.last_insert.clone() {
for c in text.chars() {
InsertChar(c).execute(state);
}
SwitchMode(EditorMode::Normal).execute(state);
}
}
}

#[derive(Clone, Debug)]
pub struct Redo;

Expand Down Expand Up @@ -197,6 +242,10 @@ impl Execute for Composed {
action.execute(state);
}
}

fn is_repeatable(&self) -> bool {
self.0.iter().any(Execute::is_repeatable)
}
}

#[cfg(test)]
Expand Down
24 changes: 24 additions & 0 deletions src/actions/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ impl Execute for ChangeWord {
DeleteWordEnd(self.0).execute(state);
state.mode = EditorMode::Insert;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Changes from the cursor to the end of the current WORD: deletes it and
Expand All @@ -32,6 +36,10 @@ impl Execute for ChangeBigWord {
DeleteBigWordEnd(self.0).execute(state);
state.mode = EditorMode::Insert;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Changes the inner word under the cursor: deletes it and enters insert mode.
Expand All @@ -44,6 +52,10 @@ impl Execute for ChangeInnerWord {
DeleteInnerWord.execute(state);
state.mode = EditorMode::Insert;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Changes the inner WORD under the cursor: deletes it and enters insert mode.
Expand All @@ -56,6 +68,10 @@ impl Execute for ChangeInnerBigWord {
DeleteInnerBigWord.execute(state);
state.mode = EditorMode::Insert;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Changes the text between the given delimiters: deletes the inner content and
Expand All @@ -78,6 +94,10 @@ impl Execute for ChangeInnerBetween {
DeleteInnerBetween::new(self.opening, self.closing).execute(state);
state.mode = EditorMode::Insert;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Changes the current selection: deletes it and enters insert mode.
Expand All @@ -92,6 +112,10 @@ impl Execute for ChangeSelection {
state.clip.set_text(deleted.into());
}
}

fn is_repeatable(&self) -> bool {
true
}
}

#[cfg(test)]
Expand Down
8 changes: 8 additions & 0 deletions src/actions/cpaste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ impl Execute for Paste {

append_str(&mut state.lines, &mut state.cursor, s);
}

fn is_repeatable(&self) -> bool {
true
}
}

#[derive(Clone, Debug)]
Expand All @@ -66,6 +70,10 @@ impl Execute for PasteOverSelection {
insert_str(&mut state.lines, &mut state.cursor, &text);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

#[derive(Clone, Debug)]
Expand Down
48 changes: 48 additions & 0 deletions src/actions/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ impl Execute for RemoveChar {
);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Replaces the character under the cursor with a given character.
Expand All @@ -56,6 +60,10 @@ impl Execute for ReplaceChar {
*ch = self.0;
};
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Deletes a character to the left of the current cursor. Deletes
Expand All @@ -71,6 +79,10 @@ impl Execute for DeleteChar {
delete_char(&mut state.lines, &mut state.cursor);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

fn delete_char(lines: &mut Lines, index: &mut Index2) {
Expand Down Expand Up @@ -118,6 +130,10 @@ impl Execute for DeleteCharForward {
delete_char_forward(&mut state.lines, &mut state.cursor);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

fn delete_char_forward(lines: &mut Lines, index: &mut Index2) {
Expand Down Expand Up @@ -154,6 +170,10 @@ impl Execute for DeleteWordForward {
delete_word_forward(state);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

fn delete_motion_forward<F>(
Expand Down Expand Up @@ -226,6 +246,10 @@ impl Execute for DeleteBigWordForward {
delete_big_word_forward(state);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

fn delete_big_word_forward(state: &mut EditorState) {
Expand Down Expand Up @@ -310,6 +334,10 @@ impl Execute for DeleteWordBackward {
delete_word_backward(state);
}
}

fn is_repeatable(&self) -> bool {
true
}
}

fn delete_word_backward(state: &mut EditorState) {
Expand Down Expand Up @@ -384,6 +412,10 @@ impl Execute for DeleteLine {
state.cursor.row = state.cursor.row.min(state.lines.len().saturating_sub(1));
}
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Deletes from the current cursor position to the first non-whitespace character of the line
Expand Down Expand Up @@ -415,6 +447,10 @@ impl Execute for DeleteToFirstCharOfLine {

state.cursor.col = anchor;
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Deletes from the current cursor position to the end of the line
Expand All @@ -434,6 +470,10 @@ impl Execute for DeleteToEndOfLine {
state.cursor.col = state.cursor.col.saturating_sub(1);
state.clip.set_text(deleted_chars.collect());
}

fn is_repeatable(&self) -> bool {
true
}
}

/// Deletes the current selection.
Expand All @@ -449,6 +489,10 @@ impl Execute for DeleteSelection {
}
state.selection = None;
}

fn is_repeatable(&self) -> bool {
true
}
}

pub(crate) fn delete_selection(state: &mut EditorState, selection: &Selection) -> Lines {
Expand All @@ -469,6 +513,10 @@ impl Execute for JoinLineWithLineBelow {
state.capture();
state.lines.join_lines(state.cursor.row);
}

fn is_repeatable(&self) -> bool {
true
}
}

#[cfg(test)]
Expand Down
Loading
Loading