diff --git a/CHANGELOG.md b/CHANGELOG.md index faf0886..cb8a83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +Unreleased +========== +- feat: add `.` command to repeat the last change (dot-repeat) + Released -------- diff --git a/Cargo.lock b/Cargo.lock index dc31d28..a215ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,7 +417,7 @@ dependencies = [ [[package]] name = "edtui" -version = "0.11.4" +version = "0.11.5" dependencies = [ "arbitrary", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 72105a3..ff9fd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/README.md b/README.md index 9b26128..04a6e7a 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/examples/app.tape b/examples/app.tape index b4d4299..c50685d 100644 --- a/examples/app.tape +++ b/examples/app.tape @@ -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 @@ -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 diff --git a/examples/app/main.rs b/examples/app/main.rs index caf78ea..07498de 100644 --- a/examples/app/main.rs +++ b/examples/app/main.rs @@ -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: diff --git a/resources/app.gif b/resources/app.gif index 249f4c2..5612230 100644 Binary files a/resources/app.gif and b/resources/app.gif differ diff --git a/src/actions.rs b/src/actions.rs index a680ebf..09254da 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -100,6 +100,7 @@ pub enum Action { SelectLine(SelectLine), Undo(Undo), Redo(Redo), + RepeatLastChange(RepeatLastChange), Paste(Paste), PasteOverSelection(PasteOverSelection), CopySelection(CopySelection), @@ -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 { @@ -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 => { @@ -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 } } @@ -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; @@ -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)] diff --git a/src/actions/change.rs b/src/actions/change.rs index 96e35f4..01c5111 100644 --- a/src/actions/change.rs +++ b/src/actions/change.rs @@ -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 @@ -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. @@ -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. @@ -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 @@ -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. @@ -92,6 +112,10 @@ impl Execute for ChangeSelection { state.clip.set_text(deleted.into()); } } + + fn is_repeatable(&self) -> bool { + true + } } #[cfg(test)] diff --git a/src/actions/cpaste.rs b/src/actions/cpaste.rs index 494b102..7669896 100644 --- a/src/actions/cpaste.rs +++ b/src/actions/cpaste.rs @@ -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)] @@ -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)] diff --git a/src/actions/delete.rs b/src/actions/delete.rs index e17b452..a65b78d 100644 --- a/src/actions/delete.rs +++ b/src/actions/delete.rs @@ -38,6 +38,10 @@ impl Execute for RemoveChar { ); } } + + fn is_repeatable(&self) -> bool { + true + } } /// Replaces the character under the cursor with a given character. @@ -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 @@ -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) { @@ -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) { @@ -154,6 +170,10 @@ impl Execute for DeleteWordForward { delete_word_forward(state); } } + + fn is_repeatable(&self) -> bool { + true + } } fn delete_motion_forward( @@ -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) { @@ -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) { @@ -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 @@ -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 @@ -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. @@ -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 { @@ -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)] diff --git a/src/actions/insert.rs b/src/actions/insert.rs index 65d24eb..71ff229 100644 --- a/src/actions/insert.rs +++ b/src/actions/insert.rs @@ -19,6 +19,15 @@ impl Execute for InsertChar { return; } insert_char(&mut state.lines, &mut state.cursor, self.0, false); + + // Capture insert session for the dot-repeat command + if let Some(buffer) = &mut state.insert_recording { + buffer.push(self.0); + } + } + + fn is_repeatable(&self) -> bool { + true } } @@ -40,6 +49,10 @@ impl Execute for LineBreak { line_break(&mut state.lines, &mut state.cursor); } } + + fn is_repeatable(&self) -> bool { + true + } } /// Appends a newline below the current cursor position. @@ -66,6 +79,10 @@ impl Execute for AppendNewline { } } } + + fn is_repeatable(&self) -> bool { + true + } } /// Appends a newline at the current cursor position. @@ -84,6 +101,10 @@ impl Execute for InsertNewline { state.lines.insert(RowIndex::new(state.cursor.row), vec![]); } } + + fn is_repeatable(&self) -> bool { + true + } } /// Pushes a line to the back of the buffer. diff --git a/src/events/key.rs b/src/events/key.rs index e0a99d9..0ccf789 100644 --- a/src/events/key.rs +++ b/src/events/key.rs @@ -20,8 +20,9 @@ use crate::actions::{ JoinLineWithLineBelow, LineBreak, MoveBackward, MoveDown, MoveForward, MoveHalfPageUp, MoveParagraphBackward, MoveParagraphForward, MoveToEndOfLine, MoveToFirst, MoveToMatchinBracket, MoveToStartOfLine, MoveUp, MoveWordBackward, MoveWordForward, - MoveWordForwardToEndOfWord, Paste, Redo, RemoveChar, RemoveCharFromSearch, SelectCurrentSearch, - SelectInnerBetween, SelectInnerWord, SelectLine, StopSearch, SwitchMode, Undo, + MoveWordForwardToEndOfWord, Paste, Redo, RemoveChar, RemoveCharFromSearch, RepeatLastChange, + SelectCurrentSearch, SelectInnerBetween, SelectInnerWord, SelectLine, StopSearch, SwitchMode, + Undo, }; use crate::events::KeyInput; use crate::{EditorMode, EditorState}; @@ -750,6 +751,11 @@ fn vim_keybindings() -> HashMap { (KeyEventRegister::n(vec![KeyInput::new('u')]), Undo.into()), // Redo (KeyEventRegister::n(vec![KeyInput::ctrl('r')]), Redo.into()), + // Repeat the last change + ( + KeyEventRegister::n(vec![KeyInput::new('.')]), + RepeatLastChange.into(), + ), // Copy ( KeyEventRegister::v(vec![KeyInput::new('y')]), @@ -1077,8 +1083,8 @@ impl KeyEventHandler { } // Else lookup an action from the register - if let Some(mut action) = self.get(key_input, mode) { - action.execute(state); + if let Some(action) = self.get(key_input, mode) { + state.execute_recorded(action); } } } @@ -1160,6 +1166,161 @@ mod tests { assert_eq!(state.lines.to_string(), String::from("Hello World!\nHi!")); } + #[test] + fn test_dot_repeats_last_change() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("aaaa")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `x` deletes one character, `.` repeats it twice more. + handler.on_event(KeyInput::new('x'), &mut state); + handler.on_event(KeyInput::new('.'), &mut state); + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "a"); + } + + #[test] + fn test_dot_repeats_multikey_change() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("one two three")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `dw` deletes a word forward; `.` repeats the whole multi-key command. + handler.on_event(KeyInput::new('d'), &mut state); + handler.on_event(KeyInput::new('w'), &mut state); + assert_eq!(state.lines.to_string(), "two three"); + + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "three"); + } + + #[test] + fn test_dot_is_noop_without_prior_change() { + use crate::{EditorState, Lines}; + + let mut state = EditorState::new(Lines::from("hello")); + let mut handler = KeyEventHandler::default(); + + // Motions are not changes, so `.` has nothing to repeat. + handler.on_event(KeyInput::new('l'), &mut state); + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "hello"); + } + + #[test] + fn test_dot_repeats_change_inner_word() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("foo bar baz")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `ciw` changes the inner word, then we type replacement text and Esc. + handler.on_event(KeyInput::new('c'), &mut state); + handler.on_event(KeyInput::new('i'), &mut state); + handler.on_event(KeyInput::new('w'), &mut state); + handler.on_event(KeyInput::new('x'), &mut state); + handler.on_event(KeyInput::new('y'), &mut state); + handler.on_event(KeyInput::new(KeyCode::Esc), &mut state); + assert_eq!(state.lines.to_string(), "xy bar baz"); + assert_eq!(state.mode, EditorMode::Normal); + // Cursor rests on the last inserted character (Vim-style), not after it. + assert_eq!(state.cursor, Index2::new(0, 1)); + + // Move onto the next word and repeat the whole change with `.`. + handler.on_event(KeyInput::new('w'), &mut state); + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "xy xy baz"); + assert_eq!(state.mode, EditorMode::Normal); + // Mid-line too, the cursor lands on the last inserted character (the + // second `y`), not on the trailing space after the word. + assert_eq!(state.cursor, Index2::new(0, 4)); + } + + #[test] + fn test_dot_repeats_insert_session() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("ab")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `i` opens an insert session; type `X` and leave with Esc. + handler.on_event(KeyInput::new('i'), &mut state); + handler.on_event(KeyInput::new('X'), &mut state); + handler.on_event(KeyInput::new(KeyCode::Esc), &mut state); + assert_eq!(state.lines.to_string(), "Xab"); + assert_eq!(state.mode, EditorMode::Normal); + + // `.` replays the whole insert session at the cursor. + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "XXab"); + assert_eq!(state.mode, EditorMode::Normal); + } + + #[test] + fn test_dot_repeats_change_word() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("one two three")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `cw` changes the word under the cursor; type `X` and leave insert. + handler.on_event(KeyInput::new('c'), &mut state); + handler.on_event(KeyInput::new('w'), &mut state); + handler.on_event(KeyInput::new('X'), &mut state); + handler.on_event(KeyInput::new(KeyCode::Esc), &mut state); + assert_eq!(state.lines.to_string(), "X two three"); + + // Put the cursor on the next word and repeat the change with `.`. + state.cursor = Index2::new(0, 2); + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "X X three"); + assert_eq!(state.mode, EditorMode::Normal); + } + + #[test] + fn test_dot_repeats_open_line() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("a\nb")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `o` opens a line below and enters insert; type `X` and leave. + handler.on_event(KeyInput::new('o'), &mut state); + handler.on_event(KeyInput::new('X'), &mut state); + handler.on_event(KeyInput::new(KeyCode::Esc), &mut state); + assert_eq!(state.lines.to_string(), "a\nX\nb"); + + // `.` opens another line below the current one and replays the text. + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "a\nX\nX\nb"); + assert_eq!(state.mode, EditorMode::Normal); + } + + #[test] + fn test_dot_repeats_delete_line() { + use crate::{EditorState, Index2, Lines}; + + let mut state = EditorState::new(Lines::from("a\nb\nc\nd")); + let mut handler = KeyEventHandler::default(); + state.cursor = Index2::new(0, 0); + + // `dd` deletes the current line; `.` repeats it. + handler.on_event(KeyInput::new('d'), &mut state); + handler.on_event(KeyInput::new('d'), &mut state); + assert_eq!(state.lines.to_string(), "b\nc\nd"); + + handler.on_event(KeyInput::new('.'), &mut state); + assert_eq!(state.lines.to_string(), "c\nd"); + } + #[test] fn test_altgr_normalization_inserts_characters() { use crate::EditorState; diff --git a/src/lib.rs b/src/lib.rs index 9ecee8e..b7fb03c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -256,6 +256,7 @@ //! | `vi` + `", ', (, [ or {` | Select between delimiter `", ', (, [ or {` | //! | `di` + `", ', (, [ or {` | Delete between delimiter `", ', (, [ or {` | //! | `ci` + `", ', (, [ or {` | Change between delimiter `", ', (, [ or {` | +//! | `.` | Repeat the last change | //! | `u` | Undo the last change | //! | `r` | Redo the last undone action | //! | `y` | Copy the selected text in visual mode | diff --git a/src/state.rs b/src/state.rs index 03b850b..cc25863 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,7 +10,7 @@ use self::highlight::Highlight; use self::search::SearchState; use self::view::ViewState; use self::{mode::EditorMode, selection::Selection, undo::Stack}; -use crate::actions::Execute; +use crate::actions::{Action, Execute}; use crate::clipboard::{Clipboard, ClipboardTrait}; use crate::helper::max_col; use crate::{Index2, Lines}; @@ -49,6 +49,15 @@ pub struct EditorState { /// Clipboard for yank and paste operations. pub(crate) clip: Clipboard, + /// The last buffer-changing command thath can be replayed by dot-repeat. + pub(crate) last_change: Option, + + /// Text typed during the last change's insert (e.g. `ciw`). + pub(crate) last_insert: Option, + + /// Text typed in the current insert session. + pub(crate) insert_recording: Option, + /// Flag indicating a system editor was requested. #[cfg(feature = "system-editor")] pub(crate) system_edit_requested: bool, @@ -84,6 +93,9 @@ impl EditorState { undo: Stack::new(), redo: Stack::new(), clip: Clipboard::default(), + last_change: None, + last_insert: None, + insert_recording: None, #[cfg(feature = "system-editor")] system_edit_requested: false, } @@ -103,6 +115,28 @@ impl EditorState { action.execute(self); } + /// Executes an action, recording it for the dot-repeat command. + pub(crate) fn execute_recorded(&mut self, mut action: Action) { + let mode_before = self.mode; + action.execute(self); + + // Inside an insert session, keep capturing until it ends. + if mode_before == EditorMode::Insert { + if self.mode != EditorMode::Insert { + self.last_insert = self.insert_recording.take(); + } + return; + } + + // Remember repeatable changes started from normal mode. + if mode_before == EditorMode::Normal && action.is_repeatable() { + self.last_change = Some(action); + self.last_insert = None; + // If the change opened insert mode, start capturing typed text. + self.insert_recording = (self.mode == EditorMode::Insert).then(String::new); + } + } + /// Set a custom clipboard. pub fn set_clipboard(&mut self, clipboard: impl ClipboardTrait + 'static) { self.clip = Clipboard::new(clipboard);