diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 05:04:26 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 5bf8d6dd077517270377e5d8551108ecf252374f (patch) | |
| tree | c7783fa9fc133980d3d6fff0129e8de5a1b43601 /test/hxdiff.zig | |
| parent | eefac04995ffad847a4098f16d2e82ccab16438b (diff) | |
| download | pardes-5bf8d6dd077517270377e5d8551108ecf252374f.tar.gz pardes-5bf8d6dd077517270377e5d8551108ecf252374f.zip | |
multiple cursors, regex selection, and Ctrl-c comments
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and
sels[] holds helix's OTHER ranges. That split is why nothing moved at one
cursor: with nsel == 0 not one line of the existing motion, operator, render or
mouse code takes a different branch, which is what protects 800 differential
cases and 67 goldens.
paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's
Selection::new (min width 1, sorted, overlaps merged, primary follows its range
through a merge). An ordinary key runs the single-selection handler once per
range, visited last-first so an edit never disturbs a range still waiting, and
each finished pass is remembered as a distance from the END of the text, which
an earlier edit cannot move — helix's change mapping without a change map.
pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from
the primary and stops the replay, which also closes the use-after-free window
if it frees the pane.
s and S reuse the / prompt wholesale rather than growing a second one: the
pattern is typed into the tag tail, and every keystroke re-runs the match from
the selection the prompt opened on, so the preview is live and Esc is just the
empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size
struct with no allocator — with 64 ops and 8 char classes per pattern, no
case-insensitive flag (helix's smart case is done by folding a scratch copy),
no captures, no multi-line anchors. The last two are the two waivers.
Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides
comment-vs-uncomment ONCE for the whole selection; replaying it would take that
decision n times. Comment tokens are a table in config.zig keyed on the same
extension syntax.zig picks grammars by.
Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left
the cursor one cell before where the append began. helix's restore_cursor can
never walk past the origin; ours backed up unconditionally. hxdiff was green
before AND after — the old one-selection contract could not see it.
hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the
harness contract now reports every range and its primary, omitted when there is
one, so 359 of the 360 old goldens are byte-identical. The one that moved is
o-count: helix's 2o really does leave two cursors and could not say so before.
Diffstat (limited to 'test/hxdiff.zig')
| -rw-r--r-- | test/hxdiff.zig | 99 |
1 files changed, 96 insertions, 3 deletions
diff --git a/test/hxdiff.zig b/test/hxdiff.zig index b04cc0b5..f6ab5c6f 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -8,6 +8,16 @@ // result: {"name":"w-basic","text":"alpha beta\n","mode":"normal", // "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} // +// MULTIPLE CURSORS extend that line with two more fields, and only when there +// is more than one selection: +// +// ...,"sels":[{"cursor":…,"anchor":…},…],"primary":1 +// +// `sels` is EVERY range in document order, each end measured exactly like the +// `cursor`/`anchor` pair above, and `primary` is the index of the one those +// two fields describe. Omitted entirely at one selection — which is why every +// golden written before multiple cursors existed is still byte-for-byte valid. +// // zig build hxdiff (diff the checked-in corpus) // zig build hxdiff -- <cases.jsonl> (results to stdout, no diff) // zig build hxparity (file-vs-pty parity, both corpora) @@ -53,7 +63,21 @@ const pardes = @import("pardes"); // drives feeds every case's text through ghostty-vt, which narrates each // sequence it does not implement. Cut the libraries back to errors; the // per-case report goes through std.debug, so a mismatch stays loud. -pub const std_options: std.Options = .{ .log_level = .err }; +// The regex engine is dropped outright rather than cut back: an `s`/`S` case +// types its pattern one key at a time, so mvzr is HANDED every incomplete +// prefix of it and logs each one at error level. src/main.zig drops the same +// scope for the same reason. +pub const std_options: std.Options = .{ .log_level = .err, .logFn = logFn }; + +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime format: []const u8, + args: anytype, +) void { + if (scope == .mvzr) return; + std.log.defaultLog(level, scope, format, args); +} const gpa = std.heap.page_allocator; @@ -152,7 +176,9 @@ pub fn main(init: std.process.Init) !void { } const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); if (goldens_path == null) { - const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{}); + // null sels/primary are DROPPED, not written: that is what + // keeps a one-selection line identical to the old contract + const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); out.appendSlice(arena, jl) catch fatal("oom", .{}); out.append(arena, '\n') catch fatal("oom", .{}); continue; @@ -185,14 +211,20 @@ const Case = struct { }; const Pos = struct { row: i32, col: i32 }; +const Sel = struct { cursor: Pos, anchor: Pos }; -// field order is the contract's emit order (Stringify writes declaration order) +// field order is the contract's emit order (Stringify writes declaration +// order). The two multi-cursor fields are OPTIONAL and emitted with +// .emit_null_optional_fields = false, so a single selection prints the +// original five-field line and nothing else. const Result = struct { name: []const u8, text: []const u8, mode: []const u8, cursor: Pos, anchor: Pos, + sels: ?[]const Sel = null, + primary: ?usize = null, }; const Waiver = struct { @@ -216,6 +248,35 @@ fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }); } + // the whole selection: absent on both sides = one range, and the two + // fields above already said everything about it + const ws = want.sels orelse &.{}; + const gs = got.sels orelse &.{}; + var sel_diff = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); + if (!sel_diff) for (ws, gs) |a, b| { + if (a.cursor.row != b.cursor.row or a.cursor.col != b.cursor.col or + a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; + }; + if (sel_diff) { + appendf(arena, &buf, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }); + } + return buf.items; +} + +/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to +/// one pair when they are the same cell), the primary starred +fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { + if (sels.len == 0) return "<one>"; + var buf: std.ArrayList(u8) = .empty; + appendf(arena, &buf, "{d} of [", .{sels.len}); + for (sels, 0..) |s, i| { + if (i > 0) appendf(arena, &buf, " ", .{}); + if (i == (primary orelse 0)) appendf(arena, &buf, "*", .{}); + appendf(arena, &buf, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }); + if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) + appendf(arena, &buf, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }); + } + appendf(arena, &buf, "]", .{}); return buf.items; } @@ -296,6 +357,11 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result } else if (std.mem.eql(u8, name, "gt")) { key.cp = '>'; if (!key.ctrl and !key.alt) key.text = ">"; + } else if (std.mem.eql(u8, name, "minus")) { + // helix spells Alt-'-' this way: a bare `-` after a modifier + // is a parse error in its own macro notation + key.cp = '-'; + if (!key.ctrl and !key.alt) key.text = "-"; } else if (std.mem.eql(u8, name, "up")) { key.cp = pardes.Key.up; } else if (std.mem.eql(u8, name, "down")) { @@ -351,12 +417,39 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // mode "select" ONLY while v-extend is on (helix keeps x/%/mi selections // in normal mode); pane.mode itself never holds a select state const mode = if (pane.mode == .normal and pane.select) "select" else @tagName(pane.mode); + // multiple cursors: the primary (cur_row/cur_col + the anchor above) and + // pane.sels, slotted together in document order. pane.sels is already + // ordered, so the primary is the only one that needs placing. + var sels: ?[]const Sel = null; + var primary: ?usize = null; + if (pane.nsel > 0) { + var out: std.ArrayList(Sel) = .empty; + const prim: Sel = .{ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor }; + const pr_row = @min(prim.cursor.row, prim.anchor.row); + const pr_col = if (prim.cursor.row == prim.anchor.row) @min(prim.cursor.col, prim.anchor.col) else if (prim.cursor.row < prim.anchor.row) prim.cursor.col else prim.anchor.col; + for (pane.sels[0..pane.nsel]) |s| { + const s_row = @min(s.row, s.arow); + const s_col = if (s.row == s.arow) @min(s.col, s.acol) else if (s.row < s.arow) s.col else s.acol; + if (primary == null and (pr_row < s_row or (pr_row == s_row and pr_col <= s_col))) { + primary = out.items.len; + try out.append(arena, prim); + } + try out.append(arena, .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } }); + } + if (primary == null) { + primary = out.items.len; + try out.append(arena, prim); + } + sels = out.items; + } return .{ .name = case.name, .text = try arena.dupe(u8, text), .mode = mode, .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor, + .sels = sels, + .primary = primary, }; } |
