From 84fca2d6e5f498f2d4384712d49d727a25441d68 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:09:12 -0300 Subject: A click that takes no text tells the event reader nothing, and click records are tested for their offsets Dogfooding saw a body right-click reported as ML0 0 2 3 qty. On file panes the offsets were right in every case tried here (a scratch made over 9P and clicked while another pane is active, after a hover, scrolled, soft-wrapped, tab-indented, inside parentheses); the test now holds them. The 0 0 form is a terminal's body, which has no offsets to give (its body is a history snapshot); since the event file now takes a record back whole, such a record can still be written back and done, and the docs say so. What the test did find: a click on whitespace sent an empty record, where acme's look3 and execute return without a word on an empty expansion (look.c:37, exec.c:141). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 3 ++- docs/fs.md | 5 +++- src/mouse.zig | 7 +++-- src/ninep/events.zig | 56 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index c9efec0b..6346e7b4 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -211,7 +211,8 @@ not the read-until-EOF helper. `pty/data` captures output while held open; it is not a history replay. Both files are shared, consuming queues, not per-client broadcasts, so a slow reader loses older data. Holding `event` open intercepts that pane's Look and Exec clicks -- and lines written to that pane's -own `look`/`exec`, as `F` records at `0 0` with the text -- so it is not a +own `look`/`exec`, as `F` records at `0 0` with the text, and clicks in a +terminal's body, also at `0 0` -- so it is not a passive logger. To have a record done, write it back: the short form ` \n` acts on that range's text, and the whole record as read acts on its text when the range is empty (the only way for a record diff --git a/docs/fs.md b/docs/fs.md index ccf06dd1..65b987d8 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -251,7 +251,10 @@ at `0 0` carrying the line (the root's `look` and `exec` still act at once); writing a record back performs the action. acme takes back only ` `, the text of that range; pardes takes the record whole as it was read too, and for an empty range acts on its text, which is how such -a line is done. `ctl` reads acme's window status line — serial, tag length, body +a line is done. A click in a file's body carries the offsets of the text it +took; one in a terminal's body cannot, since that body is a history +snapshot, and is also at `0 0` with its text. A click that takes no text +sends nothing, as in acme. `ctl` reads acme's window status line — serial, tag length, body length, a reserved zero, the dirty flag, the width in cells, the font and the tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`): whether the pane has the keyboard. It takes the pane's builtins (below), diff --git a/src/mouse.zig b/src/mouse.zig index beb2e624..0ec20df2 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -715,10 +715,9 @@ fn dispatchPointerBuiltin( const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); - const operand = text orelse { - if (gesture) |g| _ = reportGesture(p, id, cmd, "", g.on_tag, g.operand, arg != null); - return; - }; + // A click that takes no text does nothing, and tells an event reader + // nothing, as acme's look3 and execute return on an empty expansion. + const operand = text orelse return; if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return; exec.runBuiltin(p, cmd, id, "", exec.withArg(p, operand, arg)); } diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 3f36c65f..9ebb4bc1 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -921,3 +921,59 @@ test "opens of the log share the open records, and a closed one frees its record for (handles) |h| _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h }); for (p.fs.opens) |o| try testing.expect(o.node == 0); } + +test "a click in the body reports the offsets of the word it takes, so the record can be written back" { + const config = @import("../config.zig"); + const gpa = testing.allocator; + const text = "alpha qty beta\n qty\n\tqty\ndef f(qty): return qty.x\n" ++ "w" ** 100 ++ " qty\n"; + const p = try withFile(gpa, text); + defer p.deinit(); + // A scratch made over 9P, clicked while another pane has the keyboard. + const scratch = try th.newPane(p); + _ = wr(p, Node.of(scratch, .body), text); + for ([_]u32{ serialOf(p), scratch }) |serial| { + const id = p.paneBySerial(serial).?; + p.active = p.paneBySerial(if (serial == scratch) serialOf(p) else scratch).?; + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x0 = rect.x + config.GUTTER + config.PREFIX_W; + const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows; + const tab: u16 = config.tab_width; + const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 }; + // Where the wrapped line's `qty` landed, as drawn. + const surface = try p.render(frame.allocator()); + var wrap_col: u16 = 0; + while (wrap_col < rect.w and !std.mem.eql(u8, surface.at(x0 + wrap_col, y0 + 5).grapheme(), "q")) wrap_col += 1; + for ([_]Click{ + .{ .x = x0 + 7, .y = y0, .look = "ML6 9 0 3 qty\n", .exec = "MX6 9 0 3 qty\n" }, + .{ .x = x0 + 5, .y = y0 + 1, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n" }, + .{ .x = x0 + tab + 1, .y = y0 + 2, .look = "ML24 27 0 3 qty\n", .exec = "MX24 27 0 3 qty\n" }, + .{ .x = x0 + 7, .y = y0 + 3, .look = "ML34 37 0 3 qty\n", .exec = "MX34 37 0 3 qty\n" }, + // Scrolled a line, the first row shows the second. + .{ .x = x0 + 5, .y = y0, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n", .scroll = 1 }, + // Wrapped: the word on the continuation row. + .{ .x = x0 + wrap_col, .y = y0 + 5, .look = "ML154 157 0 3 qty\n", .exec = "MX154 157 0 3 qty\n" }, + }) |c| for ([_]pardes.Mouse.Button{ config.look_button, config.exec_button }, [_][]const u8{ c.look, c.exec }) |button, want| { + // The pointer rests on the word first, as a hand's does, and + // the hover preview comes up under it. + p.panes[id].?.scrollBy(c.scroll); + _ = try p.render(frame.allocator()); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = c.x, .row = c.y } }); + for (0..200) |_| p.update(.tick); + p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = c.x, .row = c.y } }); + p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = c.x, .row = c.y } }); + try testing.expectEqualStrings(want, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); + p.panes[id].?.scrollBy(-c.scroll); + }; + // A click on nothing expands to nothing, and acme then says + // nothing (look.c:37, exec.c:141). + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x0 + 60, .row = y0 + 1 } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x0 + 60, .row = y0 + 1 } }); + try testing.expectEqual(Status.again, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).reply.status); + _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); + } +} -- cgit v1.3