a73x

a45ee4ed

fix: a click at the wall is bounded by the grid, as a zoomed one is

a73x   2026-08-22 13:13

Commit message
fix: a click at the wall is bounded by the grid, as a zoomed one is

A stripe attaches at 0x0 and inherits whatever grid the daemon holds, so
it is routinely taller and wider than the session inside it —
`renderStripe` clears the rows below the grid rather than painting them.
The wall's `hitTest` bounded a click by the STRIPE, so a press on one of
those cleared rows, or right of the last column, resolved to a coordinate
the session's grid does not have. `extractSelection` answers `.invalid`,
which reaches the user as a drag that copied nothing and said nothing
about why.

The zoomed driver already had both answers and the rationale for them:
refuse rows past the grid, clamp columns because the right edge is where a
hand overshoots and a round trip spent to be told so is a copy the user
does not get. Two drivers answering one question differently is the thing
this feature's shape exists to prevent, so the wall now answers it the same
way. `Window` carries the grid to do it, published inside the paint under
the lock it already holds — the hit-test has to describe the rows that are
ON the terminal, not the ones the next frame will put there.

A tile that has not painted has a zero grid and so resolves nothing, which
is correct rather than merely safe: there is nothing on the terminal yet to
have clicked.

Also drops the drag on the pump's `.resync` arm. `redial` clears both the
zoomed Core's highlight and the wall's, because a resync renames the
absolute row space; this path re-attaches from scratch without going
through it, and on an epoch change `sel_range` still matches the held drag,
so an in-flight reply could copy the new session's text. Not
`core.reattached()`: that also flushes the overlay and forces a repaint,
which this arm has never done and is a separate question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiryvVYESzBqqtf5cH1uoA

src/wallview.zig
Old New
@@ -341,6 +341,14 @@ const Window = struct {
341 /// The grid row the window's first content line came from 341 /// The grid row the window's first content line came from
342 /// (`paint.stripeWinStart`). 342 /// (`paint.stripeWinStart`).
343 win_start: u16 = 0, 343 win_start: u16 = 0,
344 /// The replica's grid at that paint, which is what bounds a click.
345 /// A stripe attaches at 0x0 and inherits whatever grid the daemon
346 /// holds, so a stripe is routinely TALLER and WIDER than the session
347 /// inside it — `renderStripe` clears the rows below the grid rather
348 /// than painting them, and DECAWM-off clips the columns past it.
349 /// Zero by default, so a tile that has not painted yet resolves no
350 /// click at all: there is nothing on the terminal to have clicked.
351 grid: proto.Size = .{ .cols = 0, .rows = 0 },
344 }; 352 };
345 353
346 const Tile = struct { 354 const Tile = struct {
@@ -545,10 +553,22 @@ fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col:
545 for (tiles, present) |*t, p| { 553 for (tiles, present) |*t, p| {
546 if (!p) continue; 554 if (!p) continue;
547 const off = stripeContentRow(t.stripe, row) orelse continue; 555 const off = stripeContentRow(t.stripe, row) orelse continue;
556 // Past the grid's last row this stripe shows a CLEARED row, not a
557 // session line — `renderStripe`'s second loop wrote it. Refused
558 // rather than answered, and refused here rather than left for the
559 // daemon: an out-of-range row makes `extractSelection` reply
560 // `.invalid`, which reaches the user as a drag that copied nothing
561 // and said nothing about why. Same rule and same reasoning as
562 // `interact.hitTest`, which the zoomed driver uses; the two answer
563 // one question and must not answer it differently.
564 const grid_row: u32 = @as(u32, t.win.win_start) + off;
565 if (grid_row >= t.win.grid.rows) return null;
548 return .{ 566 return .{
549 .tile = t.idx, 567 .tile = t.idx,
550 .row = t.win.history_rows + t.win.win_start + off, 568 .row = t.win.history_rows + grid_row,
551 .col = col, 569 // Clamped, not refused: the right edge is where a hand
570 // overshoots, and a copy is better than a lesson.
571 .col = @min(col, t.win.grid.cols -| 1),
552 }; 572 };
553 } 573 }
554 return null; 574 return null;
@@ -898,6 +918,7 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u
898 t.win = .{ 918 t.win = .{
899 .history_rows = history_rows, 919 .history_rows = history_rows,
900 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), 920 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows),
921 .grid = .{ .cols = @intCast(eng.term.cols), .rows = @intCast(eng.term.rows) },
901 }; 922 };
902 // The highlight is drawn HERE and never by the keyboard. A stripe is 923 // The highlight is drawn HERE and never by the keyboard. A stripe is
903 // repainted from its replica on every frame, so an inversion the 924 // repainted from its replica on every frame, so an inversion the
@@ -1768,6 +1789,18 @@ fn pumpTile(t: *Tile) void {
1768 // the delta that cannot fix us. 1789 // the delta that cannot fix us.
1769 .resync => { 1790 .resync => {
1770 core.rep.state_since_attach = false; 1791 core.rep.state_since_attach = false;
1792 // A resync renames the absolute row space, so a
1793 // held highlight now names rows nobody selected —
1794 // and on an EPOCH change `sel_range` still matches
1795 // it, so an in-flight reply would copy the new
1796 // session's text. `redial` drops both sides for
1797 // this reason; this path re-attaches without going
1798 // through it. Not `core.reattached()`: that also
1799 // flushes the overlay and forces a repaint, which
1800 // this arm has never done and is a separate
1801 // question from the drag.
1802 core.drag.clear();
1803 dropDragOver(t);
1771 sendAttach(t, &transport, 0, 0) catch return; 1804 sendAttach(t, &transport, 0, 0) catch return;
1772 }, 1805 },
1773 .not_mine => switch (frame.type) { 1806 .not_mine => switch (frame.type) {
@@ -3815,6 +3848,40 @@ fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
3815 return buf[0..n]; 3848 return buf[0..n];
3816 } 3849 }
3817 3850
3851 test "a wall click is bounded by the session's grid, not by the stripe" {
3852 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3853 defer std.posix.close(p[0]);
3854 defer std.posix.close(p[1]);
3855 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3856 var tiles = [_]Tile{
3857 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3858 };
3859 var present = [_]bool{true};
3860 // A stripe eleven content rows tall over a grid of four: routine, not
3861 // contrived — a stripe attaches at 0x0 and inherits whatever grid the
3862 // daemon holds, so the stripe is nearly always the bigger of the two.
3863 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 10, .rows = 4 } };
3864
3865 // Inside the grid: the oldest retained row plus the offset.
3866 const in = hitTest(&tiles, &present, &shared, 1, 3).?;
3867 try std.testing.expectEqual(@as(u32, 100), in.row);
3868 try std.testing.expectEqual(@as(u16, 3), in.col);
3869
3870 // Right of the grid, clamped to its last column rather than refused —
3871 // the daemon would answer `.invalid` and the user would get silence.
3872 const wide = hitTest(&tiles, &present, &shared, 1, 40).?;
3873 try std.testing.expectEqual(@as(u16, 9), wide.col);
3874
3875 // Below the grid: `renderStripe` CLEARED that row, so it shows no
3876 // session line and there is nothing there to select.
3877 try std.testing.expect(hitTest(&tiles, &present, &shared, 5, 3) == null);
3878
3879 // A tile that has not painted yet has a zero grid, and so resolves
3880 // nothing at all.
3881 tiles[0].win = .{};
3882 try std.testing.expect(hitTest(&tiles, &present, &shared, 1, 3) == null);
3883 }
3884
3818 test "a plain click selects the stripe under it, and a drag highlights instead" { 3885 test "a plain click selects the stripe under it, and a drag highlights instead" {
3819 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 3886 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3820 defer std.posix.close(p[0]); 3887 defer std.posix.close(p[0]);
@@ -3826,8 +3893,8 @@ test "a plain click selects the stripe under it, and a drag highlights instead"
3826 }; 3893 };
3827 var present = [_]bool{ true, true }; 3894 var present = [_]bool{ true, true };
3828 // Both stripes have painted once, so a terminal row resolves to a line. 3895 // Both stripes have painted once, so a terminal row resolves to a line.
3829 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 3896 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3830 tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; 3897 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
3831 3898
3832 const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }; 3899 const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 };
3833 const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 }; 3900 const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 };
@@ -3899,8 +3966,8 @@ test "a drag at the wall stays in the stripe it started in, and rings for a repa
3899 std.posix.close(t.wake_r); 3966 std.posix.close(t.wake_r);
3900 std.posix.close(t.wake_w); 3967 std.posix.close(t.wake_w);
3901 }; 3968 };
3902 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 3969 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3903 tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; 3970 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
3904 3971
3905 const gen0 = shared.repaint_gen.load(.acquire); 3972 const gen0 = shared.repaint_gen.load(.acquire);
3906 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); 3973 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
@@ -3959,8 +4026,8 @@ test "a wall drag posts its selection for the pump, because the keyboard owns no
3959 std.posix.close(t.wake_r); 4026 std.posix.close(t.wake_r);
3960 std.posix.close(t.wake_w); 4027 std.posix.close(t.wake_w);
3961 }; 4028 };
3962 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 4029 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3963 tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; 4030 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
3964 4031
3965 // A drag inside stripe b, released. 4032 // A drag inside stripe b, released.
3966 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }); 4033 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
@@ -4044,8 +4111,8 @@ test "a wall drag copies on release, and a stale answer copies nothing" {
4044 std.posix.close(t.wake_r); 4111 std.posix.close(t.wake_r);
4045 std.posix.close(t.wake_w); 4112 std.posix.close(t.wake_w);
4046 }; 4113 };
4047 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 4114 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4048 tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; 4115 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
4049 4116
4050 // The pump's half. A stripe's Core is DEMOTED — it holds no claim and 4117 // The pump's half. A stripe's Core is DEMOTED — it holds no claim and
4051 // paints nothing — which is exactly why the copy cannot go out through 4118 // paints nothing — which is exactly why the copy cannot go out through
@@ -4112,7 +4179,7 @@ test "a zoomed tile copies from its own drag, not the wall's" {
4112 tiles[0].wake_w = pipe[1]; 4179 tiles[0].wake_w = pipe[1];
4113 defer std.posix.close(pipe[0]); 4180 defer std.posix.close(pipe[0]);
4114 defer std.posix.close(pipe[1]); 4181 defer std.posix.close(pipe[1]);
4115 tiles[0].win = .{ .history_rows = 0, .win_start = 0 }; 4182 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4116 shared.zoom.store(0, .release); 4183 shared.zoom.store(0, .release);
4117 4184
4118 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size); 4185 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
@@ -4169,7 +4236,7 @@ test "a copy too big for OSC 52 is said out loud rather than dropped" {
4169 tiles[0].wake_w = pipe[1]; 4236 tiles[0].wake_w = pipe[1];
4170 defer std.posix.close(pipe[0]); 4237 defer std.posix.close(pipe[0]);
4171 defer std.posix.close(pipe[1]); 4238 defer std.posix.close(pipe[1]);
4172 tiles[0].win = .{ .history_rows = 0, .win_start = 0 }; 4239 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4173 4240
4174 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size); 4241 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
4175 defer core.deinit(); 4242 defer core.deinit();
@@ -4218,8 +4285,8 @@ test "a stripe paints its own highlight and nobody else's" {
4218 4285
4219 // Both stripes have painted, so a click resolves. Tile a's window sits 4286 // Both stripes have painted, so a click resolves. Tile a's window sits
4220 // at grid row 0 with 100 rows of history behind it. 4287 // at grid row 0 with 100 rows of history behind it.
4221 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 4288 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4222 tiles[1].win = .{ .history_rows = 100, .win_start = 0 }; 4289 tiles[1].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4223 // Drag across tile a's second content line, columns 2..5. 4290 // Drag across tile a's second content line, columns 2..5.
4224 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 2, .row = 2, .at = 0 }); 4291 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 2, .row = 2, .at = 0 });
4225 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 5, .row = 2, .at = 0 }); 4292 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 5, .row = 2, .at = 0 });
@@ -4251,7 +4318,7 @@ test "a reconnect drops the highlight over its own tile, and only its own" {
4251 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, 4318 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
4252 }; 4319 };
4253 var present = [_]bool{ true, true }; 4320 var present = [_]bool{ true, true };
4254 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 4321 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4255 4322
4256 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); 4323 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
4257 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 }); 4324 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
@@ -4280,7 +4347,7 @@ test "a relayout drops the highlight, because the stripes move under it" {
4280 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, 4347 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
4281 }; 4348 };
4282 var present = [_]bool{ true, true }; 4349 var present = [_]bool{ true, true };
4283 tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; 4350 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4284 4351
4285 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); 4352 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
4286 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 }); 4353 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
@@ -4320,7 +4387,7 @@ test "a zoom move drops what the wall's input filters are holding" {
4320 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 4387 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
4321 _ = input.mouse.feed("\x1b[<0;3", &mouse_out); 4388 _ = input.mouse.feed("\x1b[<0;3", &mouse_out);
4322 try std.testing.expect(input.mouse.len > 0); 4389 try std.testing.expect(input.mouse.len > 0);
4323 tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; 4390 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
4324 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }); 4391 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4325 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 }); 4392 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
4326 try std.testing.expect(shared.drag.range() != null); 4393 try std.testing.expect(shared.drag.range() != null);
@@ -4365,8 +4432,8 @@ test "hitTest names the absolute row under a click, and nothing at all off a str
4365 }; 4432 };
4366 // What each pump last painted. The daemon holds 100 rows above tile 4433 // What each pump last painted. The daemon holds 100 rows above tile
4367 // b's viewport and the stripe was showing that grid from row 5. 4434 // b's viewport and the stripe was showing that grid from row 5.
4368 tiles[0].win = .{ .history_rows = 0, .win_start = 0 }; 4435 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
4369 tiles[1].win = .{ .history_rows = 100, .win_start = 5 }; 4436 tiles[1].win = .{ .history_rows = 100, .win_start = 5, .grid = .{ .cols = 80, .rows = 24 } };
4370 var present = [_]bool{ true, true }; 4437 var present = [_]bool{ true, true };
4371 4438
4372 // Tile b's first content row: terminal row 13, grid row 5, and 100 4439 // Tile b's first content row: terminal row 13, grid row 5, and 100
test/e2e.sh
Old New
@@ -6230,8 +6230,9 @@ grep -q "wallwheel" "$OUT.zwcapg" && {
6230 # the session's modes on the user's terminal for the rest of the wall's 6230 # the session's modes on the user's terminal for the rest of the wall's
6231 # life. What the demote then puts back is the WALL's own capture set, which 6231 # life. What the demote then puts back is the WALL's own capture set, which
6232 # is the wall reading mouse reports on its own account and not a leak; the 6232 # is the wall reading mouse reports on its own account and not a leak; the
6233 # exit's teardown is what has to leave the terminal clean. Counted with `grep -o`: these are escape sequences inside a 6233 # exit's teardown is what has to leave the terminal clean. Counted with
6234 # paint, with no newlines to make a line count mean anything. 6234 # `grep -o`: these are escape sequences inside a paint, with no newlines to
6235 # make a line count mean anything.
6235 _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l) 6236 _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l)
6236 [ "$_zw_off" -ge 2 ] || { 6237 [ "$_zw_off" -ge 2 ] || {
6237 echo "e2e FAIL: zoom wheel: mouse reporting was turned off $_zw_off time(s);" 6238 echo "e2e FAIL: zoom wheel: mouse reporting was turned off $_zw_off time(s);"