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 --- src/mouse.zig | 7 +++---- src/ninep/events.zig | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) (limited to 'src') 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