08fe4c4d
refactor: extract src/paint.zig; the sync-update bracket exists once
a73x 2026-08-12 16:15
Commit message
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 | } | ||