summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 16:20:24 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit3992e9def8692710590deacbc20bd925e0f8d64a (patch)
treeab47f821d993903fedef10425eda547e26ae82b8 /src
parent06fe2290db7d0eb654b9a908358942cfde298e29 (diff)
downloadpardes-3992e9def8692710590deacbc20bd925e0f8d64a.tar.gz
pardes-3992e9def8692710590deacbc20bd925e0f8d64a.zip
Q records keys into a register and q types them again; . repeats the last insert
Macro.zig keeps keys to be typed again. Q records into @ (or "<reg>) in helix's key notation until the next Q; q replays a register's keys count times through the modal handling, where Esc does not hop panes and Enter, Tab and Space neither look, execute nor open the leader. . replays the normal command that entered the last insert session once and what was typed in it count times (helix repeat_last_insert). a at the end of the text now adds the newline helix's append_mode does, which a terminal's text does not. helix-golf's invert_dictionary passes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/Macro.zig176
-rw-r--r--src/config.zig3
-rw-r--r--src/edit.zig15
-rw-r--r--src/modal.zig12
-rw-r--r--src/normal.zig42
-rw-r--r--src/pardes.zig50
6 files changed, 290 insertions, 8 deletions
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 {