diff options
| -rw-r--r-- | docs/helix-keys.md | 15 | ||||
| -rw-r--r-- | docs/selections.md | 5 | ||||
| -rw-r--r-- | src/Macro.zig | 176 | ||||
| -rw-r--r-- | src/config.zig | 3 | ||||
| -rw-r--r-- | src/edit.zig | 15 | ||||
| -rw-r--r-- | src/modal.zig | 12 | ||||
| -rw-r--r-- | src/normal.zig | 42 | ||||
| -rw-r--r-- | src/pardes.zig | 50 | ||||
| -rw-r--r-- | test/hxcases/cases.jsonl | 16 | ||||
| -rw-r--r-- | test/hxcases/goldens.jsonl | 16 | ||||
| -rw-r--r-- | test/hxcases/parity-waivers.jsonl | 1 |
11 files changed, 335 insertions, 16 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index cd33ccbe..3d02893b 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -80,8 +80,8 @@ at the bottom. `gj` the textual one. With the toggle off a line is one visual row and the two pairs are the same motion. - **Undo is snapshot-per-edit-op / per-insert-session**, not a - transaction log. Anything requiring replayable edits (`.`, - macros) needs new machinery. + transaction log. `.` and macros replay KEYS (`Macro.zig`), not + edits, so they need none. ## A. Implemented today @@ -147,6 +147,9 @@ language-backend queries, and the shell pipe. | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | | `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus | | `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus | +| `Q` / `"<reg>Q` | record the keys that follow into `@` (or the named register) until the next `Q`; kept as helix writes a macro, in key notation (`xt,S=<ret>_<A-(>`), so `"@p` pastes it and a yanked text can be replayed | helix `record_macro` | helix-verified | +| `q` / `<n>q` / `"<reg>q` | type a register's keys again, n times. Replayed keys go to the modal handling only: Esc does not hop panes, Enter and Tab neither look nor execute, and Space does not open the leader | helix `replay_macro`; `Macro.zig`, `Pardes.replayKeys` | helix-verified | +| `.` / `<n>.` | repeat the last insert session: the normal command that entered it once, the keys typed n times, then leave insert mode the way it was left | helix `repeat_last_insert`; the same key log as macros | helix-verified | ## B. To implement @@ -352,7 +355,6 @@ text. | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | -| `Q` / `q` | record / replay macro | macros — needs replayable input log | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | @@ -363,7 +365,6 @@ text. | `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | | `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | | `zm` (view) | align middle horizontally | marginal even with hscroll | -| `.` | repeat last insert | **deferred by decision**: needs recording the insert session's keystrokes and a replay path — a new subsystem; undo is whole-buffer snapshots with no edit log to piggyback on. Revisit after phases 2–5 if the log exists by then for another reason | | Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes' `n`/`N` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) @@ -403,13 +404,15 @@ Files (all in `test/hxcases/`): registers of one value per range, `sel-regex-caret` and `sel-regex-dot-newline` when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. -- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 15 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different scrolloff band — no text differs), and five case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing - newline, an all-whitespace last row the dump trims). + newline, an all-whitespace last row the dump trims). One more, + `dot-append-count`: `a` at a file's end grows it by a newline (helix + append_mode), which a terminal's text, not the editor's to grow, does not. - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered step of each walkthrough, its keys being the command up to and including diff --git a/docs/selections.md b/docs/selections.md index 35fa806f..202ec3d9 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -98,8 +98,9 @@ the replay. answer from the shell that comes later, so `"+p` pastes once, at the primary, and insert `Ctrl-r +` types what pardes last put on the clipboard rather than asking the desktop. -- `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no - macros. +- `Alt-.` repeats the last `f`/`t`/`F`/`T`. `.` and macros (`Q`/`q`) are + keys typed again (`Macro.zig`), so whatever those keys do to the ranges + happens again: there is no edit log underneath. ## Where it differs from helix diff --git a/src/Macro.zig b/src/Macro.zig new file mode 100644 index 00000000..7475992e --- /dev/null +++ b/src/Macro.zig @@ -0,0 +1,176 @@ +//! Keys kept to be typed again. `Q` records the keys that follow into a +//! register until the next `Q`, and `q` types them again (helix macros); +//! `.` types again the last insert session together with the normal command +//! that began it (helix repeat_last_insert). A macro is kept in its register +//! as helix writes one, in key notation (`xt,S=<ret>_<A-(>`), so it can be +//! pasted, edited and yanked back like any text. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Key = pardes.Key; + +const Macro = @This(); + +/// The register `Q` is recording into, 0 when it is not. +recording: u21 = 0, +recorded: std.ArrayList(Stored) = .empty, +/// The keys of the normal command under way (a count, `"<reg>`, a prefix +/// and the key that completes it), dropped when it completes without +/// entering insert mode. +command: std.ArrayList(Stored) = .empty, +/// `.`'s: the command that began the last insert session and every key +/// typed in it, the one that left it included. +last_insert: std.ArrayList(Stored) = .empty, +/// Where in `last_insert` the typed keys begin, after the command's. +typed_from: usize = 0, +/// Keys are going into `last_insert`. +inserting: bool = false, +/// How deep macros are being typed again; `.`'s replay is `repeating`. +replaying: u8 = 0, +repeating: bool = false, + +/// A key as it can be typed again: what it was, not the text it came with. +pub const Stored = struct { + cp: u21, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + + pub fn of(key: Key) Stored { + return .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }; + } + + /// The key again, its text made from its codepoint into `buf`. + pub fn typed(s: Stored, buf: *[4]u8) Key { + var k: Key = .{ .cp = s.cp, .ctrl = s.ctrl, .alt = s.alt, .shift = s.shift }; + if (!s.ctrl and !s.alt and s.cp >= 0x20 and s.cp != Key.backspace and s.cp < 0xF0000) { + const n = std.unicode.utf8Encode(s.cp, buf) catch return k; + k.text = buf[0..n]; + } + return k; + } +}; + +pub fn deinit(m: *Macro, gpa: std.mem.Allocator) void { + m.recorded.deinit(gpa); + m.command.deinit(gpa); + m.last_insert.deinit(gpa); + m.* = .{}; +} + +/// A typed key, each of its characters one key: what a host delivers as +/// one key event with a run of text is that run typed. +pub fn append(list: *std.ArrayList(Stored), gpa: std.mem.Allocator, key: Key) void { + const view = std.unicode.Utf8View.init(key.text) catch null; + if (key.text.len == 0 or key.ctrl or key.alt or view == null) { + list.append(gpa, .of(key)) catch {}; + return; + } + var it = view.?.iterator(); + while (it.nextCodepoint()) |cp| list.append(gpa, .{ .cp = cp, .shift = key.shift }) catch {}; +} + +const names = [_]struct { cp: u21, name: []const u8 }{ + .{ .cp = Key.escape, .name = "esc" }, + .{ .cp = Key.enter, .name = "ret" }, + .{ .cp = Key.tab, .name = "tab" }, + .{ .cp = Key.backspace, .name = "backspace" }, + .{ .cp = Key.delete, .name = "del" }, + .{ .cp = ' ', .name = "space" }, + .{ .cp = '<', .name = "lt" }, + .{ .cp = '>', .name = "gt" }, + .{ .cp = '-', .name = "minus" }, + .{ .cp = Key.up, .name = "up" }, + .{ .cp = Key.down, .name = "down" }, + .{ .cp = Key.left, .name = "left" }, + .{ .cp = Key.right, .name = "right" }, + .{ .cp = Key.home, .name = "home" }, + .{ .cp = Key.end, .name = "end" }, + .{ .cp = Key.page_up, .name = "pageup" }, + .{ .cp = Key.page_down, .name = "pagedown" }, +}; + +/// Keys in helix's notation: a plain character as itself, anything else +/// in angle brackets with its modifiers (`<C-r>`, `<A-(>`, `<ret>`). +pub fn encode(gpa: std.mem.Allocator, keys: []const Stored) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + for (keys) |k| { + var name: ?[]const u8 = null; + for (names) |n| if (n.cp == k.cp) { + name = n.name; + }; + // `-` is only spelled out after a modifier, where helix needs it + if (k.cp == '-' and !k.ctrl and !k.alt) name = null; + var buf: [4]u8 = undefined; + const bare = if (name) |n| n else buf[0..(std.unicode.utf8Encode(k.cp, &buf) catch continue)]; + if (!k.ctrl and !k.alt and name == null) { + try out.appendSlice(gpa, bare); + continue; + } + try out.append(gpa, '<'); + if (k.ctrl) try out.appendSlice(gpa, "C-"); + if (k.alt) try out.appendSlice(gpa, "A-"); + try out.appendSlice(gpa, bare); + try out.append(gpa, '>'); + } + return out.toOwnedSlice(gpa); +} + +/// The keys helix notation names; what it cannot read ends the list. +pub fn decode(gpa: std.mem.Allocator, text: []const u8) ![]Stored { + var out: std.ArrayList(Stored) = .empty; + errdefer out.deinit(gpa); + var i: usize = 0; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch break; + if (i + len > text.len) break; + if (text[i] != '<') { + try out.append(gpa, .{ .cp = std.unicode.utf8Decode(text[i..][0..len]) catch break }); + i += len; + continue; + } + const close = std.mem.indexOfScalarPos(u8, text, i + 1, '>') orelse break; + var name = text[i + 1 .. close]; + i = close + 1; + var k: Stored = .{ .cp = 0 }; + while (name.len > 2 and name[1] == '-') : (name = name[2..]) switch (name[0]) { + 'C' => k.ctrl = true, + 'A' => k.alt = true, + 'S' => k.shift = true, + else => break, + }; + for (names) |n| if (std.mem.eql(u8, n.name, name)) { + k.cp = n.cp; + }; + if (k.cp == 0) k.cp = std.unicode.utf8Decode(name) catch break; + try out.append(gpa, k); + } + return out.toOwnedSlice(gpa); +} + +test "a macro's keys go into helix notation and come back the same" { + const gpa = std.testing.allocator; + const keys = [_]Stored{ + .{ .cp = 'x' }, + .{ .cp = 't' }, + .{ .cp = ',' }, + .{ .cp = 'S' }, + .{ .cp = '=' }, + .{ .cp = Key.enter }, + .{ .cp = '_' }, + .{ .cp = '(', .alt = true }, + .{ .cp = 'r', .ctrl = true }, + .{ .cp = '<' }, + .{ .cp = ' ' }, + .{ .cp = '-' }, + .{ .cp = '-', .alt = true }, + .{ .cp = 'λ' }, + .{ .cp = Key.escape }, + }; + const text = try encode(gpa, &keys); + defer gpa.free(text); + try std.testing.expectEqualStrings("xt,S=<ret>_<A-(><C-r><lt><space>-<A-minus>λ<esc>", text); + const back = try decode(gpa, text); + defer gpa.free(back); + try std.testing.expectEqualSlices(Stored, &keys, back); +} diff --git a/src/config.zig b/src/config.zig index 91665b14..bc053176 100644 --- a/src/config.zig +++ b/src/config.zig @@ -267,6 +267,9 @@ pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; +pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }}; +pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }}; +pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; diff --git a/src/edit.zig b/src/edit.zig index 3e12f169..a373741c 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -546,8 +546,19 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; t.restore_cursor = true; - const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.positionAt(text, gap); + const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }); + const gap = modal.nextGrapheme(text, hi); + // helix append_mode: a range that reaches the end of the text + // gets a newline after it to append before (a terminal's text + // is not the editor's to grow) + const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false; + var now = text; + if (hi < text.len and gap >= text.len and !terminal) { + const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return; + setEditText(p, t, grown); + now = grown; // what setEditText keeps; `text` may be gone + } + const gc = modal.positionAt(now, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); // helix append_mode: the range reaches one past its end, which diff --git a/src/modal.zig b/src/modal.zig index bce941d5..34c4dac7 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -129,6 +129,9 @@ pub const Normal = struct { insert_output, append_output, keep_pipe, + macro_record, + macro_replay, + repeat_insert, search, search_next, search_prev, @@ -320,6 +323,9 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + macro_record, + macro_replay: u32, + repeat_insert: u32, pub fn scope(value: Action) Scope { return switch (value) { @@ -331,6 +337,9 @@ pub const Normal = struct { .pipe_selection, .search, .search_step, + .macro_record, + .macro_replay, + .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { .join_lines, @@ -602,6 +611,9 @@ pub const Normal = struct { if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); + if (key.has(.macro_record)) return resultAction(.macro_record); + if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count }); + if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); diff --git a/src/normal.zig b/src/normal.zig index 53e416fd..542c9fb3 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -18,6 +18,7 @@ const Key = pardes.Key; const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Pardes = pardes.Pardes; +const Macro = pardes.Macro; fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; @@ -324,6 +325,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void switch (semantic) { .escape => { t.select = false; + // typed again by a macro, Esc is only helix's + if (p.macro.replaying > 0) return; return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { @@ -571,7 +574,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .format => .format, }, ""), .adjust_number => |delta| return edit.normalAdjustNumber(p, t, delta), - .leader => { + .leader => if (p.macro.replaying == 0) { p.leader_on = true; p.leader_n = 0; }, @@ -588,6 +591,43 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .macro_record => { + const m = &p.macro; + if (m.recording == 0) { + m.recording = if (p.register != 0) p.register else '@'; + m.recorded.clearRetainingCapacity(); + return; + } + // the keys of this command (a count, `"<reg>`, the `Q`) are not + // the macro's + m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1)); + const keys = Macro.encode(p.gpa, m.recorded.items) catch return; + defer p.gpa.free(keys); + p.registers.put(p.gpa, m.recording, keys, 0, 1, true); + m.recording = 0; + }, + .macro_replay => |count| { + const name: u21 = if (p.register != 0) p.register else '@'; + const notation = p.registers.value(name, 0) orelse return; + const keys = Macro.decode(p.scratch.allocator(), notation) catch return; + p.register = 0; + for (0..count) |_| p.replayKeys(keys); + }, + .repeat_insert => |count| { + const m = &p.macro; + if (m.last_insert.items.len == 0 or m.repeating) return; + if (m.inserting) return; // the session is still open + const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return; + m.repeating = true; + defer m.repeating = false; + // helix repeat_last_insert: the command once, what was typed + // count times, and out of insert mode the way it was left + const typed = @min(m.typed_from, keys.len); + p.replayKeys(keys[0..typed]); + if (keys.len == typed) return; + for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]); + p.replayKeys(keys[keys.len - 1 ..]); + }, } } diff --git a/src/pardes.zig b/src/pardes.zig index c9d93979..87041f39 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -18,6 +18,7 @@ pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.zig"); pub const Registers = @import("Registers.zig"); +pub const Macro = @import("Macro.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -455,6 +456,7 @@ test { _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -4096,6 +4098,7 @@ pub const Pardes = struct { in_len: usize = 0, registers: Registers = .{}, + macro: Macro = .{}, /// an edited text nothing kept (a tag's shown text): see edit.retireEditText retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, @@ -4269,6 +4272,7 @@ pub const Pardes = struct { for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); p.registers.deinit(gpa); + p.macro.deinit(gpa); if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); @@ -5125,7 +5129,21 @@ pub const Pardes = struct { } else panes.Terminal.enterTty(p, id); } + /// Type `keys` again (a macro, `.`): through the modal handling as + /// typed, except that no key leaves the text for pardes's own acts -- + /// Esc does not hop panes, and Enter and Tab neither look nor execute. + pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void { + if (p.macro.replaying >= 8) return; // a macro typing itself + p.macro.replaying += 1; + defer p.macro.replaying -= 1; + for (keys) |stored| { + var buf: [4]u8 = undefined; + p.handleKey(stored.typed(&buf)); + } + } + fn handleKey(p: *Pardes, key: Key) void { + if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key); // A question waiting on a pane takes the next key, whatever mode the // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { @@ -5266,7 +5284,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); return; } - if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. const header_word = t.what == .column or t.what == .workspace; @@ -5320,14 +5338,36 @@ pub const Pardes = struct { defer if (t.refused) { t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; }; + const was = t.mode; switch (t.mode) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape) and !t.reg_wait) return edit.exitInsert(p, t); - edit.handleInsert(p, t, key); - }, + .insert => if (hit(key, config.escape) and !t.reg_wait) + edit.exitInsert(p, t) + else + edit.handleInsert(p, t, key), .tty => panes.Terminal.forwardKey(p, p.active, key), } + // `.`'s log: the keys of the normal command that entered insert + // mode, then everything typed until it is left + if (p.macro.repeating) return; + const m = &p.macro; + switch (was) { + .normal => { + Macro.append(&m.command, p.gpa, key); + if (t.mode == .insert) { + m.last_insert.clearRetainingCapacity(); + m.last_insert.appendSlice(p.gpa, m.command.items) catch {}; + m.typed_from = m.last_insert.items.len; + m.inserting = true; + m.command.clearRetainingCapacity(); + } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity(); + }, + .insert => if (m.inserting) { + Macro.append(&m.last_insert, p.gpa, key); + if (t.mode != .insert) m.inserting = false; + }, + .tty => {}, + } } fn leaderKey(p: *Pardes, key: Key) void { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index ba75c611..8d6b0e6e 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -470,6 +470,22 @@ {"name": "reg-count-after-quote", "pane": "file", "text": "ab\n", "keys": "\"ay\"a3p"} {"name": "reg-ctrl-r-named", "pane": "file", "text": "ab\n", "keys": "\"ayli<C-r>a<esc>"} {"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "i<C-r><esc>x"} +{"name": "macro-record-replay", "pane": "file", "text": "a\nb\nc\n", "keys": "QA!<esc>jQq"} +{"name": "macro-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "QA!<esc>jQ2q"} +{"name": "macro-named", "pane": "file", "text": "a\nb\nc\n", "keys": "\"aQA!<esc>jQ\"aq"} +{"name": "macro-register-text", "pane": "file", "text": "a\nb\n", "keys": "QA!<esc>jQ\"@P"} +{"name": "macro-with-prompt", "pane": "file", "text": "a,b\nc,d\n", "keys": "QxS,<ret>d<esc>jQq"} +{"name": "macro-esc-in-normal", "pane": "file", "text": "ab\ncd\n", "keys": "Qvl<esc>jQq"} +{"name": "macro-empty", "pane": "file", "text": "ab\n", "keys": "QQq"} +{"name": "dot-insert", "pane": "file", "text": "ab\ncd\n", "keys": "iX<esc>j.."} +{"name": "dot-append-count", "pane": "file", "text": "ab\ncd\n", "keys": "aYZ<esc>j3."} +{"name": "dot-change", "pane": "file", "text": "ab cd\nef gh\n", "keys": "wcX<esc>jb."} +{"name": "dot-open-below", "pane": "file", "text": "a\nb\n", "keys": "oX<esc>j."} +{"name": "dot-counted-command", "pane": "file", "text": "abcd\n", "keys": "2lixy<esc>."} +{"name": "dot-multi", "pane": "file", "text": "ab\nab\n", "keys": "CiX<esc>."} +{"name": "dot-nothing", "pane": "file", "text": "ab\n", "keys": ".x"} +{"name": "append-at-end-adds-newline", "pane": "file", "text": "ab\n", "keys": "jaX<esc>"} +{"name": "append-at-end-no-newline", "pane": "file", "text": "ab", "keys": "laX<esc>"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C<esc>"} {"name": "msel-select-mode", "pane": "file", "text": "abcd\nabcd\n", "keys": "Cvl"} {"name": "msel-open-count", "pane": "file", "text": "ab\n", "keys": "2O"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 188b7ce4..d62cb633 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -470,6 +470,22 @@ {"name":"reg-count-after-quote","text":"aaaab\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}} {"name":"reg-ctrl-r-named","text":"aab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} {"name":"reg-ctrl-r-esc","text":"xab\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"macro-record-replay","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-count","text":"a!\nb!\nc!\nd\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"macro-named","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-register-text","text":"a!\nbA!<esc>j\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":1}} +{"name":"macro-with-prompt","text":",c,d\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"macro-esc-in-normal","text":"ab\ncd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"macro-empty","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"dot-insert","text":"Xab\ncXXd\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}} +{"name":"dot-change","text":"Xcd\nX gh\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"dot-open-below","text":"a\nX\nb\nX\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"dot-counted-command","text":"abxyxycd\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} +{"name":"dot-multi","text":"XXab\nXXab\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1} +{"name":"dot-nothing","text":"ab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"append-at-end-adds-newline","text":"ab\nX","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"append-at-end-no-newline","text":"abX\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}} {"name":"msel-esc-keeps","text":"aa\nbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-select-mode","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-open-count","text":"\n\nab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index ad24eaa9..2393681e 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -12,3 +12,4 @@ {"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} {"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"dot-append-count","reason":"A terminal's text is not the editor's to grow: a at its end adds no newline to append before, where a file gets one (helix append_mode).","reference":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}},"expected":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}} |
