a73x

08fe4c4d

refactor: extract src/paint.zig; the sync-update bracket exists once

a73x   2026-08-12 16:15

Commit message
refactor: extract src/paint.zig; the sync-update bracket exists once

The paint layer is pure — an fd out, replica/engine types in, no
transport knowledge — so it moves whole: clampCursor, renderClipped,
paintDeltaClipped, bannerText, paintBanner, renderScrollback and their
seven tests, verbatim.

The synchronized-update bracket had three matched copies. It now has
one named pair; renderClipped, paintDeltaClipped and paintOverlay
(which stays in client.zig, being prediction glue) reference it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -199,6 +199,17 @@ pub fn build(b: *std.Build) void {
199 }); 199 });
200 proxy_mod.addImport("testtmp", testtmp_mod); 200 proxy_mod.addImport("testtmp", testtmp_mod);
201 201
202 // Painting the replica onto a tty. Takes an fd out and replica/engine
203 // types in, and knows nothing about transports — which is what lets its
204 // tests drive every painter through a pipe with no daemon anywhere.
205 const paint_mod = b.createModule(.{
206 .root_source_file = b.path("src/paint.zig"),
207 .target = target,
208 .optimize = optimize,
209 });
210 paint_mod.addImport("engine", engine_mod);
211 paint_mod.addImport("protocol", protocol_mod);
212
202 const client_mod = b.createModule(.{ 213 const client_mod = b.createModule(.{
203 .root_source_file = b.path("src/client.zig"), 214 .root_source_file = b.path("src/client.zig"),
204 .target = target, 215 .target = target,
@@ -216,6 +227,7 @@ pub fn build(b: *std.Build) void {
216 // The client borrows ignoreSigpipe, which proxy owns. proxy is a leaf, 227 // The client borrows ignoreSigpipe, which proxy owns. proxy is a leaf,
217 // so this adds no cycle and teaches the proxy nothing. 228 // so this adds no cycle and teaches the proxy nothing.
218 client_mod.addImport("proxy", proxy_mod); 229 client_mod.addImport("proxy", proxy_mod);
230 client_mod.addImport("paint", paint_mod);
219 231
220 const mux_mod = b.createModule(.{ 232 const mux_mod = b.createModule(.{
221 .root_source_file = b.path("src/mux_main.zig"), 233 .root_source_file = b.path("src/mux_main.zig"),
@@ -331,7 +343,7 @@ pub fn build(b: *std.Build) void {
331 // absence here was a live hazard recorded in decisions.md — muxd's 343 // absence here was a live hazard recorded in decisions.md — muxd's
332 // entrypoint could grow tests that silently never ran, exactly as 344 // entrypoint could grow tests that silently never ran, exactly as
333 // mux_main.zig's five did before it was added. 345 // mux_main.zig's five did before it was added.
334 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, render_mod, ptyclient_mod }) |mod| { 346 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| {
335 const t = b.addTest(.{ .root_module = mod }); 347 const t = b.addTest(.{ .root_module = mod });
336 t.use_llvm = true; 348 t.use_llvm = true;
337 t.use_lld = true; 349 t.use_lld = true;
src/client.zig
Old New
@@ -12,6 +12,9 @@ const TmpDir = @import("testtmp").TmpDir;
12 const quic_client = @import("quic_client"); 12 const quic_client = @import("quic_client");
13 const predict = @import("predict"); 13 const predict = @import("predict");
14 const handoff = @import("handoff"); 14 const handoff = @import("handoff");
15 // Named `paint_mod` because paintOverlay holds a local ArrayList called
16 // `paint`, which a container-level `paint` would collide with.
17 const paint_mod = @import("paint");
15 // For ignoreSigpipe only, which proxy.zig owns. 18 // For ignoreSigpipe only, which proxy.zig owns.
16 const proxy = @import("proxy"); 19 const proxy = @import("proxy");
17 20
@@ -1010,7 +1013,7 @@ fn session(
1010 // reconcile will never run again and the glyph would otherwise stay 1013 // reconcile will never run again and the glyph would otherwise stay
1011 // on screen for the rest of the session. 1014 // on screen for the rest of the session.
1012 if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) { 1015 if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) {
1013 try renderClipped(alloc, replica, size, stdout_fd); 1016 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1014 } 1017 }
1015 1018
1016 // Labelled, because "no whole frame yet" must leave the REST of this 1019 // Labelled, because "no whole frame yet" must leave the REST of this
@@ -1080,7 +1083,7 @@ fn session(
1080 overlay.flush(); 1083 overlay.flush();
1081 overlay.noteSeq(prefix.seq); 1084 overlay.noteSeq(prefix.seq);
1082 if (scroll_pages == 0) { 1085 if (scroll_pages == 0) {
1083 try renderClipped(alloc, replica, size, stdout_fd); 1086 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1084 repaint_after_resync = false; // banner painted over 1087 repaint_after_resync = false; // banner painted over
1085 } 1088 }
1086 }, 1089 },
@@ -1135,10 +1138,10 @@ fn session(
1135 // It is affordable precisely because reconcile v2 1138 // It is affordable precisely because reconcile v2
1136 // made contradictions rare — a burst outrunning 1139 // made contradictions rare — a burst outrunning
1137 // the round trip is no longer one. 1140 // the round trip is no longer one.
1138 try renderClipped(alloc, replica, size, stdout_fd); 1141 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1139 repaint_after_resync = false; 1142 repaint_after_resync = false;
1140 } else { 1143 } else {
1141 try paintDeltaClipped(alloc, frame.payload, size, stdout_fd); 1144 try paint_mod.paintDeltaClipped(alloc, frame.payload, size, stdout_fd);
1142 } 1145 }
1143 // Last, and after either paint: the rows the daemon 1146 // Last, and after either paint: the rows the daemon
1144 // just sent have overwritten anything drawn on them, 1147 // just sent have overwritten anything drawn on them,
@@ -1155,12 +1158,12 @@ fn session(
1155 const had_pending = overlay.pendingCount() > 0; 1158 const had_pending = overlay.pendingCount() > 0;
1156 overlay.setMode(flags); 1159 overlay.setMode(flags);
1157 if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) { 1160 if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) {
1158 try renderClipped(alloc, replica, size, stdout_fd); 1161 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1159 } 1162 }
1160 }, 1163 },
1161 .scrollback_chunk => { 1164 .scrollback_chunk => {
1162 if (scroll_pages == 0 or frame.payload.len < 6) continue; 1165 if (scroll_pages == 0 or frame.payload.len < 6) continue;
1163 try renderScrollback(alloc, frame.payload[6..], size, stdout_fd); 1166 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd);
1164 }, 1167 },
1165 .exit_status => { 1168 .exit_status => {
1166 // Before any session state, exit_status is almost always 1169 // Before any session state, exit_status is almost always
@@ -1223,7 +1226,7 @@ fn session(
1223 if (scroll_pages > 0) scroll_pages -= 1; 1226 if (scroll_pages > 0) scroll_pages -= 1;
1224 if (scroll_pages == 0) { 1227 if (scroll_pages == 0) {
1225 overlay.setScrollMode(false); 1228 overlay.setScrollMode(false);
1226 try renderClipped(alloc, replica, size, stdout_fd); 1229 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1227 } else { 1230 } else {
1228 requestScrollPage(transport, scroll_pages, history_rows, size) catch { 1231 requestScrollPage(transport, scroll_pages, history_rows, size) catch {
1229 needs_reconnect = true; 1232 needs_reconnect = true;
@@ -1234,7 +1237,7 @@ fn session(
1234 // Any other key exits scroll mode (swallowed, not forwarded). 1237 // Any other key exits scroll mode (swallowed, not forwarded).
1235 scroll_pages = 0; 1238 scroll_pages = 0;
1236 overlay.setScrollMode(false); 1239 overlay.setScrollMode(false);
1237 try renderClipped(alloc, replica, size, stdout_fd); 1240 try paint_mod.renderClipped(alloc, replica, size, stdout_fd);
1238 } else { 1241 } else {
1239 // Speculate before sending, so the glyph is on screen 1242 // Speculate before sending, so the glyph is on screen
1240 // while the keystroke is still in flight. The bytes that 1243 // while the keystroke is still in flight. The bytes that
@@ -1263,63 +1266,6 @@ fn ttySize(fd: std.posix.fd_t) ?proto.Size {
1263 return .{ .cols = ws.col, .rows = ws.row }; 1266 return .{ .cols = ws.col, .rows = ws.row };
1264 } 1267 }
1265 1268
1266 fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
1267 return .{
1268 .x = @min(cur.x, tty.cols -| 1),
1269 .y = @min(cur.y, tty.rows -| 1),
1270 };
1271 }
1272
1273 /// Full repaint of the replica, clipped to the local tty. The replica is
1274 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty
1275 /// are skipped and long rows clip at the right edge because autowrap is
1276 /// off (DECAWM, set at attach).
1277 fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size, out_fd: std.posix.fd_t) !void {
1278 var paint: std.ArrayList(u8) = .empty;
1279 defer paint.deinit(alloc);
1280 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
1281
1282 const grid_rows: u16 = @intCast(replica.term.rows);
1283 const limit = @min(grid_rows, tty.rows);
1284 var y: u16 = 0;
1285 while (y < limit) : (y += 1) {
1286 var cup: [16]u8 = undefined;
1287 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1}));
1288 const row = try replica.dumpVtRow(alloc, y);
1289 defer alloc.free(row);
1290 try paint.appendSlice(alloc, row);
1291 }
1292
1293 const cur = clampCursor(replica.cursorPos(), tty);
1294 var cbuf: [16]u8 = undefined;
1295 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
1296 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
1297 try proto.writeAllFd(out_fd, paint.items);
1298 }
1299
1300 /// Paint a delta directly, skipping rows outside the local tty. The
1301 /// replica is updated separately via composeDelta (full, unclipped).
1302 fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.Size, out_fd: std.posix.fd_t) !void {
1303 const hdr = try proto.readDeltaHeader(payload);
1304 var paint: std.ArrayList(u8) = .empty;
1305 defer paint.deinit(alloc);
1306 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l");
1307
1308 var it = proto.deltaRowIterator(payload);
1309 while (try it.next()) |row| {
1310 if (row.row >= tty.rows) continue;
1311 var cup: [16]u8 = undefined;
1312 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, row.row) + 1}));
1313 try paint.appendSlice(alloc, row.bytes);
1314 }
1315
1316 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, tty);
1317 var cbuf: [16]u8 = undefined;
1318 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
1319 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
1320 try proto.writeAllFd(out_fd, paint.items);
1321 }
1322
1323 // ---- prediction -------------------------------------------------------- 1269 // ---- prediction --------------------------------------------------------
1324 // 1270 //
1325 // The overlay is a display decision and nothing else. It never writes to 1271 // The overlay is a display decision and nothing else. It never writes to
@@ -1379,7 +1325,7 @@ fn paintOverlay(
1379 if (!overlay.confident or overlay.pendingCount() == 0) return; 1325 if (!overlay.confident or overlay.pendingCount() == 0) return;
1380 var paint: std.ArrayList(u8) = .empty; 1326 var paint: std.ArrayList(u8) = .empty;
1381 defer paint.deinit(alloc); 1327 defer paint.deinit(alloc);
1382 paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l") catch return; 1328 paint.appendSlice(alloc, paint_mod.sync_begin) catch return;
1383 1329
1384 var i: usize = 0; 1330 var i: usize = 0;
1385 while (i < overlay.pendingCount()) : (i += 1) { 1331 while (i < overlay.pendingCount()) : (i += 1) {
@@ -1402,9 +1348,9 @@ fn paintOverlay(
1402 } 1348 }
1403 1349
1404 const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); 1350 const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
1405 const cur = clampCursor(.{ .x = pc.x, .y = pc.y }, tty); 1351 const cur = paint_mod.clampCursor(.{ .x = pc.x, .y = pc.y }, tty);
1406 var cbuf: [32]u8 = undefined; 1352 var cbuf: [32]u8 = undefined;
1407 const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H\x1b[?25h\x1b[?2026l", .{ 1353 const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{
1408 cur.y + 1, 1354 cur.y + 1,
1409 cur.x + 1, 1355 cur.x + 1,
1410 }) catch return; 1356 }) catch return;
@@ -1487,28 +1433,6 @@ fn requestScrollPage(
1487 try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); 1433 try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows));
1488 } 1434 }
1489 1435
1490 /// An inverse status marker parked in the top-right corner: `[scroll]` when
1491 /// viewing history, `[reconnecting]` when the transport is being rebuilt.
1492 /// Text only, so a caller mid-repaint can append it into its own paint
1493 /// buffer and keep the whole screen one synchronized update.
1494 fn bannerText(buf: []u8, size: proto.Size, label: []const u8) ![]const u8 {
1495 // Columns are 1-based; a label wider than the whole tty would otherwise
1496 // address column 0, which terminals only silently forgive.
1497 const col = @max(1, size.cols -| @as(u16, @intCast(label.len)));
1498 return std.fmt.bufPrint(buf, "\x1b[1;{d}H\x1b[7m{s}\x1b[0m", .{ col, label });
1499 }
1500
1501 /// Drop a banner onto a screen that is otherwise staying put — the cursor is
1502 /// saved and restored around it, so the shell's cursor does not visibly jump
1503 /// to the corner. Best-effort: a status marker is never worth failing over.
1504 fn paintBanner(out_fd: std.posix.fd_t, size: proto.Size, label: []const u8) void {
1505 var buf: [96]u8 = undefined;
1506 const mark = bannerText(&buf, size, label) catch return;
1507 var paint: [128]u8 = undefined;
1508 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return;
1509 proto.writeAllFd(out_fd, text) catch {};
1510 }
1511
1512 /// Wait up to `timeout_ms` for the user to give up on a reconnect. Input 1436 /// Wait up to `timeout_ms` for the user to give up on a reconnect. Input
1513 /// typed while disconnected is read and dropped by policy — replaying a 1437 /// typed while disconnected is read and dropped by policy — replaying a
1514 /// burst of stale keystrokes into the shell on resume is worse than losing 1438 /// burst of stale keystrokes into the shell on resume is worse than losing
@@ -1564,7 +1488,7 @@ fn reconnect(
1564 stdout_fd: std.posix.fd_t, 1488 stdout_fd: std.posix.fd_t,
1565 is_tty: bool, 1489 is_tty: bool,
1566 ) bool { 1490 ) bool {
1567 if (is_tty) paintBanner(stdout_fd, size, "[reconnecting]"); 1491 if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[reconnecting]");
1568 // The dead transport is released exactly once, here. Everything after 1492 // The dead transport is released exactly once, here. Everything after
1569 // this point owns only what it opened itself, which is what keeps the 1493 // this point owns only what it opened itself, which is what keeps the
1570 // retry loop from closing the same fd twice. 1494 // retry loop from closing the same fd twice.
@@ -1624,24 +1548,6 @@ fn reconnect(
1624 } 1548 }
1625 } 1549 }
1626 1550
1627 /// Paint a fetched history page: clear, rows, and an inverse [scroll]
1628 /// marker top-right so the user knows they're not live.
1629 fn renderScrollback(
1630 alloc: std.mem.Allocator,
1631 rows_vt: []const u8,
1632 size: proto.Size,
1633 out_fd: std.posix.fd_t,
1634 ) !void {
1635 var paint: std.ArrayList(u8) = .empty;
1636 defer paint.deinit(alloc);
1637 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
1638 try paint.appendSlice(alloc, rows_vt);
1639 var mark_buf: [96]u8 = undefined;
1640 try paint.appendSlice(alloc, try bannerText(&mark_buf, size, "[scroll]"));
1641 try paint.appendSlice(alloc, "\x1b[?2026l");
1642 try proto.writeAllFd(out_fd, paint.items);
1643 }
1644
1645 fn devNull() !std.posix.fd_t { 1551 fn devNull() !std.posix.fd_t {
1646 return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); 1552 return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0);
1647 } 1553 }
@@ -2144,37 +2050,6 @@ test "handoff: keystrokes during the announce wait are owed to the shell" {
2144 try std.testing.expectEqualStrings("echo hi\n", carry.items); 2050 try std.testing.expectEqualStrings("echo hi\n", carry.items);
2145 } 2051 }
2146 2052
2147 test "paintBanner parks an inverse label top-right without moving the cursor" {
2148 const pipe = try std.posix.pipe();
2149 defer std.posix.close(pipe[0]);
2150 paintBanner(pipe[1], .{ .cols = 80, .rows = 24 }, "[reconnecting]");
2151 std.posix.close(pipe[1]);
2152
2153 var out: [256]u8 = undefined;
2154 const n = try std.posix.read(pipe[0], &out);
2155 const text = out[0..n];
2156 // Row 1, right-aligned: 80 columns less the label's own width.
2157 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;66H") != null);
2158 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7m[reconnecting]\x1b[0m") != null);
2159 // Saved and restored around the paint, so the shell's cursor does not
2160 // visibly jump into the corner while we reconnect.
2161 try std.testing.expect(std.mem.startsWith(u8, text, "\x1b[s"));
2162 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[u"));
2163 }
2164
2165 test "paintBanner on a narrow tty clamps to column 1 instead of underflowing" {
2166 const pipe = try std.posix.pipe();
2167 defer std.posix.close(pipe[0]);
2168 // Label longer than the whole terminal: the saturating subtraction must
2169 // land on column 1, never wrap around to a huge column.
2170 paintBanner(pipe[1], .{ .cols = 4, .rows = 24 }, "[reconnecting]");
2171 std.posix.close(pipe[1]);
2172
2173 var out: [256]u8 = undefined;
2174 const n = try std.posix.read(pipe[0], &out);
2175 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[1;1H") != null);
2176 }
2177
2178 test "drainStdinForQuit: the quit byte is seen, other input is dropped" { 2053 test "drainStdinForQuit: the quit byte is seen, other input is dropped" {
2179 // Ctrl-\ anywhere in what was typed means the user gave up. 2054 // Ctrl-\ anywhere in what was typed means the user gave up.
2180 { 2055 {
@@ -2259,141 +2134,6 @@ test "drainStdinForQuit: closed stdin still paces the retry instead of spinning"
2259 try std.testing.expect(elapsed_ms >= 150); 2134 try std.testing.expect(elapsed_ms >= 150);
2260 } 2135 }
2261 2136
2262 test "renderScrollback paints rows with an inverse scroll marker" {
2263 const alloc = std.testing.allocator;
2264 const pipe = try std.posix.pipe();
2265 defer std.posix.close(pipe[0]);
2266 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .cols = 80, .rows = 24 }, pipe[1]);
2267 std.posix.close(pipe[1]);
2268
2269 var out: [4096]u8 = undefined;
2270 const n = try std.posix.read(pipe[0], &out);
2271 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "old-row-1") != null);
2272 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[7m[scroll]") != null);
2273 }
2274
2275 test "renderClipped paints only rows that fit and clamps the cursor" {
2276 const alloc = std.testing.allocator;
2277 var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 });
2278 defer replica.deinit();
2279 replica.feed("top row\r\n");
2280 var i: usize = 0;
2281 while (i < 28) : (i += 1) replica.feed("mid\r\n");
2282 replica.feed("bottom row\x1b[30;100H"); // cursor parked at grid corner
2283
2284 const pipe = try std.posix.pipe();
2285 defer std.posix.close(pipe[0]);
2286 // Local tty is smaller than the 100x30 grid.
2287 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]);
2288 std.posix.close(pipe[1]);
2289
2290 var out: std.ArrayList(u8) = .empty;
2291 defer out.deinit(alloc);
2292 var chunk: [4096]u8 = undefined;
2293 while (true) {
2294 const n = try std.posix.read(pipe[0], &chunk);
2295 if (n == 0) break;
2296 try out.appendSlice(alloc, chunk[0..n]);
2297 }
2298
2299 try std.testing.expect(std.mem.indexOf(u8, out.items, "top row") != null);
2300 // Row 29 (0-based) of the grid is beyond a 24-row tty: never painted.
2301 try std.testing.expect(std.mem.indexOf(u8, out.items, "bottom row") == null);
2302 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[25;") == null); // no CUP past the tty
2303 // Cursor clamped into the tty (row 24, col 80), inside sync brackets.
2304 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[24;80H") != null);
2305 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?2026h") != null);
2306 }
2307
2308 test "renderClipped stops at the grid when the tty is the larger one" {
2309 // The other direction of the clip: dumpVtRow asserts y < term.rows, so
2310 // the row loop must bound on the grid, not just on the tty.
2311 const alloc = std.testing.allocator;
2312 var replica = try Engine.init(alloc, .{ .cols = 40, .rows = 10 });
2313 defer replica.deinit();
2314 replica.feed("small grid\x1b[10;40H");
2315
2316 const pipe = try std.posix.pipe();
2317 defer std.posix.close(pipe[0]);
2318 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]);
2319 std.posix.close(pipe[1]);
2320
2321 var out: std.ArrayList(u8) = .empty;
2322 defer out.deinit(alloc);
2323 var chunk: [4096]u8 = undefined;
2324 while (true) {
2325 const n = try std.posix.read(pipe[0], &chunk);
2326 if (n == 0) break;
2327 try out.appendSlice(alloc, chunk[0..n]);
2328 }
2329
2330 try std.testing.expect(std.mem.indexOf(u8, out.items, "small grid") != null);
2331 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;1H") != null); // last grid row
2332 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[11;1H") == null); // none past it
2333 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;40H") != null); // cursor unclamped
2334 }
2335
2336 test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
2337 const alloc = std.testing.allocator;
2338 var payload: std.ArrayList(u8) = .empty;
2339 defer payload.deinit(alloc);
2340 try proto.appendDeltaHeader(&payload, alloc, .{
2341 .seq = 9,
2342 .history_rows = 0,
2343 .cursor_x = 99,
2344 .cursor_y = 29,
2345 .row_count = 2,
2346 });
2347 try proto.appendDeltaRow(&payload, alloc, 3, "\x1b[0mfits");
2348 try proto.appendDeltaRow(&payload, alloc, 28, "\x1b[0mdoes-not-fit");
2349
2350 const pipe = try std.posix.pipe();
2351 defer std.posix.close(pipe[0]);
2352 try paintDeltaClipped(alloc, payload.items, .{ .cols = 80, .rows = 24 }, pipe[1]);
2353 std.posix.close(pipe[1]);
2354 var out: [4096]u8 = undefined;
2355 const n = try std.posix.read(pipe[0], &out);
2356
2357 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[2K\x1b[0mfits") != null);
2358 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
2359 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
2360 }
2361
2362 test "paintDeltaClipped brackets the whole paint in one synchronized update" {
2363 // The delta path's flicker defence, and the one layer that can hold it:
2364 // a synchronized update changes WHEN the terminal shows the paint, never
2365 // WHAT it shows, so no rendered grid can tell a torn paint from a whole
2366 // one. The campaign proved that the hard way — mutation 5 dropped this
2367 // wrapper and both e2e suites passed, because there was nothing for them
2368 // to see. Asserted on the ends rather than by substring search: the point
2369 // is that every row lands INSIDE the brackets, which only position shows.
2370 const alloc = std.testing.allocator;
2371 var payload: std.ArrayList(u8) = .empty;
2372 defer payload.deinit(alloc);
2373 try proto.appendDeltaHeader(&payload, alloc, .{
2374 .seq = 3,
2375 .history_rows = 0,
2376 .cursor_x = 0,
2377 .cursor_y = 1,
2378 .row_count = 1,
2379 });
2380 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0mrow");
2381
2382 const pipe = try std.posix.pipe();
2383 defer std.posix.close(pipe[0]);
2384 try paintDeltaClipped(alloc, payload.items, .{ .cols = 80, .rows = 24 }, pipe[1]);
2385 std.posix.close(pipe[1]);
2386 var out: [4096]u8 = undefined;
2387 const n = try std.posix.read(pipe[0], &out);
2388
2389 // Begin: the update opens before the first CUP, and the cursor goes with
2390 // it — a visible cursor stepping through the rows is the same flicker.
2391 try std.testing.expect(std.mem.startsWith(u8, out[0..n], "\x1b[?2026h\x1b[?25l"));
2392 // End: the cursor comes back and the frame is committed, in that order.
2393 // Committing first would show one frame with the cursor still hidden.
2394 try std.testing.expect(std.mem.endsWith(u8, out[0..n], "\x1b[?25h\x1b[?2026l"));
2395 }
2396
2397 test "parseQuicAddr: no port means 4433, explicit port wins" { 2137 test "parseQuicAddr: no port means 4433, explicit port wins" {
2398 // 4433 spelled out, not `quic_client.default_port`: asserting against 2138 // 4433 spelled out, not `quic_client.default_port`: asserting against
2399 // the constant the code under test reads would hold for any value, so 2139 // the constant the code under test reads would hold for any value, so
src/paint.zig
Old New
@@ -0,0 +1,274 @@
1 //! painting the replica to a tty: clipped renders, delta rows, banner,
2 //! scrollback — pure fd-out, no transport knowledge.
3 const std = @import("std");
4 const Engine = @import("engine").Engine;
5 const proto = @import("protocol");
6
7 /// The synchronized-update bracket. Exists exactly once because a
8 /// dropped half is invisible to both e2e suites (the bytes still
9 /// paint, just tearably) — only the wrapper unit test sees it.
10 pub const sync_begin = "\x1b[?2026h\x1b[?25l";
11 pub const sync_end = "\x1b[?25h\x1b[?2026l";
12
13 pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
14 return .{
15 .x = @min(cur.x, tty.cols -| 1),
16 .y = @min(cur.y, tty.rows -| 1),
17 };
18 }
19
20 /// Full repaint of the replica, clipped to the local tty. The replica is
21 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty
22 /// are skipped and long rows clip at the right edge because autowrap is
23 /// off (DECAWM, set at attach).
24 pub fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size, out_fd: std.posix.fd_t) !void {
25 var paint: std.ArrayList(u8) = .empty;
26 defer paint.deinit(alloc);
27 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J");
28
29 const grid_rows: u16 = @intCast(replica.term.rows);
30 const limit = @min(grid_rows, tty.rows);
31 var y: u16 = 0;
32 while (y < limit) : (y += 1) {
33 var cup: [16]u8 = undefined;
34 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1}));
35 const row = try replica.dumpVtRow(alloc, y);
36 defer alloc.free(row);
37 try paint.appendSlice(alloc, row);
38 }
39
40 const cur = clampCursor(replica.cursorPos(), tty);
41 var cbuf: [16]u8 = undefined;
42 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
43 try paint.appendSlice(alloc, sync_end);
44 try proto.writeAllFd(out_fd, paint.items);
45 }
46
47 /// Paint a delta directly, skipping rows outside the local tty. The
48 /// replica is updated separately via composeDelta (full, unclipped).
49 pub fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.Size, out_fd: std.posix.fd_t) !void {
50 const hdr = try proto.readDeltaHeader(payload);
51 var paint: std.ArrayList(u8) = .empty;
52 defer paint.deinit(alloc);
53 try paint.appendSlice(alloc, sync_begin);
54
55 var it = proto.deltaRowIterator(payload);
56 while (try it.next()) |row| {
57 if (row.row >= tty.rows) continue;
58 var cup: [16]u8 = undefined;
59 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, row.row) + 1}));
60 try paint.appendSlice(alloc, row.bytes);
61 }
62
63 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, tty);
64 var cbuf: [16]u8 = undefined;
65 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
66 try paint.appendSlice(alloc, sync_end);
67 try proto.writeAllFd(out_fd, paint.items);
68 }
69
70 /// An inverse status marker parked in the top-right corner: `[scroll]` when
71 /// viewing history, `[reconnecting]` when the transport is being rebuilt.
72 /// Text only, so a caller mid-repaint can append it into its own paint
73 /// buffer and keep the whole screen one synchronized update.
74 fn bannerText(buf: []u8, size: proto.Size, label: []const u8) ![]const u8 {
75 // Columns are 1-based; a label wider than the whole tty would otherwise
76 // address column 0, which terminals only silently forgive.
77 const col = @max(1, size.cols -| @as(u16, @intCast(label.len)));
78 return std.fmt.bufPrint(buf, "\x1b[1;{d}H\x1b[7m{s}\x1b[0m", .{ col, label });
79 }
80
81 /// Drop a banner onto a screen that is otherwise staying put — the cursor is
82 /// saved and restored around it, so the shell's cursor does not visibly jump
83 /// to the corner. Best-effort: a status marker is never worth failing over.
84 pub fn paintBanner(out_fd: std.posix.fd_t, size: proto.Size, label: []const u8) void {
85 var buf: [96]u8 = undefined;
86 const mark = bannerText(&buf, size, label) catch return;
87 var paint: [128]u8 = undefined;
88 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return;
89 proto.writeAllFd(out_fd, text) catch {};
90 }
91
92 /// Paint a fetched history page: clear, rows, and an inverse [scroll]
93 /// marker top-right so the user knows they're not live.
94 pub fn renderScrollback(
95 alloc: std.mem.Allocator,
96 rows_vt: []const u8,
97 size: proto.Size,
98 out_fd: std.posix.fd_t,
99 ) !void {
100 var paint: std.ArrayList(u8) = .empty;
101 defer paint.deinit(alloc);
102 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
103 try paint.appendSlice(alloc, rows_vt);
104 var mark_buf: [96]u8 = undefined;
105 try paint.appendSlice(alloc, try bannerText(&mark_buf, size, "[scroll]"));
106 try paint.appendSlice(alloc, "\x1b[?2026l");
107 try proto.writeAllFd(out_fd, paint.items);
108 }
109
110 test "paintBanner parks an inverse label top-right without moving the cursor" {
111 const pipe = try std.posix.pipe();
112 defer std.posix.close(pipe[0]);
113 paintBanner(pipe[1], .{ .cols = 80, .rows = 24 }, "[reconnecting]");
114 std.posix.close(pipe[1]);
115
116 var out: [256]u8 = undefined;
117 const n = try std.posix.read(pipe[0], &out);
118 const text = out[0..n];
119 // Row 1, right-aligned: 80 columns less the label's own width.
120 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;66H") != null);
121 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7m[reconnecting]\x1b[0m") != null);
122 // Saved and restored around the paint, so the shell's cursor does not
123 // visibly jump into the corner while we reconnect.
124 try std.testing.expect(std.mem.startsWith(u8, text, "\x1b[s"));
125 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[u"));
126 }
127
128 test "paintBanner on a narrow tty clamps to column 1 instead of underflowing" {
129 const pipe = try std.posix.pipe();
130 defer std.posix.close(pipe[0]);
131 // Label longer than the whole terminal: the saturating subtraction must
132 // land on column 1, never wrap around to a huge column.
133 paintBanner(pipe[1], .{ .cols = 4, .rows = 24 }, "[reconnecting]");
134 std.posix.close(pipe[1]);
135
136 var out: [256]u8 = undefined;
137 const n = try std.posix.read(pipe[0], &out);
138 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[1;1H") != null);
139 }
140
141 test "renderScrollback paints rows with an inverse scroll marker" {
142 const alloc = std.testing.allocator;
143 const pipe = try std.posix.pipe();
144 defer std.posix.close(pipe[0]);
145 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .cols = 80, .rows = 24 }, pipe[1]);
146 std.posix.close(pipe[1]);
147
148 var out: [4096]u8 = undefined;
149 const n = try std.posix.read(pipe[0], &out);
150 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "old-row-1") != null);
151 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[7m[scroll]") != null);
152 }
153
154 test "renderClipped paints only rows that fit and clamps the cursor" {
155 const alloc = std.testing.allocator;
156 var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 });
157 defer replica.deinit();
158 replica.feed("top row\r\n");
159 var i: usize = 0;
160 while (i < 28) : (i += 1) replica.feed("mid\r\n");
161 replica.feed("bottom row\x1b[30;100H"); // cursor parked at grid corner
162
163 const pipe = try std.posix.pipe();
164 defer std.posix.close(pipe[0]);
165 // Local tty is smaller than the 100x30 grid.
166 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]);
167 std.posix.close(pipe[1]);
168
169 var out: std.ArrayList(u8) = .empty;
170 defer out.deinit(alloc);
171 var chunk: [4096]u8 = undefined;
172 while (true) {
173 const n = try std.posix.read(pipe[0], &chunk);
174 if (n == 0) break;
175 try out.appendSlice(alloc, chunk[0..n]);
176 }
177
178 try std.testing.expect(std.mem.indexOf(u8, out.items, "top row") != null);
179 // Row 29 (0-based) of the grid is beyond a 24-row tty: never painted.
180 try std.testing.expect(std.mem.indexOf(u8, out.items, "bottom row") == null);
181 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[25;") == null); // no CUP past the tty
182 // Cursor clamped into the tty (row 24, col 80), inside sync brackets.
183 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[24;80H") != null);
184 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?2026h") != null);
185 }
186
187 test "renderClipped stops at the grid when the tty is the larger one" {
188 // The other direction of the clip: dumpVtRow asserts y < term.rows, so
189 // the row loop must bound on the grid, not just on the tty.
190 const alloc = std.testing.allocator;
191 var replica = try Engine.init(alloc, .{ .cols = 40, .rows = 10 });
192 defer replica.deinit();
193 replica.feed("small grid\x1b[10;40H");
194
195 const pipe = try std.posix.pipe();
196 defer std.posix.close(pipe[0]);
197 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]);
198 std.posix.close(pipe[1]);
199
200 var out: std.ArrayList(u8) = .empty;
201 defer out.deinit(alloc);
202 var chunk: [4096]u8 = undefined;
203 while (true) {
204 const n = try std.posix.read(pipe[0], &chunk);
205 if (n == 0) break;
206 try out.appendSlice(alloc, chunk[0..n]);
207 }
208
209 try std.testing.expect(std.mem.indexOf(u8, out.items, "small grid") != null);
210 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;1H") != null); // last grid row
211 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[11;1H") == null); // none past it
212 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;40H") != null); // cursor unclamped
213 }
214
215 test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
216 const alloc = std.testing.allocator;
217 var payload: std.ArrayList(u8) = .empty;
218 defer payload.deinit(alloc);
219 try proto.appendDeltaHeader(&payload, alloc, .{
220 .seq = 9,
221 .history_rows = 0,
222 .cursor_x = 99,
223 .cursor_y = 29,
224 .row_count = 2,
225 });
226 try proto.appendDeltaRow(&payload, alloc, 3, "\x1b[0mfits");
227 try proto.appendDeltaRow(&payload, alloc, 28, "\x1b[0mdoes-not-fit");
228
229 const pipe = try std.posix.pipe();
230 defer std.posix.close(pipe[0]);
231 try paintDeltaClipped(alloc, payload.items, .{ .cols = 80, .rows = 24 }, pipe[1]);
232 std.posix.close(pipe[1]);
233 var out: [4096]u8 = undefined;
234 const n = try std.posix.read(pipe[0], &out);
235
236 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[2K\x1b[0mfits") != null);
237 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
238 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
239 }
240
241 test "paintDeltaClipped brackets the whole paint in one synchronized update" {
242 // The delta path's flicker defence, and the one layer that can hold it:
243 // a synchronized update changes WHEN the terminal shows the paint, never
244 // WHAT it shows, so no rendered grid can tell a torn paint from a whole
245 // one. The campaign proved that the hard way — mutation 5 dropped this
246 // wrapper and both e2e suites passed, because there was nothing for them
247 // to see. Asserted on the ends rather than by substring search: the point
248 // is that every row lands INSIDE the brackets, which only position shows.
249 const alloc = std.testing.allocator;
250 var payload: std.ArrayList(u8) = .empty;
251 defer payload.deinit(alloc);
252 try proto.appendDeltaHeader(&payload, alloc, .{
253 .seq = 3,
254 .history_rows = 0,
255 .cursor_x = 0,
256 .cursor_y = 1,
257 .row_count = 1,
258 });
259 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0mrow");
260
261 const pipe = try std.posix.pipe();
262 defer std.posix.close(pipe[0]);
263 try paintDeltaClipped(alloc, payload.items, .{ .cols = 80, .rows = 24 }, pipe[1]);
264 std.posix.close(pipe[1]);
265 var out: [4096]u8 = undefined;
266 const n = try std.posix.read(pipe[0], &out);
267
268 // Begin: the update opens before the first CUP, and the cursor goes with
269 // it — a visible cursor stepping through the rows is the same flicker.
270 try std.testing.expect(std.mem.startsWith(u8, out[0..n], "\x1b[?2026h\x1b[?25l"));
271 // End: the cursor comes back and the frame is committed, in that order.
272 // Committing first would show one frame with the cursor still hidden.
273 try std.testing.expect(std.mem.endsWith(u8, out[0..n], "\x1b[?25h\x1b[?2026l"));
274 }