From 27c4269a3d74a664116377efa22657e3f2461452 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 21:14:56 +0200 Subject: [PATCH] Add normal dot repeat --- KEYMAP.md | 1 + REQUIREMENTS.md | 1 + src/tui.zig | 178 ++++++++++++++++++++++++++++++++++++++++-- tools/terminal_e2e.py | 1 + 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/KEYMAP.md b/KEYMAP.md index f20254a..3b1ee6b 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -188,6 +188,7 @@ such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`. | `Ctrl-u` `Ctrl-d` | half-page up/down when the SSH client exposes Ctrl; `g u` / `g d` are mobile fallback aliases | | `H` `L` | mobile-reachable line start/end aliases | | `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `Space g` `l` opens line targets | +| `.` | repeat the last repeatable local edit (insert/change/delete/put/open-line subset) | | `u` | undo | | `Space u` `r` | redo | | `Space w` | write/save current buffer | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 772af41..bda94dd 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -24,6 +24,7 @@ Rows are redgate TSV requirements: `ringidsummary [tag]`. 1 011 Vertical line motions SHALL preserve a preferred cursor cell across ragged lines, clamped to each target line until horizontal/editing motion resets the preference. [mobile] 1 012 Line selections SHALL be able to include the line's newline boundary so linewise edits can preserve Vim-like newline semantics. [mobile] 1 013 Document start/end and half-page motions SHALL be reachable from Normal mode with Ctrl-capable SSH keys and documented mobile fallback aliases. [mobile] +1 014 Normal-mode dot repeat SHALL replay the last deterministic local edit for the supported insert/change/delete/put subset without recording panel, search, or external side effects. [mobile] ## ui diff --git a/src/tui.zig b/src/tui.zig index 9f349b3..a83cba1 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -53,6 +53,17 @@ const EditSnapshot = struct { cursor_byte: usize, }; +const RepeatInsert = struct { + before_protocol: ?[]u8 = null, + text: []u8, +}; + +const RepeatEdit = union(enum) { + insert: RepeatInsert, + protocol: []u8, + paste, +}; + const ObjectRange = struct { start: usize, end: usize, @@ -170,6 +181,11 @@ pub const Client = struct { undo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty, yank_bytes: ?[]u8 = null, + last_repeat: ?RepeatEdit = null, + insert_repeat_bytes: std.ArrayList(u8) = .empty, + pending_insert_protocol: ?[]u8 = null, + recording_insert_repeat: bool = false, + replaying_repeat: bool = false, pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client { return initWithIo(allocator, viewport, null); @@ -193,6 +209,9 @@ pub const Client = struct { if (self.saved_bytes) |bytes| self.allocator.free(bytes); if (self.owned_message) |bytes| self.allocator.free(bytes); if (self.yank_bytes) |bytes| self.allocator.free(bytes); + self.freeRepeatEdit(); + if (self.pending_insert_protocol) |line| self.allocator.free(line); + self.insert_repeat_bytes.deinit(self.allocator); self.search_prompt.deinit(self.allocator); self.project_search_prompt.deinit(self.allocator); self.allocator.free(self.search_query); @@ -463,6 +482,7 @@ pub const Client = struct { } pub fn enterInsertMode(self: *Client) void { + self.beginInsertRepeat(); self.mode = .insert; self.message = "insert"; } @@ -1026,6 +1046,7 @@ pub const Client = struct { switch (event) { .text => |text| { if (std.mem.eql(u8, text, "i")) { + self.beginInsertRepeat(); self.mode = .insert; self.message = "insert"; self.pending_count = 0; @@ -1033,6 +1054,7 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "a")) { try self.applyProtocol("command move_right"); + try self.beginInsertRepeatAfter("command move_right"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; @@ -1040,6 +1062,7 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "A")) { try self.applyProtocol("command move_line_end"); + try self.beginInsertRepeatAfter("command move_line_end"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; @@ -1047,6 +1070,7 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "o")) { try self.applyMutatingProtocol("command open_line_below"); + try self.beginInsertRepeatAfter("command open_line_below"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; @@ -1054,6 +1078,7 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "O")) { try self.applyMutatingProtocol("command open_line_above"); + try self.beginInsertRepeatAfter("command open_line_above"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; @@ -1074,7 +1099,8 @@ pub const Client = struct { if (std.mem.eql(u8, text, "/")) return self.openSearchPrompt(); if (std.mem.eql(u8, text, "n")) return self.nextSearchMatch(); if (std.mem.eql(u8, text, "N")) return self.previousSearchMatch(); - if (std.mem.eql(u8, text, "p")) return self.pasteRegister(); + if (std.mem.eql(u8, text, "p")) return self.pasteRegister(true); + if (std.mem.eql(u8, text, ".")) return self.repeatLastEdit(); if (std.mem.eql(u8, text, "u")) return self.undoEdit(); if (std.mem.eql(u8, text, "U")) return self.redoEdit(); if (std.mem.eql(u8, text, "H") or std.mem.eql(u8, text, "0") or std.mem.eql(u8, text, "^")) return self.applyProtocol("command move_line_start"); @@ -1122,6 +1148,7 @@ pub const Client = struct { .enter => try self.insertNewlineWithIndent(), .tab => try self.insertText(" "), .escape => { + try self.finishInsertRepeat(); self.mode = .normal; self.message = "normal"; }, @@ -1905,11 +1932,11 @@ pub const Client = struct { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "d")) { try self.yankCurrentLine(true); - try self.applyMutatingProtocol("command delete_line"); + try self.applyRepeatableMutatingProtocol("command delete_line"); return; } - if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward"); - if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward"); + if (std.mem.eql(u8, text, "h")) return self.applyRepeatableMutatingProtocol("command delete_backward"); + if (std.mem.eql(u8, text, "l")) return self.applyRepeatableMutatingProtocol("command delete_forward"); if (text.len == 1) { if (self.objectRangeForKey(text[0])) |range| return self.deleteRange(range) else |_| {} } @@ -1921,18 +1948,21 @@ pub const Client = struct { if (std.mem.eql(u8, text, "c")) { try self.yankCurrentLine(false); try self.applyMutatingProtocol("command change_line"); + try self.beginInsertRepeatAfter("command change_line"); self.mode = .insert; self.message = "insert"; return; } if (std.mem.eql(u8, text, "h")) { try self.applyMutatingProtocol("command delete_backward"); + try self.beginInsertRepeatAfter("command delete_backward"); self.mode = .insert; self.message = "insert"; return; } if (std.mem.eql(u8, text, "l")) { try self.applyMutatingProtocol("command delete_forward"); + try self.beginInsertRepeatAfter("command delete_forward"); self.mode = .insert; self.message = "insert"; return; @@ -2013,7 +2043,7 @@ pub const Client = struct { self.yank_bytes = copy; } - fn pasteRegister(self: *Client) !void { + fn pasteRegister(self: *Client, record_repeat: bool) !void { const bytes = self.yank_bytes orelse { self.message = "nothing yanked"; return; @@ -2025,6 +2055,7 @@ pub const Client = struct { return err; }; self.noteDocumentChanged(); + if (record_repeat and !self.replaying_repeat) try self.setRepeatPaste(); } fn yankCurrentLine(self: *Client, include_newline: bool) !void { @@ -2068,6 +2099,79 @@ pub const Client = struct { return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte }; } + fn beginInsertRepeat(self: *Client) void { + self.beginInsertRepeatAfter(null) catch unreachable; + } + + fn beginInsertRepeatAfter(self: *Client, before_protocol: ?[]const u8) !void { + self.insert_repeat_bytes.clearRetainingCapacity(); + self.recording_insert_repeat = !self.replaying_repeat; + if (self.pending_insert_protocol) |old| self.allocator.free(old); + self.pending_insert_protocol = if (before_protocol) |line| try self.allocator.dupe(u8, line) else null; + } + + fn finishInsertRepeat(self: *Client) !void { + defer { + self.insert_repeat_bytes.clearRetainingCapacity(); + self.recording_insert_repeat = false; + if (self.pending_insert_protocol) |old| self.allocator.free(old); + self.pending_insert_protocol = null; + } + if (!self.recording_insert_repeat or self.replaying_repeat) return; + if (self.pending_insert_protocol == null and self.insert_repeat_bytes.items.len == 0) return; + try self.setRepeatInsert(self.pending_insert_protocol, self.insert_repeat_bytes.items); + } + + fn setRepeatInsert(self: *Client, before_protocol: ?[]const u8, text: []const u8) !void { + self.freeRepeatEdit(); + const protocol_copy = if (before_protocol) |line| try self.allocator.dupe(u8, line) else null; + errdefer if (protocol_copy) |line| self.allocator.free(line); + self.last_repeat = .{ .insert = .{ + .before_protocol = protocol_copy, + .text = try self.allocator.dupe(u8, text), + } }; + } + + fn setRepeatProtocol(self: *Client, line: []const u8) !void { + self.freeRepeatEdit(); + self.last_repeat = .{ .protocol = try self.allocator.dupe(u8, line) }; + } + + fn setRepeatPaste(self: *Client) !void { + self.freeRepeatEdit(); + self.last_repeat = .paste; + } + + fn freeRepeatEdit(self: *Client) void { + if (self.last_repeat) |repeat| switch (repeat) { + .insert => |insert| { + if (insert.before_protocol) |line| self.allocator.free(line); + self.allocator.free(insert.text); + }, + .protocol => |line| self.allocator.free(line), + .paste => {}, + }; + self.last_repeat = null; + } + + fn repeatLastEdit(self: *Client) !void { + const repeat = self.last_repeat orelse { + self.message = "nothing to repeat"; + return; + }; + self.replaying_repeat = true; + defer self.replaying_repeat = false; + switch (repeat) { + .insert => |insert| { + if (insert.before_protocol) |line| try self.applyMutatingProtocol(line); + if (insert.text.len != 0) try self.insertText(insert.text); + }, + .protocol => |line| try self.applyMutatingProtocol(line), + .paste => try self.pasteRegister(false), + } + self.message = "repeat"; + } + fn objectRangeForKey(self: *Client, key: u8) !ObjectRange { return switch (key) { 'w' => try self.wordRange(), @@ -2171,6 +2275,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + if (self.recording_insert_repeat and !self.replaying_repeat) try self.insert_repeat_bytes.appendSlice(self.allocator, text); self.noteDocumentChanged(); self.message = null; } @@ -2213,6 +2318,11 @@ pub const Client = struct { self.noteDocumentChanged(); } + fn applyRepeatableMutatingProtocol(self: *Client, line: []const u8) !void { + try self.applyMutatingProtocol(line); + if (!self.replaying_repeat) try self.setRepeatProtocol(line); + } + const PageDirection = enum { up, down }; fn halfPage(self: *Client, direction: PageDirection) !void { @@ -5185,3 +5295,61 @@ test "regular: vertical viewport follows cursor line" { try std.testing.expect(std.mem.indexOf(u8, frame, "12│") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") == null); } + +test "regular: dot repeats insert session" { + var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open alpha beta"); + try client.handleInput("i"); + try client.handleInput("X"); + try client.handleInput("\x1b"); + try client.handleInput("w"); + try client.handleInput("."); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("Xalpha Xbeta", snap.bytes); +} + +test "regular: dot repeats delete command" { + var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open abcd"); + try client.handleInput("d"); + try client.handleInput("l"); + try client.handleInput("."); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("cd", snap.bytes); +} + +test "regular: dot repeats simple change command and typed text" { + var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open abcd"); + try client.handleInput("c"); + try client.handleInput("l"); + try client.handleInput("X"); + try client.handleInput("\x1b"); + try client.handleInput("."); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("XXcd", snap.bytes); +} + +test "regular: dot repeats put and remains undoable" { + var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open abc"); + try client.handleInput("y"); + try client.handleInput("w"); + try client.handleInput("G"); + try client.handleInput("p"); + try client.handleInput("."); + var snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("abcabcabc", snap.bytes); + + try client.handleInput("u"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("abcabc", snap.bytes); +} diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 43024a1..9cdf270 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -515,6 +515,7 @@ SCENARIOS = [ Scenario("normal-A-append", 56, 14, "existing", "attached-keyboard", "append-eol", "truecolor", "file", setup_existing, (b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), "alpha!\nbeta", ("1│", "☻", "alpha")), Scenario("insert-enter-indent", 56, 14, "indented", "attached-keyboard", "newline-indent", "truecolor", "file", setup_indented, (b"A", b"\r", b"x", b"\x1b", b" ", b"w", b" ", b"q"), " item\n x", ("1│", "2│", "☻")), Scenario("insert-tab-expands-spaces", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem x", ("1│", "☻")), + Scenario("dot-repeat-local-edit", 56, 12, "short", "attached-keyboard", "repeat", "truecolor", "file", setup_short, (b"i", b"X", b"\x1b", b"w", b".", b" ", b"w", b" ", b"q"), "XabcdefX", ("mode:normal", "☻")), Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nl6\nl7\nXl8", ("8│", "☻")), Scenario("counted-move-inserts-at-count", 56, 12, "short", "attached-keyboard", "counts", "truecolor", "file", setup_short, (b"3", b"l", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcXdef", ("1│", "☻")), Scenario("percent-match-jump", 56, 12, "brackets", "attached-keyboard", "match-jump", "truecolor", "file", setup_brackets, (b"%", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "(abX)", ("1│", "☻")),