a73x

f04db7c5

feat: a drag over a zoomed tile paints what it crossed

a73x   2026-08-22 13:13

Commit message
feat: a drag over a zoomed tile paints what it crossed

The zoomed half of the selection, and the easy one: `renderClipped` is
top-anchored, so a terminal row IS a grid row and the only conversion
left is the history the daemon holds under it. The Core already
receives the mouse bytes unsplit, already knows whether the session's
application wants them, and owns the replica, the paint and the thread
they run on — so it paints its own highlight and needs no shared state
at all. The wall's loop cannot do that, and does not.

The highlight rides through the painters as a CALLBACK, asked per row.
`select` and `paint` are siblings at layer 1 and may not import each
other, so the join is `interact.Highlight` — which converts as well as
adapts, absolute rows to grid rows against the history count of the
frame being painted. A paint with no selection installs no callback and
emits the bytes it emitted before this existed.

A delta cannot paint a highlighted row. It paints what the daemon sent,
as the daemon sent it, so a row under the inversion would come back
plain and the selection would develop holes wherever the session was
still writing; a held selection takes the full-repaint arm the resync
and the contradiction already share.

When the session's application asks for the mouse, the application gets
the drag — tmux's rule on `mouse_any_flag`, and the one mux's mode
mirror already follows. The selection is CLEARED there rather than
merely ignored: one left standing would sit inverted on a screen that
has stopped being ours to draw on.

A resync clears it too, and for a different reason: absolute rows are
counted from the oldest row the daemon still retains, so a resync
renames the whole space and a kept highlight is an inversion over rows
nobody selected.

Scrolled back, a drag selects nothing. `renderScrollback` blits VT
bytes the daemon composed with no engine behind them, so there is no
row on that screen this Core can name — refused, rather than answered
against the live view the user is not looking at.

Rows past the grid name no line; columns past it clamp. The right edge
is where a hand overshoots, and an out-of-range column is what makes
the daemon answer `.invalid` — a round trip spent to be told so is a
copy the user does not get.

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

build.zig
Old New
@@ -232,7 +232,7 @@ const mod_table = [_]ModSpec{
232 // must be drivable by anything holding a transport — the CLI client 232 // must be drivable by anything holding a transport — the CLI client
233 // today, a wall tile from the wall's phase-3 convergence on — and it 233 // today, a wall tile from the wall's phase-3 convergence on — and it
234 // names no transport type for exactly that reason (see its header). 234 // names no transport type for exactly that reason (see its header).
235 .{ .name = "interact", .path = "src/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint" } }, 235 .{ .name = "interact", .path = "src/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint", "select" } },
236 // quic and quic_server both: the listener it owns, and the vocabulary 236 // quic and quic_server both: the listener it owns, and the vocabulary
237 // it names directly (the key it loads, the idle default it falls back 237 // it names directly (the key it loads, the idle default it falls back
238 // to). xdg is for endpoint_req's lazy bind — the default key path, 238 // to). xdg is for endpoint_req's lazy bind — the default key path,
src/interact.zig
Old New
@@ -41,6 +41,41 @@ const client_core = @import("client_core");
41 // Named `paint_mod` because paintOverlay holds a local ArrayList called 41 // Named `paint_mod` because paintOverlay holds a local ArrayList called
42 // `paint`, which a container-level `paint` would collide with. 42 // `paint`, which a container-level `paint` would collide with.
43 const paint_mod = @import("paint"); 43 const paint_mod = @import("paint");
44 const select = @import("select");
45
46 /// What `select` answers, in the shape `paint` asks for.
47 ///
48 /// The two are siblings at layer 1 and may not import one another, so
49 /// somebody above both has to join them; this is the smaller half of that
50 /// join, and the wall reuses it rather than keeping a second copy
51 /// (`wallview.paintStripe`).
52 ///
53 /// It converts as well as adapts. `select` speaks ABSOLUTE rows —
54 /// counted from the oldest row the daemon retains — and a painter speaks
55 /// grid rows, so the history count of the frame being painted is what
56 /// turns one into the other. Reading it from the same replica the paint
57 /// is walking is the point: a highlight resolved against one frame's
58 /// history and painted onto another's is a highlight on the wrong lines.
59 pub const Highlight = struct {
60 drag: *const select.Drag,
61 /// Which tile the drag belongs to. Zero for a zoomed `Core`, which is
62 /// the only session on its screen; the wall passes the real index.
63 tile: usize,
64 history_rows: u32,
65
66 /// A paint with no selection on it asks nothing per row and emits
67 /// exactly the bytes it emitted before this feature existed.
68 pub fn sink(self: *const Highlight) paint_mod.Highlight {
69 if (self.drag.range() == null) return .{};
70 return .{ .ctx = @constCast(self), .span = &spanOf };
71 }
72
73 fn spanOf(ctx: ?*anyopaque, row: u16, cols: u16) ?paint_mod.Span {
74 const self: *const Highlight = @ptrCast(@alignCast(ctx.?));
75 const s = self.drag.span(self.tile, self.history_rows + row, cols) orelse return null;
76 return .{ .from = s.from, .to = s.to };
77 }
78 };
44 79
45 // Ctrl-\. In a live session it is the command prefix (see PrefixFilter); 80 // Ctrl-\. In a live session it is the command prefix (see PrefixFilter);
46 // while dialling or reconnecting there is no session to command, so a bare 81 // while dialling or reconnecting there is no session to command, so a bare
@@ -1185,6 +1220,11 @@ pub const Core = struct {
1185 /// Mouse reports arrive in the same reads as keystrokes; this splits 1220 /// Mouse reports arrive in the same reads as keystrokes; this splits
1186 /// them back out, across read boundaries. 1221 /// them back out, across read boundaries.
1187 mouse: MouseFilter = .{}, 1222 mouse: MouseFilter = .{},
1223 /// The mux selection over THIS session, while this Core holds the
1224 /// terminal. A demoted wall tile has `claim == .none` and so never
1225 /// sees a report at all: unzoomed, the drag belongs to the wall's own
1226 /// keyboard loop, over its own hit-test.
1227 drag: select.Drag = .{},
1188 /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N 1228 /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N
1189 /// rows above live. Rows rather than pages because the wheel moves by a 1229 /// rows above live. Rows rather than pages because the wheel moves by a
1190 /// few lines and the keys move by a screen — one unit that expresses 1230 /// few lines and the keys move by a screen — one unit that expresses
@@ -1388,6 +1428,16 @@ pub const Core = struct {
1388 return self.rep.eng; 1428 return self.rep.eng;
1389 } 1429 }
1390 1430
1431 /// The selection as this frame's paint must see it.
1432 ///
1433 /// Returned BY VALUE and kept on the caller's stack for the length of
1434 /// the paint: `sink()` hands the painter a pointer to it, and the
1435 /// history count it carries is read from the replica the paint is
1436 /// about to walk.
1437 fn highlight(self: *Core) Highlight {
1438 return .{ .drag = &self.drag, .tile = 0, .history_rows = self.rep.history_rows };
1439 }
1440
1391 /// May this Core paint now, and hold that answer until `endPaint`? 1441 /// May this Core paint now, and hold that answer until `endPaint`?
1392 /// Every write of GRID bytes goes through this pair; see `Sink`. 1442 /// Every write of GRID bytes goes through this pair; see `Sink`.
1393 fn beginPaint(self: *Core) bool { 1443 fn beginPaint(self: *Core) bool {
@@ -1410,7 +1460,8 @@ pub const Core = struct {
1410 pub fn repaint(self: *Core) !void { 1460 pub fn repaint(self: *Core) !void {
1411 if (!self.beginPaint()) return; 1461 if (!self.beginPaint()) return;
1412 defer self.endPaint(); 1462 defer self.endPaint();
1413 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1463 const hl = self.highlight();
1464 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd);
1414 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd); 1465 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd);
1415 } 1466 }
1416 1467
@@ -1419,7 +1470,8 @@ pub const Core = struct {
1419 fn paintFull(self: *Core) !void { 1470 fn paintFull(self: *Core) !void {
1420 if (!self.beginPaint()) return; 1471 if (!self.beginPaint()) return;
1421 defer self.endPaint(); 1472 defer self.endPaint();
1422 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1473 const hl = self.highlight();
1474 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd);
1423 } 1475 }
1424 1476
1425 /// A one-line marker in the corner, painted over by the next full 1477 /// A one-line marker in the corner, painted over by the next full
@@ -1496,7 +1548,13 @@ pub const Core = struct {
1496 // on scroll exit comes from it. 1548 // on scroll exit comes from it.
1497 if (self.scroll_rows == 0 and self.beginPaint()) { 1549 if (self.scroll_rows == 0 and self.beginPaint()) {
1498 defer self.endPaint(); 1550 defer self.endPaint();
1499 if (self.repaint_after_resync or verdict == .contradicted) { 1551 // A held selection takes the full-repaint arm too. A delta
1552 // paints the rows the daemon sent AS THE DAEMON SENT THEM, so
1553 // a row under the highlight would come back un-inverted and
1554 // the selection would develop holes wherever the session was
1555 // still writing. Affordable for the same reason the arm above
1556 // is: a selection is a thing a hand holds for a moment.
1557 if (self.repaint_after_resync or verdict == .contradicted or self.drag.range() != null) {
1500 // First frame back after a reconnect. The daemon sent only 1558 // First frame back after a reconnect. The daemon sent only
1501 // what changed, which is correct — but the screen still 1559 // what changed, which is correct — but the screen still
1502 // carries the banner, so repaint the whole thing from the 1560 // carries the banner, so repaint the whole thing from the
@@ -1513,7 +1571,8 @@ pub const Core = struct {
1513 // the overlay below it are ONE hold of the sink (a zoom that 1571 // the overlay below it are ONE hold of the sink (a zoom that
1514 // moved between them would put the two halves of this frame 1572 // moved between them would put the two halves of this frame
1515 // on two different screens), and the sink is not reentrant. 1573 // on two different screens), and the sink is not reentrant.
1516 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1574 const hl = self.highlight();
1575 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd);
1517 self.repaint_after_resync = false; 1576 self.repaint_after_resync = false;
1518 } else { 1577 } else {
1519 try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd); 1578 try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd);
@@ -1746,13 +1805,31 @@ pub const Core = struct {
1746 // with the escape deleted. 1805 // with the escape deleted.
1747 var keys = typed; 1806 var keys = typed;
1748 var wheel: i32 = 0; 1807 var wheel: i32 = 0;
1808 // What is on screen before this read's reports are read, so the
1809 // repaint below is spent only on a highlight that actually moved.
1810 // A drag reports on every cell the pointer crosses.
1811 const was = self.drag.range();
1749 if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { 1812 if (self.claim == .none or self.semantic.terminal_modes.appMouse()) {
1750 self.mouse.reset(); 1813 self.mouse.reset();
1814 // The application asked for the mouse, so the application gets
1815 // the drag and there is no mux selection here — the same rule
1816 // the mode mirror already follows, and tmux's on
1817 // `mouse_any_flag`. Shift+drag stays the terminal's own escape
1818 // hatch. Cleared rather than merely ignored: a selection made
1819 // before the application asked would otherwise sit inverted on
1820 // a screen that no longer belongs to it.
1821 self.drag.clear();
1751 } else { 1822 } else {
1752 const m = self.mouse.feed(typed, &self.mouse_buf); 1823 const m = self.mouse.feed(typed, &self.mouse_buf);
1753 keys = m.forward; 1824 keys = m.forward;
1754 wheel = m.wheel; 1825 wheel = m.wheel;
1826 self.dragReports(m.events);
1755 } 1827 }
1828 // This Core owns its own screen and its own thread — a tile's pump
1829 // is what runs `forward` — so the highlight is painted here rather
1830 // than left in shared state for somebody else to draw. The wall's
1831 // unzoomed loop cannot do that and does not (`wallview.paintStripe`).
1832 if (!std.meta.eql(was, self.drag.range())) try self.paintFull();
1756 1833
1757 // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's 1834 // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's
1758 // own version). The alt screen has no scrollback — `historyRows` 1835 // own version). The alt screen has no scrollback — `historyRows`
@@ -1847,6 +1924,59 @@ pub const Core = struct {
1847 return .ok; 1924 return .ok;
1848 } 1925 }
1849 1926
1927 /// Feed this read's mouse reports to the drag.
1928 ///
1929 /// Left button only. Middle is the terminal's own paste and right its
1930 /// menu, and stealing either would be a surprise with no answer. The
1931 /// button word arrives from the filter verbatim, motion bit and
1932 /// modifiers included, so it is the low two bits that name the button.
1933 ///
1934 /// A plain click is a defined no-op here, which is the difference
1935 /// between this driver and the wall's: at the wall a click moves the
1936 /// selection between stripes, and a zoomed tile is the only session on
1937 /// its screen, so there is nothing for a click to select.
1938 fn dragReports(self: *Core, events: []const MouseFilter.Event) void {
1939 for (events) |ev| {
1940 if (ev.button & 0b11 != 0) continue;
1941 const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
1942 switch (ev.kind) {
1943 .press => self.drag.press(cell, self.hitTest(ev)),
1944 .motion => self.drag.motion(cell, self.hitTest(ev)),
1945 .release => _ = self.drag.release(),
1946 }
1947 }
1948 }
1949
1950 /// Which line of this session a report landed on, or null for a report
1951 /// that names none.
1952 ///
1953 /// The mapping is trivial and that is the whole reason selection lives
1954 /// in the Core while zoomed: `renderClipped` is top-anchored, so a
1955 /// terminal row IS a grid row, and the only conversion left is the
1956 /// history the daemon is holding under it.
1957 fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit {
1958 // Scrolled back, nothing on this screen is addressable:
1959 // `renderScrollback` blits VT bytes the daemon composed, with no
1960 // engine behind them, so there is no row here to name. Refused
1961 // rather than half-answered — the alternative is a selection whose
1962 // coordinates belong to the live view the user is not looking at.
1963 if (self.scroll_rows > 0) return null;
1964 const grid_rows: u16 = @intCast(self.rep.eng.term.rows);
1965 const cols: u16 = @intCast(self.rep.eng.term.cols);
1966 // Rows past the grid (a terminal taller than the daemon's grid)
1967 // hold no session line: `renderClipped` never painted them.
1968 if (ev.row >= @min(grid_rows, self.size.rows)) return null;
1969 return .{
1970 .tile = 0,
1971 .row = self.rep.history_rows + ev.row,
1972 // Columns clamp instead of refusing, because the right edge is
1973 // where a hand naturally overshoots. An out-of-range column is
1974 // what makes the daemon answer `.invalid`, and a round trip
1975 // spent to be told so is a copy the user does not get.
1976 .col = @min(ev.col, cols -| 1),
1977 };
1978 }
1979
1850 /// Go back to the live view without painting it — what a driver does 1980 /// Go back to the live view without painting it — what a driver does
1851 /// BEFORE a reconnect, where the resync's own repaint is what will 1981 /// BEFORE a reconnect, where the resync's own repaint is what will
1852 /// arrive. 1982 /// arrive.
@@ -1877,6 +2007,10 @@ pub const Core = struct {
1877 /// predicted against a connection that no longer exists — dropping it 2007 /// predicted against a connection that no longer exists — dropping it
1878 /// is not an accusation, so the counters stay where they are. 2008 /// is not an accusation, so the counters stay where they are.
1879 pub fn reattached(self: *Core) void { 2009 pub fn reattached(self: *Core) void {
2010 // The absolute row space is counted from the oldest row the daemon
2011 // retains, and a resync renames it outright — so a highlight kept
2012 // across one is an inversion over rows nobody selected.
2013 self.drag.clear();
1880 self.rep.state_since_attach = false; 2014 self.rep.state_since_attach = false;
1881 self.repaint_after_resync = true; 2015 self.repaint_after_resync = true;
1882 self.overlay.flush(); 2016 self.overlay.flush();
@@ -2929,6 +3063,239 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic
2929 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); 3063 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
2930 } 3064 }
2931 3065
3066 /// A transport that swallows everything. `forward` writes the keystrokes
3067 /// it did not consume, and none of these tests are about those bytes.
3068 const NullTransport = struct {
3069 fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {}
3070 };
3071
3072 /// A zoomed Core on a pipe, with a claim, ready to be dragged over.
3073 fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
3074 var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 });
3075 core.is_tty = true;
3076 // A drag only happens on a terminal this Core took over — which is
3077 // exactly the promoted-tile case, and the reason an unzoomed tile
3078 // never reaches any of this.
3079 _ = core.claimTerminal();
3080 core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
3081 return core;
3082 }
3083
3084 test "interact: a drag at a zoomed tile inverts what it crossed, and a click does not" {
3085 const alloc = std.testing.allocator;
3086 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3087 defer std.posix.close(p[0]);
3088 defer std.posix.close(p[1]);
3089 var core = try dragFixture(alloc, p[1]);
3090 defer core.deinit();
3091 var tr: NullTransport = .{};
3092 var buf: [8192]u8 = undefined;
3093 _ = drainPipe(p[0], &buf); // the claim
3094
3095 // Press on row 1, column 2. Nothing is painted: a press that turns out
3096 // to be a click must not flicker an inversion on its way past.
3097 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3098 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3099
3100 // Drag to column 6 of the same row.
3101 _ = try core.forward(&tr, "\x1b[<32;7;2M");
3102 const painted = drainPipe(p[0], &buf);
3103 // Columns 2..6 of grid row 1, addressed by column and inverted: the
3104 // span opens at column 3 (1-based) and the tail resumes at column 8.
3105 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[3G\x1b[0m\x1b[7m") != null);
3106 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[0m\x1b[8G") != null);
3107 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
3108 // The neighbouring rows are ordinary dumps in the same repaint.
3109 try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") != null);
3110 try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") != null);
3111
3112 // The release does not move the highlight, so it does not repaint one.
3113 _ = try core.forward(&tr, "\x1b[<0;7;2m");
3114 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3115
3116 // A press elsewhere puts the selection away — and the repaint that
3117 // says so carries no inversion at all.
3118 _ = try core.forward(&tr, "\x1b[<0;1;4M");
3119 const cleared = drainPipe(p[0], &buf);
3120 try std.testing.expect(std.mem.indexOf(u8, cleared, "row-three") != null);
3121 try std.testing.expect(std.mem.indexOf(u8, cleared, "\x1b[7m") == null);
3122 // ...and that press is a click, which a zoomed tile answers with
3123 // nothing: there is one session on this screen to select.
3124 _ = try core.forward(&tr, "\x1b[<0;1;4m");
3125 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3126 }
3127
3128 test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" {
3129 const alloc = std.testing.allocator;
3130 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3131 defer std.posix.close(p[0]);
3132 defer std.posix.close(p[1]);
3133 var core = try dragFixture(alloc, p[1]);
3134 defer core.deinit();
3135 var tr: NullTransport = .{};
3136 var buf: [8192]u8 = undefined;
3137 _ = drainPipe(p[0], &buf);
3138
3139 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3140 _ = try core.forward(&tr, "\x1b[<32;7;2M");
3141 try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") != null);
3142
3143 // vim's `set mouse=a` arrives mid-drag. The reports are the
3144 // application's from here, and the selection standing on its screen is
3145 // not mux's to keep.
3146 core.semantic.terminal_modes = .{
3147 .bracketed_paste = false,
3148 .mouse_normal = true,
3149 .mouse_button = true,
3150 .mouse_sgr = true,
3151 };
3152 _ = try core.forward(&tr, "\x1b[<32;9;2M");
3153 const after = drainPipe(p[0], &buf);
3154 try std.testing.expect(std.mem.indexOf(u8, after, "row-one") != null);
3155 try std.testing.expect(std.mem.indexOf(u8, after, "\x1b[7m") == null);
3156 // A further drag under the application selects nothing at all.
3157 _ = try core.forward(&tr, "\x1b[<32;11;2M");
3158 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3159 }
3160
3161 test "interact: a drag while scrolled back selects nothing" {
3162 const alloc = std.testing.allocator;
3163 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3164 defer std.posix.close(p[0]);
3165 defer std.posix.close(p[1]);
3166 var core = try dragFixture(alloc, p[1]);
3167 defer core.deinit();
3168 var tr: NullTransport = .{};
3169 var buf: [8192]u8 = undefined;
3170 _ = drainPipe(p[0], &buf);
3171
3172 // Viewing history: `renderScrollback` blits daemon VT bytes with no
3173 // engine behind them, so nothing on this screen is a row this Core can
3174 // name. Refused outright rather than answered against the live view
3175 // the user is not looking at.
3176 core.scroll_rows = 4;
3177 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3178 _ = try core.forward(&tr, "\x1b[<32;7;2M");
3179 try std.testing.expect(core.drag.range() == null);
3180 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3181 }
3182
3183 test "interact: a reattach drops the highlight, because the rows have been renamed" {
3184 const alloc = std.testing.allocator;
3185 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3186 defer std.posix.close(p[0]);
3187 defer std.posix.close(p[1]);
3188 var core = try dragFixture(alloc, p[1]);
3189 defer core.deinit();
3190 var tr: NullTransport = .{};
3191 var buf: [8192]u8 = undefined;
3192 _ = drainPipe(p[0], &buf);
3193
3194 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3195 _ = try core.forward(&tr, "\x1b[<32;7;3M");
3196 try std.testing.expect(core.drag.range() != null);
3197 _ = drainPipe(p[0], &buf);
3198
3199 // Absolute rows count from the oldest row the daemon still retains,
3200 // and a resync renames that space outright.
3201 core.reattached();
3202 try std.testing.expect(core.drag.range() == null);
3203 try core.repaint();
3204 try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") == null);
3205 }
3206
3207 test "interact: the anchor is an absolute row, and the paint converts it back" {
3208 const alloc = std.testing.allocator;
3209 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3210 defer std.posix.close(p[0]);
3211 defer std.posix.close(p[1]);
3212 var core = try dragFixture(alloc, p[1]);
3213 defer core.deinit();
3214 var tr: NullTransport = .{};
3215 var buf: [8192]u8 = undefined;
3216 _ = drainPipe(p[0], &buf);
3217
3218 // A session with history behind it, which is every session that has
3219 // been used. The number is what makes the two directions of the
3220 // conversion distinguishable at all: with none retained, a grid row
3221 // and an absolute row are the same integer and neither half of the
3222 // arithmetic is under test.
3223 core.rep.history_rows = 500;
3224
3225 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3226 _ = try core.forward(&tr, "\x1b[<32;7;2M");
3227 const r = core.drag.range().?;
3228 // Terminal row 1 is grid row 1 is the 501st retained row — the space
3229 // `protocol.SelectionReq` speaks, and the one a scroll cannot rename.
3230 try std.testing.expectEqual(@as(u32, 501), r.from.row);
3231 try std.testing.expectEqual(@as(u32, 501), r.to.row);
3232 // ...and the paint puts the inversion back on grid row 1, which is the
3233 // other half of the same arithmetic: a painter that took the absolute
3234 // row literally would invert a row 500 below the one pointed at, or
3235 // none at all.
3236 const painted = drainPipe(p[0], &buf);
3237 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[0mro\x1b[3G\x1b[0m\x1b[7m") != null);
3238 }
3239
3240 test "interact: only the left button drags" {
3241 const alloc = std.testing.allocator;
3242 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3243 defer std.posix.close(p[0]);
3244 defer std.posix.close(p[1]);
3245 var core = try dragFixture(alloc, p[1]);
3246 defer core.deinit();
3247 var tr: NullTransport = .{};
3248 var buf: [8192]u8 = undefined;
3249 _ = drainPipe(p[0], &buf);
3250
3251 // Middle button (1) and right (2). One is the terminal's own paste and
3252 // the other its menu; stealing either would be a surprise with no
3253 // answer, and the button word arrives with the motion bit set on a
3254 // drag — 33 is a middle-button drag, not a press of button 33.
3255 _ = try core.forward(&tr, "\x1b[<1;3;2M");
3256 _ = try core.forward(&tr, "\x1b[<33;7;2M");
3257 try std.testing.expect(core.drag.range() == null);
3258 _ = try core.forward(&tr, "\x1b[<2;3;3M");
3259 _ = try core.forward(&tr, "\x1b[<34;7;3M");
3260 try std.testing.expect(core.drag.range() == null);
3261 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3262 }
3263
3264 test "interact: a row past the grid names no line, and a column past it clamps" {
3265 const alloc = std.testing.allocator;
3266 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3267 defer std.posix.close(p[0]);
3268 defer std.posix.close(p[1]);
3269 // A terminal taller and wider than the daemon's grid, which latest-wins
3270 // leaves routinely.
3271 var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 40, .rows = 12 });
3272 core.is_tty = true;
3273 _ = core.claimTerminal();
3274 defer core.deinit();
3275 var tr: NullTransport = .{};
3276 var buf: [8192]u8 = undefined;
3277 _ = drainPipe(p[0], &buf);
3278
3279 // The grid is smaller than the Core's clip size: 20x6 against 40x12.
3280 try core.rep.eng.resize(20, 6);
3281 core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two");
3282
3283 // Row 8 is inside the terminal and past the grid: `renderClipped`
3284 // never painted a session line there.
3285 _ = try core.forward(&tr, "\x1b[<0;3;9M");
3286 try std.testing.expect(core.drag.range() == null);
3287
3288 // Column 34 is past the grid's right edge. It clamps to the last
3289 // column instead of refusing — the right edge is where a hand
3290 // overshoots — and an out-of-range column is what makes the daemon
3291 // answer `.invalid` when the selection is asked for.
3292 _ = try core.forward(&tr, "\x1b[<0;35;2M");
3293 _ = try core.forward(&tr, "\x1b[<32;3;2M");
3294 const r = core.drag.range().?;
3295 try std.testing.expectEqual(@as(u16, 19), r.to.col);
3296 try std.testing.expectEqual(@as(u16, 2), r.from.col);
3297 }
3298
2932 test "interact: only lifecycle and agent-channel frames come back to the driver" { 3299 test "interact: only lifecycle and agent-channel frames come back to the driver" {
2933 const alloc = std.testing.allocator; 3300 const alloc = std.testing.allocator;
2934 // No terminal at either end: every paint and every side channel is 3301 // No terminal at either end: every paint and every side channel is
src/paint.zig
Old New
@@ -18,6 +18,36 @@ const proto = @import("protocol");
18 pub const sync_begin = "\x1b[?2026h\x1b[?25l"; 18 pub const sync_begin = "\x1b[?2026h\x1b[?25l";
19 pub const sync_end = "\x1b[?25h\x1b[?2026l"; 19 pub const sync_end = "\x1b[?25h\x1b[?2026l";
20 20
21 /// Inclusive grid columns of one row, painted inverted.
22 pub const Span = struct { from: u16, to: u16 };
23
24 /// Which columns of a grid row a painter must invert, asked per row.
25 ///
26 /// A callback and not a list of rows, for a layering reason and a
27 /// practical one. The answer comes from `select.zig`, which is layer 1
28 /// like this module — neither may import the other — so the shape of a
29 /// selection cannot be named here; and a painter emits row by row anyway,
30 /// so per-row is the question it already has to ask. `cols` is handed
31 /// DOWN rather than remembered by the caller: the width the highlight is
32 /// clamped to must be the width this paint is about to use, and
33 /// `Engine.dumpVtRowSpan` asserts it.
34 ///
35 /// Null `span` — the default — is a paint with no selection on it, which
36 /// is nearly every paint.
37 pub const Highlight = struct {
38 ctx: ?*anyopaque = null,
39 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
40 };
41
42 /// One grid row, inverted where the highlight says so. The single place
43 /// the two painters agree about what a selection does to a row.
44 fn dumpRow(alloc: std.mem.Allocator, replica: *Engine, y: u16, hl: Highlight) ![]u8 {
45 const ask = hl.span orelse return replica.dumpVtRow(alloc, y);
46 const s = ask(hl.ctx, y, @intCast(replica.term.cols)) orelse
47 return replica.dumpVtRow(alloc, y);
48 return replica.dumpVtRowSpan(alloc, y, s.from, s.to);
49 }
50
21 pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos { 51 pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
22 return .{ 52 return .{
23 .x = @min(cur.x, tty.cols -| 1), 53 .x = @min(cur.x, tty.cols -| 1),
@@ -29,7 +59,13 @@ pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
29 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty 59 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty
30 /// are skipped and long rows clip at the right edge because autowrap is 60 /// are skipped and long rows clip at the right edge because autowrap is
31 /// off (DECAWM, set at attach). 61 /// off (DECAWM, set at attach).
32 pub fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size, out_fd: std.posix.fd_t) !void { 62 pub fn renderClipped(
63 alloc: std.mem.Allocator,
64 replica: *Engine,
65 tty: proto.Size,
66 hl: Highlight,
67 out_fd: std.posix.fd_t,
68 ) !void {
33 var paint: std.ArrayList(u8) = .empty; 69 var paint: std.ArrayList(u8) = .empty;
34 defer paint.deinit(alloc); 70 defer paint.deinit(alloc);
35 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J"); 71 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J");
@@ -40,7 +76,7 @@ pub fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size
40 while (y < limit) : (y += 1) { 76 while (y < limit) : (y += 1) {
41 var cup: [16]u8 = undefined; 77 var cup: [16]u8 = undefined;
42 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1})); 78 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1}));
43 const row = try replica.dumpVtRow(alloc, y); 79 const row = try dumpRow(alloc, replica, y, hl);
44 defer alloc.free(row); 80 defer alloc.free(row);
45 try paint.appendSlice(alloc, row); 81 try paint.appendSlice(alloc, row);
46 } 82 }
@@ -154,6 +190,7 @@ pub fn renderStripe(
154 replica: *Engine, 190 replica: *Engine,
155 row_off: u16, 191 row_off: u16,
156 view: proto.Size, 192 view: proto.Size,
193 hl: Highlight,
157 out_fd: std.posix.fd_t, 194 out_fd: std.posix.fd_t,
158 ) !void { 195 ) !void {
159 var paint: std.ArrayList(u8) = .empty; 196 var paint: std.ArrayList(u8) = .empty;
@@ -171,7 +208,7 @@ pub fn renderStripe(
171 }) { 208 }) {
172 var cup: [16]u8 = undefined; 209 var cup: [16]u8 = undefined;
173 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row})); 210 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row}));
174 const row = try replica.dumpVtRow(alloc, y); 211 const row = try dumpRow(alloc, replica, y, hl);
175 defer alloc.free(row); 212 defer alloc.free(row);
176 try paint.appendSlice(alloc, row); 213 try paint.appendSlice(alloc, row);
177 } 214 }
@@ -210,7 +247,7 @@ test "renderStripe paints the grid bottom at the stripe offset, no wipe, no curs
210 defer std.posix.close(pipe[0]); 247 defer std.posix.close(pipe[0]);
211 // 3 view rows over a 5-row grid, cursor on row 4: the window keeps the 248 // 3 view rows over a 5-row grid, cursor on row 4: the window keeps the
212 // cursor on its last row — grid rows 2..4 — at offset 4. 249 // cursor on its last row — grid rows 2..4 — at offset 4.
213 try renderStripe(alloc, replica, 4, .{ .cols = 20, .rows = 3 }, pipe[1]); 250 try renderStripe(alloc, replica, 4, .{ .cols = 20, .rows = 3 }, .{}, pipe[1]);
214 std.posix.close(pipe[1]); 251 std.posix.close(pipe[1]);
215 252
216 var out: [4096]u8 = undefined; 253 var out: [4096]u8 = undefined;
@@ -245,7 +282,7 @@ test "renderStripe with the cursor high shows the grid top, not blank bottom row
245 282
246 const pipe = try std.posix.pipe(); 283 const pipe = try std.posix.pipe();
247 defer std.posix.close(pipe[0]); 284 defer std.posix.close(pipe[0]);
248 try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 8 }, pipe[1]); 285 try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 8 }, .{}, pipe[1]);
249 std.posix.close(pipe[1]); 286 std.posix.close(pipe[1]);
250 287
251 var out: [8192]u8 = undefined; 288 var out: [8192]u8 = undefined;
@@ -266,7 +303,7 @@ test "renderStripe clears stripe rows a short grid does not reach" {
266 defer std.posix.close(pipe[0]); 303 defer std.posix.close(pipe[0]);
267 // 4 view rows over a 2-row grid at offset 0: rows 3 and 4 must be 304 // 4 view rows over a 2-row grid at offset 0: rows 3 and 4 must be
268 // cleared, not skipped. 305 // cleared, not skipped.
269 try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 4 }, pipe[1]); 306 try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 4 }, .{}, pipe[1]);
270 std.posix.close(pipe[1]); 307 std.posix.close(pipe[1]);
271 308
272 var out: [4096]u8 = undefined; 309 var out: [4096]u8 = undefined;
@@ -277,6 +314,78 @@ test "renderStripe clears stripe rows a short grid does not reach" {
277 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[2K") != null); 314 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[2K") != null);
278 } 315 }
279 316
317 /// A highlight of two fixed rows, standing in for what `select.Drag`
318 /// answers: rows 1 and 2, from column 3 to the width the painter offers.
319 const TestHighlight = struct {
320 cols_seen: u16 = 0,
321
322 fn span(ctx: ?*anyopaque, row: u16, cols: u16) ?Span {
323 const self: *TestHighlight = @ptrCast(@alignCast(ctx.?));
324 self.cols_seen = cols;
325 if (row != 1 and row != 2) return null;
326 return .{ .from = 3, .to = cols -| 1 };
327 }
328
329 fn hl(self: *TestHighlight) Highlight {
330 return .{ .ctx = self, .span = &span };
331 }
332 };
333
334 test "renderClipped inverts the highlighted rows and leaves the rest alone" {
335 const alloc = std.testing.allocator;
336 var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 });
337 defer replica.deinit();
338 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
339
340 const pipe = try std.posix.pipe();
341 defer std.posix.close(pipe[0]);
342 var h: TestHighlight = .{};
343 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, h.hl(), pipe[1]);
344 std.posix.close(pipe[1]);
345
346 var out: [8192]u8 = undefined;
347 const n = try std.posix.read(pipe[0], &out);
348 const text = out[0..n];
349 // The two highlighted rows carry an inversion; the rows above and
350 // below are ordinary dumps and must not.
351 try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "\x1b[7m"));
352 const r0 = std.mem.indexOf(u8, text, "row-zero").?;
353 const r1 = std.mem.indexOf(u8, text, "\x1b[7m").?;
354 const r3 = std.mem.indexOf(u8, text, "row-three").?;
355 try std.testing.expect(r0 < r1 and r1 < r3);
356 // The span begins at column 4 (0-based 3), which only the CHA says.
357 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null);
358 // The width the highlight was clamped to is the GRID's, not the tty's:
359 // a 12-column replica painted on an 80-column terminal has 68 columns
360 // no cell can be selected in, and asking the daemon for one is a round
361 // trip spent to be told `.invalid`.
362 try std.testing.expectEqual(@as(u16, 12), h.cols_seen);
363 }
364
365 test "renderStripe inverts a highlighted row inside the stripe" {
366 const alloc = std.testing.allocator;
367 var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 });
368 defer replica.deinit();
369 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
370
371 const pipe = try std.posix.pipe();
372 defer std.posix.close(pipe[0]);
373 var h: TestHighlight = .{};
374 // The whole grid fits the view, so the window starts at grid row 0 and
375 // the highlight's rows 1 and 2 are the stripe's second and third.
376 try renderStripe(alloc, replica, 0, .{ .cols = 12, .rows = 4 }, h.hl(), pipe[1]);
377 std.posix.close(pipe[1]);
378
379 var out: [8192]u8 = undefined;
380 const n = try std.posix.read(pipe[0], &out);
381 const text = out[0..n];
382 try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "\x1b[7m"));
383 // GRID rows, not terminal rows: the highlight is asked about the row
384 // of the session, and the stripe offset is the painter's business.
385 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2;1H\x1b[2K\x1b[0mrow\x1b[4G\x1b[0m\x1b[7m") != null);
386 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[2K\x1b[0mrow-zero") != null);
387 }
388
280 test "paintBanner parks an inverse label top-right without moving the cursor" { 389 test "paintBanner parks an inverse label top-right without moving the cursor" {
281 const pipe = try std.posix.pipe(); 390 const pipe = try std.posix.pipe();
282 defer std.posix.close(pipe[0]); 391 defer std.posix.close(pipe[0]);
@@ -333,7 +442,7 @@ test "renderClipped paints only rows that fit and clamps the cursor" {
333 const pipe = try std.posix.pipe(); 442 const pipe = try std.posix.pipe();
334 defer std.posix.close(pipe[0]); 443 defer std.posix.close(pipe[0]);
335 // Local tty is smaller than the 100x30 grid. 444 // Local tty is smaller than the 100x30 grid.
336 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]); 445 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, pipe[1]);
337 std.posix.close(pipe[1]); 446 std.posix.close(pipe[1]);
338 447
339 var out: std.ArrayList(u8) = .empty; 448 var out: std.ArrayList(u8) = .empty;
@@ -372,7 +481,7 @@ test "renderClipped stops at the grid when the tty is the larger one" {
372 481
373 const pipe = try std.posix.pipe(); 482 const pipe = try std.posix.pipe();
374 defer std.posix.close(pipe[0]); 483 defer std.posix.close(pipe[0]);
375 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]); 484 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, pipe[1]);
376 std.posix.close(pipe[1]); 485 std.posix.close(pipe[1]);
377 486
378 var out: std.ArrayList(u8) = .empty; 487 var out: std.ArrayList(u8) = .empty;
src/wallview.zig
Old New
@@ -833,7 +833,7 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u
833 .history_rows = history_rows, 833 .history_rows = history_rows,
834 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), 834 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows),
835 }; 835 };
836 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, t.shared.out_fd) catch {}; 836 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, .{}, t.shared.out_fd) catch {};
837 return true; 837 return true;
838 } 838 }
839 839