a73x

3c7beb0b

feat: every painter reads the grid, and the two browser clients hold one

a73x   2026-09-04 18:04

Commit message
feat: every painter reads the grid, and the two browser clients hold one

paint.zig gains the serializer that turns a grid row into terminal bytes:
an SGR wherever the style changes, a wide glyph once with its spacer
skipped, a stop at the last cell that is not a default blank, and the
three-piece inverted span the selection needs. There is no verbatim delta
path any more — the frame's rows are cells, so every painted row is
re-serialized at the pane's own width, which is also what keeps a grid-wide
row off a narrower pane's neighbour.

The interaction loop, the wasm core and the browser stand-in hold a Grid
where they held an Engine, and the scrollback page is decoded rows rather
than a blob of VT. The web page passes the whole scrollback chunk down,
header included, because only that header says how many rows follow.

Tests author their screens through an engine and mirror them in, which is
the path the daemon's encoder and the replica's decoder take on the wire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

src/client/wasm_core.zig
Old New
@@ -1,4 +1,4 @@
1 //! The browser replica core: Engine + Replica + ClientCore + keymap compiled 1 //! The browser replica core: Grid + Replica + ClientCore + keymap compiled
2 //! to wasm32-freestanding. The JS shell is glue; every decision is on this side. 2 //! to wasm32-freestanding. The JS shell is glue; every decision is on this side.
3 //! 3 //!
4 //! FRAME-driven, not byte-driven: the host stages one payload and calls 4 //! FRAME-driven, not byte-driven: the host stages one payload and calls
@@ -12,7 +12,8 @@
12 12
13 const std = @import("std"); 13 const std = @import("std");
14 const builtin = @import("builtin"); 14 const builtin = @import("builtin");
15 const Engine = @import("term").engine.Engine; 15 const grid_mod = @import("term").grid;
16 const Grid = grid_mod.Grid;
16 const Replica = @import("term").replica.Replica; 17 const Replica = @import("term").replica.Replica;
17 const keymap = @import("keymap.zig"); 18 const keymap = @import("keymap.zig");
18 const proto = @import("term").protocol; 19 const proto = @import("term").protocol;
@@ -44,7 +45,7 @@ pub const std_options: std.Options = .{
44 }; 45 };
45 46
46 const Core = struct { 47 const Core = struct {
47 eng: *Engine, 48 grid: *Grid,
48 rep: Replica, 49 rep: Replica,
49 client: client_core.ClientCore = .{}, 50 client: client_core.ClientCore = .{},
50 /// Borrows from input_buf. It is valid only until the host next stages 51 /// Borrows from input_buf. It is valid only until the host next stages
@@ -58,7 +59,7 @@ const Core = struct {
58 .history_rows = 0, 59 .history_rows = 0,
59 .text = &.{}, 60 .text = &.{},
60 }, 61 },
61 /// Grid the readout buffers are sized for; follows rep.grid. 62 /// Geometry the readout buffers are sized for; follows the replica's grid.
62 cols: u16, 63 cols: u16,
63 rows: u16, 64 rows: u16,
64 /// Per-row damage since the last mux_read_viewport. Snapshots and 65 /// Per-row damage since the last mux_read_viewport. Snapshots and
@@ -71,9 +72,9 @@ const Core = struct {
71 /// Packed cells, 4 u32 per cell (see mux_viewport_ptr). 72 /// Packed cells, 4 u32 per cell (see mux_viewport_ptr).
72 viewport: []u32, 73 viewport: []u32,
73 cursor_row: u16 = 0, 74 cursor_row: u16 = 0,
74 /// Scrollback view: a scratch terminal the host feeds fetched history 75 /// Scrollback view: the rows of the last fetched history chunk, decoded.
75 /// rows into. Never touches the live replica. 76 /// Never touches the live replica.
76 scroll_eng: ?*Engine = null, 77 scroll_rows: ?[]grid_mod.Row = null,
77 }; 78 };
78 79
79 var core: ?*Core = null; 80 var core: ?*Core = null;
@@ -109,14 +110,19 @@ fn clearBorrowedInputResults(c: *Core) void {
109 // --------------------------------------------------------------------- 110 // ---------------------------------------------------------------------
110 111
111 fn teardown(c: *Core) void { 112 fn teardown(c: *Core) void {
112 if (c.scroll_eng) |se| se.deinit(); 113 freeScrollRows(c);
113 alloc.free(c.viewport); 114 alloc.free(c.viewport);
114 alloc.free(c.dirty_list); 115 alloc.free(c.dirty_list);
115 alloc.free(c.dirty); 116 alloc.free(c.dirty);
116 c.eng.deinit(); 117 c.grid.deinit();
117 alloc.destroy(c); 118 alloc.destroy(c);
118 } 119 }
119 120
121 fn freeScrollRows(c: *Core) void {
122 if (c.scroll_rows) |rows| grid_mod.freeRows(alloc, rows);
123 c.scroll_rows = null;
124 }
125
120 fn allocGridBufs(c: *Core, cols: u16, rows: u16) !void { 126 fn allocGridBufs(c: *Core, cols: u16, rows: u16) !void {
121 const cells = @as(usize, cols) * rows; 127 const cells = @as(usize, cols) * rows;
122 const viewport = try alloc.alloc(u32, cells * 4); 128 const viewport = try alloc.alloc(u32, cells * 4);
@@ -142,7 +148,7 @@ export fn mux_init(cols: u32, rows: u32) i32 {
142 148
143 const c = alloc.create(Core) catch return -1; 149 const c = alloc.create(Core) catch return -1;
144 c.* = .{ 150 c.* = .{
145 .eng = undefined, 151 .grid = undefined,
146 .rep = undefined, 152 .rep = undefined,
147 .cols = 0, 153 .cols = 0,
148 .rows = 0, 154 .rows = 0,
@@ -150,16 +156,13 @@ export fn mux_init(cols: u32, rows: u32) i32 {
150 .dirty_list = &.{}, 156 .dirty_list = &.{},
151 .viewport = &.{}, 157 .viewport = &.{},
152 }; 158 };
153 c.eng = Engine.init(alloc, .{ 159 c.grid = Grid.init(alloc, @intCast(cols), @intCast(rows)) catch {
154 .cols = @intCast(cols),
155 .rows = @intCast(rows),
156 }) catch {
157 alloc.destroy(c); 160 alloc.destroy(c);
158 return -2; 161 return -2;
159 }; 162 };
160 c.rep = Replica.init(alloc, c.eng); 163 c.rep = Replica.init(alloc, c.grid);
161 allocGridBufs(c, @intCast(cols), @intCast(rows)) catch { 164 allocGridBufs(c, @intCast(cols), @intCast(rows)) catch {
162 c.eng.deinit(); 165 c.grid.deinit();
163 alloc.destroy(c); 166 alloc.destroy(c);
164 return -1; 167 return -1;
165 }; 168 };
@@ -198,18 +201,13 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
198 if (t != .snapshot and t != .delta) return -3; 201 if (t != .snapshot and t != .delta) return -3;
199 const payload = input_buf[0..len]; 202 const payload = input_buf[0..len];
200 203
201 const cursor_before = c.rep.eng.cursorPos(); 204 const cursor_before = c.rep.grid.cursor;
202 const applied = c.rep.apply(t, payload) catch |err| switch (err) { 205 const applied = c.rep.apply(t, payload) catch |err| switch (err) {
203 // A short snapshot proves nothing and paints nothing. 206 // A snapshot that will not decode proves nothing and paints nothing.
204 error.BadPayload => return -3, 207 error.BadPayload => return -3,
205 // Engine resize failure: the grid the daemon named is beyond us. 208 // Allocation failure, or a grid the daemon named that is beyond us.
206 else => return -3, 209 else => return -3,
207 }; 210 };
208 // Drain and DROP: replaying can make the replica answer for itself, but the
209 // daemon already answers the application — forwarding would double every
210 // reply. Unread they accumulate with no bound, so each engine drains where
211 // it feeds.
212 c.rep.eng.clearPtyOutput();
213 if (applied == .resync) return 1; 211 if (applied == .resync) return 1;
214 212
215 // Damage bookkeeping. 213 // Damage bookkeeping.
@@ -240,7 +238,7 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
240 // The renderer draws the cursor; both its old and new rows 238 // The renderer draws the cursor; both its old and new rows
241 // need repainting even when no content there changed. 239 // need repainting even when no content there changed.
242 if (cursor_before.y < c.rows) c.dirty[cursor_before.y] = true; 240 if (cursor_before.y < c.rows) c.dirty[cursor_before.y] = true;
243 const cur = c.rep.eng.cursorPos(); 241 const cur = c.rep.grid.cursor;
244 if (cur.y < c.rows) c.dirty[cur.y] = true; 242 if (cur.y < c.rows) c.dirty[cur.y] = true;
245 }, 243 },
246 else => unreachable, 244 else => unreachable,
@@ -405,12 +403,12 @@ export fn mux_rows() u32 {
405 403
406 export fn mux_cursor_x() u32 { 404 export fn mux_cursor_x() u32 {
407 const c = core orelse return 0; 405 const c = core orelse return 0;
408 return c.rep.eng.cursorPos().x; 406 return c.rep.grid.cursor.x;
409 } 407 }
410 408
411 export fn mux_cursor_y() u32 { 409 export fn mux_cursor_y() u32 {
412 const c = core orelse return 0; 410 const c = core orelse return 0;
413 return c.rep.eng.cursorPos().y; 411 return c.rep.grid.cursor.y;
414 } 412 }
415 413
416 export fn mux_history_rows() u32 { 414 export fn mux_history_rows() u32 {
@@ -512,7 +510,7 @@ export fn mux_read_viewport() u32 {
512 c.dirty_count = 0; 510 c.dirty_count = 0;
513 for (c.dirty, 0..) |d, y| { 511 for (c.dirty, 0..) |d, y| {
514 if (!d) continue; 512 if (!d) continue;
515 paintRow(c, c.eng, @intCast(y)); 513 paintRow(c, c.rep.grid, @intCast(y));
516 c.dirty_list[c.dirty_count] = @intCast(y); 514 c.dirty_list[c.dirty_count] = @intCast(y);
517 c.dirty_count += 1; 515 c.dirty_count += 1;
518 } 516 }
@@ -526,28 +524,36 @@ export fn mux_dirty_row(i: u32) u32 {
526 return c.dirty_list[i]; 524 return c.dirty_list[i];
527 } 525 }
528 526
529 fn paintRow(c: *Core, eng: *Engine, y: u16) void { 527 fn paintRow(c: *Core, g: *const Grid, y: u16) void {
528 paintRowFrom(c, g.row(y), y);
529 }
530
531 /// One row into the readout buffer JS reads. The colours arrive packed the
532 /// way the page already decodes them, so nothing is repacked here.
533 fn paintRowFrom(c: *Core, r: *const grid_mod.Row, y: u16) void {
530 var x: u16 = 0; 534 var x: u16 = 0;
531 while (x < c.cols) : (x += 1) { 535 while (x < c.cols) : (x += 1) {
532 const base = (@as(usize, y) * c.cols + x) * 4; 536 const base = (@as(usize, y) * c.cols + x) * 4;
533 const cell = eng.term.screens.active.pages.getCell(.{ .viewport = .{ 537 if (x >= r.cells.len) {
534 .x = @intCast(x),
535 .y = @intCast(y),
536 } }) orelse {
537 c.viewport[base] = 0; 538 c.viewport[base] = 0;
538 c.viewport[base + 1] = 0; 539 c.viewport[base + 1] = 0;
539 c.viewport[base + 2] = 0; 540 c.viewport[base + 2] = 0;
540 c.viewport[base + 3] = 0; 541 c.viewport[base + 3] = 0;
541 continue; 542 continue;
543 }
544 const cell = r.cells[x];
545 // The first codepoint of the cell's text; a cell that carries none
546 // is a blank, which the page draws as an empty cell.
547 c.viewport[base] = cp: {
548 if (cell.text_len == 0) break :cp 0;
549 const text = r.textOf(cell);
550 const len = std.unicode.utf8ByteSequenceLength(text[0]) catch break :cp 0;
551 if (len > text.len) break :cp 0;
552 break :cp std.unicode.utf8Decode(text[0..len]) catch 0;
542 }; 553 };
543 c.viewport[base] = switch (cell.cell.content_tag) { 554 c.viewport[base + 1] = cell.style.fg;
544 .codepoint, .codepoint_grapheme => cell.cell.content.codepoint, 555 c.viewport[base + 2] = cell.style.bg;
545 .bg_color_palette, .bg_color_rgb => 0, 556 const wide: u32 = switch (cell.wide) {
546 };
547 const style = cell.style();
548 c.viewport[base + 1] = packColor(style.fg_color);
549 c.viewport[base + 2] = packColor(style.bg_color);
550 const wide: u32 = switch (cell.cell.wide) {
551 .narrow => 0, 557 .narrow => 0,
552 .wide => 1 << 16, 558 .wide => 1 << 16,
553 .spacer_tail, .spacer_head => 1 << 17, 559 .spacer_tail, .spacer_head => 1 << 17,
@@ -556,50 +562,28 @@ fn paintRow(c: *Core, eng: *Engine, y: u16) void {
556 // 1 italic, 2 faint, 3 blink, 4 inverse, 5 invisible, 562 // 1 italic, 2 faint, 3 blink, 4 inverse, 5 invisible,
557 // 6 strikethrough, 7 overline, bits 8-10 underline style), then 563 // 6 strikethrough, 7 overline, bits 8-10 underline style), then
558 // wide << 16 and spacer << 17 from the switch above. 564 // wide << 16 and spacer << 17 from the switch above.
559 c.viewport[base + 3] = @as(u32, @as(u16, @bitCast(style.flags))) | wide; 565 c.viewport[base + 3] = @as(u32, cell.style.flags) | wide;
560 } 566 }
561 } 567 }
562 568
563 fn packColor(col: anytype) u32 {
564 return switch (col) {
565 .none => 0,
566 .palette => |p| (1 << 24) | @as(u32, p),
567 .rgb => |rgb| (2 << 24) |
568 (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | @as(u32, rgb.b),
569 };
570 }
571
572 // --------------------------------------------------------------------- 569 // ---------------------------------------------------------------------
573 // Scrollback view (a scratch terminal; the live replica is never touched) 570 // Scrollback view (a scratch terminal; the live replica is never touched)
574 // --------------------------------------------------------------------- 571 // ---------------------------------------------------------------------
575 572
576 /// Feed `len` staged bytes (a scrollback_chunk's rows, echo header 573 /// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed
577 /// already stripped by the host) into the scratch terminal, resetting it 574 /// header included, because the row count lives in that header and the rows
578 /// first. The scratch is created lazily at the live grid size and follows 575 /// are only self-delimiting once you know how many there are. Replaces
579 /// it. Returns 0, -1 uninit, -2 overflow. 576 /// whatever the last chunk left. Returns 0, -1 uninit or undecodable,
577 /// -2 overflow.
580 export fn mux_scroll_feed(len: u32) i32 { 578 export fn mux_scroll_feed(len: u32) i32 {
581 const c = core orelse return -1; 579 const c = core orelse return -1;
582 clearBorrowedInputResults(c); 580 clearBorrowedInputResults(c);
583 if (len > input_buf.len) return -2; 581 if (len > input_buf.len) return -2;
584 if (c.scroll_eng) |se| { 582 if (len < 6) return -1;
585 if (se.term.cols != c.cols or se.term.rows != c.rows) { 583 const count = std.mem.readInt(u16, input_buf[4..6], .little);
586 se.deinit(); 584 const rows = grid_mod.decodeRows(alloc, input_buf[6..len], count, c.cols) catch return -1;
587 c.scroll_eng = null; 585 freeScrollRows(c);
588 } 586 c.scroll_rows = rows;
589 }
590 if (c.scroll_eng == null) {
591 c.scroll_eng = Engine.init(alloc, .{
592 .cols = c.cols,
593 .rows = c.rows,
594 .max_scrollback = 0,
595 }) catch return -1;
596 }
597 const se = c.scroll_eng.?;
598 se.reset();
599 se.feed(input_buf[0..len]);
600 // Same drain rule as mux_apply_frame: reset() is a fullReset of the
601 // terminal, not of Engine.pty_out, and history rows can answer too.
602 se.clearPtyOutput();
603 return 0; 587 return 0;
604 } 588 }
605 589
@@ -608,13 +592,20 @@ export fn mux_scroll_feed(len: u32) i32 {
608 /// repaint from the replica). Returns rows painted. 592 /// repaint from the replica). Returns rows painted.
609 export fn mux_read_scroll_viewport() u32 { 593 export fn mux_read_scroll_viewport() u32 {
610 const c = core orelse return 0; 594 const c = core orelse return 0;
611 const se = c.scroll_eng orelse return 0; 595 const rows = c.scroll_rows orelse return 0;
612 var y: u16 = 0; 596 var y: u16 = 0;
613 while (y < c.rows) : (y += 1) paintRow(c, se, y); 597 // A chunk shorter than the viewport is a page near the top of history;
598 // the rows past it are blank rather than whatever the live grid holds.
599 while (y < c.rows) : (y += 1) {
600 if (y < rows.len) paintRowFrom(c, &rows[y], y) else paintRowFrom(c, &blank_row, y);
601 }
614 @memset(c.dirty, true); 602 @memset(c.dirty, true);
615 return c.rows; 603 return c.rows;
616 } 604 }
617 605
606 /// A row with no cells at all: every column past its end reads as blank.
607 const blank_row: grid_mod.Row = .{ .cells = &.{} };
608
618 // --------------------------------------------------------------------- 609 // ---------------------------------------------------------------------
619 // Diagnostics 610 // Diagnostics
620 // --------------------------------------------------------------------- 611 // ---------------------------------------------------------------------
@@ -627,12 +618,13 @@ export fn mux_output_len() u32 {
627 return output_len; 618 return output_len;
628 } 619 }
629 620
630 /// Plain-text dump of the live viewport (verify.js's referee; matches 621 /// Plain-text dump of the live viewport (verify.js's referee). The same text
631 /// Engine.dumpPlain, the same text mux d dump prints). 622 /// `mux d dump` prints, save for a row's trailing spaces: a client holds none
623 /// and never did, and `Grid.dumpPlain` says why.
632 export fn mux_dump_plain() i32 { 624 export fn mux_dump_plain() i32 {
633 const c = core orelse return -1; 625 const c = core orelse return -1;
634 output_len = 0; 626 output_len = 0;
635 const text = c.rep.eng.dumpPlain(alloc) catch return -2; 627 const text = c.rep.grid.dumpPlain(alloc) catch return -2;
636 defer alloc.free(text); 628 defer alloc.free(text);
637 if (text.len > output_buf.len) return -3; 629 if (text.len > output_buf.len) return -3;
638 @memcpy(output_buf[0..text.len], text); 630 @memcpy(output_buf[0..text.len], text);
src/tui/interact.zig
Old New
@@ -12,7 +12,8 @@
12 //! and `replica.zig` is the one applier. 12 //! and `replica.zig` is the one applier.
13 13
14 const std = @import("std"); 14 const std = @import("std");
15 const Engine = @import("term").engine.Engine; 15 const grid_mod = @import("term").grid;
16 const Grid = grid_mod.Grid;
16 const Replica = @import("term").replica.Replica; 17 const Replica = @import("term").replica.Replica;
17 const proto = @import("term").protocol; 18 const proto = @import("term").protocol;
18 const predict = @import("predict.zig"); 19 const predict = @import("predict.zig");
@@ -868,11 +869,11 @@ fn writeSideChannel(
868 /// What the replica shows at one cell — the `prev_ch` a prediction is judged 869 /// What the replica shows at one cell — the `prev_ch` a prediction is judged
869 /// against later. Read at the PREDICTED cursor, not the replica's own: 870 /// against later. Read at the PREDICTED cursor, not the replica's own:
870 /// mid-burst the wrong one turns "not answered yet" into "contradicted". 871 /// mid-burst the wrong one turns "not answered yet" into "contradicted".
871 fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { 872 fn replicaCellChar(alloc: std.mem.Allocator, replica: *const Grid, at: predict.CursorPos) u8 {
872 const plain = replica.dumpPlain(alloc) catch return ' '; 873 const plain = replica.dumpPlain(alloc) catch return ' ';
873 defer alloc.free(plain); 874 defer alloc.free(plain);
874 const grid: predict.PlainGrid = .{ .text = plain, .cols = @intCast(replica.term.cols) }; 875 const text: predict.PlainGrid = .{ .text = plain, .cols = replica.cols };
875 return grid.cellChar(at.y, at.x) orelse ' '; 876 return text.cellChar(at.y, at.x) orelse ' ';
876 } 877 }
877 878
878 /// Judge the overlay against the replica as it stands — after the 879 /// Judge the overlay against the replica as it stands — after the
@@ -880,14 +881,14 @@ fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.Curso
880 fn reconcileOverlay( 881 fn reconcileOverlay(
881 alloc: std.mem.Allocator, 882 alloc: std.mem.Allocator,
882 overlay: *predict.Overlay, 883 overlay: *predict.Overlay,
883 replica: *Engine, 884 replica: *const Grid,
884 seq: u64, 885 seq: u64,
885 now_ms: i64, 886 now_ms: i64,
886 ) predict.Verdict { 887 ) predict.Verdict {
887 const plain = replica.dumpPlain(alloc) catch return .none; 888 const plain = replica.dumpPlain(alloc) catch return .none;
888 defer alloc.free(plain); 889 defer alloc.free(plain);
889 return overlay.reconcile( 890 return overlay.reconcile(
890 predict.PlainGrid{ .text = plain, .cols = @intCast(replica.term.cols) }, 891 predict.PlainGrid{ .text = plain, .cols = replica.cols },
891 seq, 892 seq,
892 now_ms, 893 now_ms,
893 ); 894 );
@@ -898,7 +899,7 @@ fn reconcileOverlay(
898 fn paintOverlay( 899 fn paintOverlay(
899 alloc: std.mem.Allocator, 900 alloc: std.mem.Allocator,
900 overlay: *predict.Overlay, 901 overlay: *predict.Overlay,
901 base: Engine.CursorPos, 902 base: grid_mod.CursorPos,
902 vp: paint_mod.Viewport, 903 vp: paint_mod.Viewport,
903 out_fd: std.posix.fd_t, 904 out_fd: std.posix.fd_t,
904 ) void { 905 ) void {
@@ -943,7 +944,7 @@ fn paintOverlay(
943 fn offerKeystroke( 944 fn offerKeystroke(
944 alloc: std.mem.Allocator, 945 alloc: std.mem.Allocator,
945 overlay: *predict.Overlay, 946 overlay: *predict.Overlay,
946 replica: *Engine, 947 replica: *const Grid,
947 chunk: []const u8, 948 chunk: []const u8,
948 vp: paint_mod.Viewport, 949 vp: paint_mod.Viewport,
949 out_fd: std.posix.fd_t, 950 out_fd: std.posix.fd_t,
@@ -956,7 +957,7 @@ fn offerKeystroke(
956 return; 957 return;
957 } 958 }
958 959
959 const base = replica.cursorPos(); 960 const base = replica.cursor;
960 const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); 961 const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
961 const out = overlay.predictAt(.{ 962 const out = overlay.predictAt(.{
962 .cursor = at, 963 .cursor = at,
@@ -1134,7 +1135,7 @@ pub const Claim = enum { none, session };
1134 /// 1135 ///
1135 /// The driver owns the transport and the loop; the Core owns each event: 1136 /// The driver owns the transport and the loop; the Core owns each event:
1136 /// 1137 ///
1137 /// * `initSized` / `deinit` — the Core owns its Engine, its overlay and 1138 /// * `initSized` / `deinit` — the Core owns its Grid, its overlay and
1138 /// whatever terminal claim it still holds, and puts all three back. 1139 /// whatever terminal claim it still holds, and puts all three back.
1139 /// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS 1140 /// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS
1140 /// while focused. Raw mode and SIGWINCH belong to the driver. 1141 /// while focused. Raw mode and SIGWINCH belong to the driver.
@@ -1144,7 +1145,7 @@ pub const Claim = enum { none, session };
1144 /// calls `forward` with the bytes that were not a chord. 1145 /// calls `forward` with the bytes that were not a chord.
1145 /// * around a reconnect: `dropScrollView` before, `reattached` after. 1146 /// * around a reconnect: `dropScrollView` before, `reattached` after.
1146 /// 1147 ///
1147 /// It depends on an Engine, a Replica, the overlay, the painter and the 1148 /// It depends on a Grid, a Replica, the overlay, the painter and the
1148 /// shared decoder — never a transport type. Nothing here is a singleton: a 1149 /// shared decoder — never a transport type. Nothing here is a singleton: a
1149 /// tile brings its own Core, so there is never a second applier for one tile. 1150 /// tile brings its own Core, so there is never a second applier for one tile.
1150 pub const Core = struct { 1151 pub const Core = struct {
@@ -1182,7 +1183,7 @@ pub const Core = struct {
1182 owns_screen: bool = true, 1183 owns_screen: bool = true,
1183 /// The replay core (replica.zig). Public because the driver reads it: a 1184 /// The replay core (replica.zig). Public because the driver reads it: a
1184 /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach` 1185 /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach`
1185 /// tells a refusal from a shell exiting. The Core owns the Engine. 1186 /// tells a refusal from a shell exiting. The Core owns the Grid.
1186 rep: Replica, 1187 rep: Replica,
1187 /// Speculative echo. Born `.never` and stays there until a daemon tells 1188 /// Speculative echo. Born `.never` and stays there until a daemon tells
1188 /// it otherwise, so an old daemon that has never heard of pty_mode gets 1189 /// it otherwise, so an old daemon that has never heard of pty_mode gets
@@ -1241,7 +1242,7 @@ pub const Core = struct {
1241 /// an origin only comes off a coordinate and a clamp only lowers it. 1242 /// an origin only comes off a coordinate and a clamp only lowers it.
1242 mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined, 1243 mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
1243 1244
1244 /// Born at a size the driver measured: the Engine has to be born at 1245 /// Born at a size the driver measured: the grid has to be born at
1245 /// the size the first paint clips to. 1246 /// the size the first paint clips to.
1246 pub fn initSized( 1247 pub fn initSized(
1247 alloc: std.mem.Allocator, 1248 alloc: std.mem.Allocator,
@@ -1252,14 +1253,14 @@ pub const Core = struct {
1252 // The driver's layout was cut from ONE reading of the terminal, so 1253 // The driver's layout was cut from ONE reading of the terminal, so
1253 // a second ioctl here would clip a tile to rows the wall 1254 // a second ioctl here would clip a tile to rows the wall
1254 // does not believe in. 1255 // does not believe in.
1255 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); 1256 const g = try Grid.init(alloc, size.cols, size.rows);
1256 return .{ 1257 return .{
1257 .alloc = alloc, 1258 .alloc = alloc,
1258 .in_fd = in_fd, 1259 .in_fd = in_fd,
1259 .out_fd = out_fd, 1260 .out_fd = out_fd,
1260 .is_tty = std.posix.isatty(in_fd), 1261 .is_tty = std.posix.isatty(in_fd),
1261 .size = size, 1262 .size = size,
1262 .rep = Replica.init(alloc, eng), 1263 .rep = Replica.init(alloc, g),
1263 .overlay = predict.Overlay.init(alloc, size.cols, size.rows), 1264 .overlay = predict.Overlay.init(alloc, size.cols, size.rows),
1264 }; 1265 };
1265 } 1266 }
@@ -1275,7 +1276,7 @@ pub const Core = struct {
1275 self.releaseTerminal(.write); 1276 self.releaseTerminal(.write);
1276 if (self.owns_stats) dumpPredictStats(self.overlay.counters); 1277 if (self.owns_stats) dumpPredictStats(self.overlay.counters);
1277 self.overlay.deinit(); 1278 self.overlay.deinit();
1278 self.rep.eng.deinit(); 1279 self.rep.grid.deinit();
1279 } 1280 }
1280 1281
1281 /// A pump calls this before claiming, to follow a resize. 1282 /// A pump calls this before claiming, to follow a resize.
@@ -1336,9 +1337,9 @@ pub const Core = struct {
1336 } 1337 }
1337 } 1338 }
1338 1339
1339 /// The engine this Core's replica paints from. 1340 /// The grid this Core's replica paints from.
1340 pub fn grid(self: *Core) *Engine { 1341 pub fn grid(self: *Core) *Grid {
1341 return self.rep.eng; 1342 return self.rep.grid;
1342 } 1343 }
1343 1344
1344 /// Returned BY VALUE and kept on the caller's stack for the length of 1345 /// Returned BY VALUE and kept on the caller's stack for the length of
@@ -1375,16 +1376,16 @@ pub const Core = struct {
1375 if (!self.beginPaint()) return; 1376 if (!self.beginPaint()) return;
1376 defer self.endPaint(); 1377 defer self.endPaint();
1377 const hl = self.highlight(); 1378 const hl = self.highlight();
1378 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd); 1379 try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
1379 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.viewport(), self.out_fd); 1380 paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
1380 } 1381 }
1381 1382
1382 /// The terminal cursor the painters end on — the same clamp-then-offset 1383 /// The terminal cursor the painters end on — the same clamp-then-offset
1383 /// math. An overlay may park ahead; the replica cursor is the 1384 /// math. An overlay may park ahead; the replica cursor is the
1384 /// approximation. 1385 /// approximation.
1385 pub fn screenCursor(self: *Core) Engine.CursorPos { 1386 pub fn screenCursor(self: *Core) grid_mod.CursorPos {
1386 const vp = self.viewport(); 1387 const vp = self.viewport();
1387 const c = paint_mod.clampCursor(self.rep.eng.cursorPos(), vp); 1388 const c = paint_mod.clampCursor(self.rep.grid.cursor, vp);
1388 return .{ .x = c.x + vp.left, .y = c.y + vp.top }; 1389 return .{ .x = c.x + vp.left, .y = c.y + vp.top };
1389 } 1390 }
1390 1391
@@ -1394,15 +1395,15 @@ pub const Core = struct {
1394 /// highlight with a hole in it. Painting the whole screen instead cost 1395 /// highlight with a hole in it. Painting the whole screen instead cost
1395 /// 4.8 KB a cell at 120x40. The overlay goes back on top, as in `repaint`. 1396 /// 4.8 KB a cell at 120x40. The overlay goes back on top, as in `repaint`.
1396 fn paintDragChange(self: *Core, was: ?select.Range) !void { 1397 fn paintDragChange(self: *Core, was: ?select.Range) !void {
1397 // Scroll mode owns the screen: `renderScrollback` blits VT bytes with 1398 // Scroll mode owns the screen: `renderScrollback` paints rows this
1398 // no engine behind them, so a row from the live replica lands on a 1399 // Core does not hold, so a row from the live replica would land on a
1399 // page this Core cannot address. Leaving scroll mode repaints in full. 1400 // page it cannot address. Leaving scroll mode repaints in full.
1400 if (self.scroll_rows > 0) return; 1401 if (self.scroll_rows > 0) return;
1401 const now = self.drag.range(); 1402 const now = self.drag.range();
1402 if (std.meta.eql(was, now)) return; 1403 if (std.meta.eql(was, now)) return;
1403 1404
1404 const cols: u16 = @intCast(self.rep.eng.term.cols); 1405 const cols: u16 = @intCast(self.rep.grid.cols);
1405 const grid_rows: u16 = @intCast(self.rep.eng.term.rows); 1406 const grid_rows: u16 = @intCast(self.rep.grid.rows);
1406 const limit = @min(grid_rows, self.size.rows); 1407 const limit = @min(grid_rows, self.size.rows);
1407 var rows: std.ArrayList(u16) = .empty; 1408 var rows: std.ArrayList(u16) = .empty;
1408 defer rows.deinit(self.alloc); 1409 defer rows.deinit(self.alloc);
@@ -1420,13 +1421,13 @@ pub const Core = struct {
1420 const hl = self.highlight(); 1421 const hl = self.highlight();
1421 try paint_mod.renderRowsClipped( 1422 try paint_mod.renderRowsClipped(
1422 self.alloc, 1423 self.alloc,
1423 self.rep.eng, 1424 self.rep.grid,
1424 self.viewport(), 1425 self.viewport(),
1425 hl.sink(), 1426 hl.sink(),
1426 rows.items, 1427 rows.items,
1427 self.out_fd, 1428 self.out_fd,
1428 ); 1429 );
1429 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.viewport(), self.out_fd); 1430 paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
1430 } 1431 }
1431 1432
1432 /// The whole screen from the replica, with nothing put back on top — 1433 /// The whole screen from the replica, with nothing put back on top —
@@ -1435,7 +1436,7 @@ pub const Core = struct {
1435 if (!self.beginPaint()) return; 1436 if (!self.beginPaint()) return;
1436 defer self.endPaint(); 1437 defer self.endPaint();
1437 const hl = self.highlight(); 1438 const hl = self.highlight();
1438 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd); 1439 try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
1439 } 1440 }
1440 1441
1441 /// A one-line marker in the corner, painted over by the next full 1442 /// A one-line marker in the corner, painted over by the next full
@@ -1479,7 +1480,7 @@ pub const Core = struct {
1479 const verdict = reconcileOverlay( 1480 const verdict = reconcileOverlay(
1480 self.alloc, 1481 self.alloc,
1481 &self.overlay, 1482 &self.overlay,
1482 self.rep.eng, 1483 self.rep.grid,
1483 self.rep.last_seq, 1484 self.rep.last_seq,
1484 std.time.milliTimestamp(), 1485 std.time.milliTimestamp(),
1485 ); 1486 );
@@ -1495,16 +1496,16 @@ pub const Core = struct {
1495 // certainly right. Painted raw, not through `paintFull`: this 1496 // certainly right. Painted raw, not through `paintFull`: this
1496 // and the overlay below are ONE hold of a non-reentrant sink. 1497 // and the overlay below are ONE hold of a non-reentrant sink.
1497 const hl = self.highlight(); 1498 const hl = self.highlight();
1498 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd); 1499 try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
1499 self.repaint_after_resync = false; 1500 self.repaint_after_resync = false;
1500 } else { 1501 } else {
1501 const hl = self.highlight(); 1502 const hl = self.highlight();
1502 try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.eng, self.viewport(), hl.sink(), self.out_fd); 1503 try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.grid, self.viewport(), hl.sink(), self.out_fd);
1503 } 1504 }
1504 // Last, and after either paint: the rows the daemon just sent 1505 // Last, and after either paint: the rows the daemon just sent
1505 // have overwritten anything drawn on them, including predictions 1506 // have overwritten anything drawn on them, including predictions
1506 // that are still outstanding. 1507 // that are still outstanding.
1507 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.viewport(), self.out_fd); 1508 paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
1508 } 1509 }
1509 } 1510 }
1510 1511
@@ -1526,11 +1527,17 @@ pub const Core = struct {
1526 /// painted over a screen it no longer describes. 1527 /// painted over a screen it no longer describes.
1527 fn scrollbackPage(self: *Core, payload: []const u8) !Pass { 1528 fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
1528 // 6 = the `scrollback_chunk` header (u32 start, u16 count); the 1529 // 6 = the `scrollback_chunk` header (u32 start, u16 count); the
1529 // rows follow it. Short of that there is nothing to render. 1530 // CellRows follow it. Short of that there is nothing to render.
1530 if (self.scroll_rows == 0 or payload.len < 6) return .skip; 1531 if (self.scroll_rows == 0 or payload.len < 6) return .skip;
1532 const count = std.mem.readInt(u16, payload[4..6], .little);
1533 // The daemon encoded these at ITS grid width, which is the width the
1534 // live grid holds too: a client never asks for history at a size it
1535 // did not attach at.
1536 const rows = grid_mod.decodeRows(self.alloc, payload[6..], count, self.rep.grid.cols) catch return .skip;
1537 defer grid_mod.freeRows(self.alloc, rows);
1531 if (!self.beginPaint()) return .carry_on; 1538 if (!self.beginPaint()) return .carry_on;
1532 defer self.endPaint(); 1539 defer self.endPaint();
1533 try paint_mod.renderScrollback(self.alloc, payload[6..], self.viewport(), @intCast(self.rep.eng.term.cols), self.owns_screen, self.out_fd); 1540 try paint_mod.renderScrollback(self.alloc, rows, self.rep.grid.cols, self.viewport(), self.owns_screen, self.out_fd);
1534 return .carry_on; 1541 return .carry_on;
1535 } 1542 }
1536 1543
@@ -1813,7 +1820,7 @@ pub const Core = struct {
1813 // at it — but an overlay glyph would be graffiti on the new holder. 1820 // at it — but an overlay glyph would be graffiti on the new holder.
1814 if (self.beginPaint()) { 1821 if (self.beginPaint()) {
1815 defer self.endPaint(); 1822 defer self.endPaint();
1816 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.viewport(), self.out_fd); 1823 offerKeystroke(self.alloc, &self.overlay, self.rep.grid, keys, self.viewport(), self.out_fd);
1817 } 1824 }
1818 transport.writeFrame(.input, keys) catch return .lost; 1825 transport.writeFrame(.input, keys) catch return .lost;
1819 // These keystrokes are lost with the transport, by the same 1826 // These keystrokes are lost with the transport, by the same
@@ -1899,8 +1906,8 @@ pub const Core = struct {
1899 /// a button the hand released over a neighbour. The edge is what the 1906 /// a button the hand released over a neighbour. The edge is what the
1900 /// pane SHOWS, the smaller of the grid and the clip, like `hitTest`. 1907 /// pane SHOWS, the smaller of the grid and the clip, like `hitTest`.
1901 fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 { 1908 fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 {
1902 const rows = @min(@as(u16, @intCast(self.rep.eng.term.rows)), self.size.rows); 1909 const rows = @min(@as(u16, @intCast(self.rep.grid.rows)), self.size.rows);
1903 const cols = @min(@as(u16, @intCast(self.rep.eng.term.cols)), self.size.cols); 1910 const cols = @min(@as(u16, @intCast(self.rep.grid.cols)), self.size.cols);
1904 var len: usize = 0; 1911 var len: usize = 0;
1905 var from: usize = 0; 1912 var from: usize = 0;
1906 for (m.events) |ev| { 1913 for (m.events) |ev| {
@@ -1936,8 +1943,8 @@ pub const Core = struct {
1936 if (ev.row < self.row_off or ev.col < self.col_off) return null; 1943 if (ev.row < self.row_off or ev.col < self.col_off) return null;
1937 const grow = ev.row - self.row_off; 1944 const grow = ev.row - self.row_off;
1938 const gcol = ev.col - self.col_off; 1945 const gcol = ev.col - self.col_off;
1939 const grid_rows: u16 = @intCast(self.rep.eng.term.rows); 1946 const grid_rows: u16 = @intCast(self.rep.grid.rows);
1940 const grid_cols: u16 = @intCast(self.rep.eng.term.cols); 1947 const grid_cols: u16 = @intCast(self.rep.grid.cols);
1941 // Rows past the grid (a terminal taller than the daemon's grid) 1948 // Rows past the grid (a terminal taller than the daemon's grid)
1942 // hold no session line: `renderClipped` never painted them. 1949 // hold no session line: `renderClipped` never painted them.
1943 if (grow >= @min(grid_rows, self.size.rows)) return null; 1950 if (grow >= @min(grid_rows, self.size.rows)) return null;
@@ -2978,6 +2985,57 @@ fn devNull() !std.posix.fd_t {
2978 /// to it, and both paths are walked. 2985 /// to it, and both paths are walked.
2979 const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }; 2986 const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
2980 2987
2988 /// Only the tests reach for an engine now: one authors the screens a client
2989 /// would be sent, and one plays the terminal the client paints onto.
2990 const Engine = @import("term").engine.Engine;
2991
2992 /// An engine and the grid it mirrors into. A test describes a screen in VT,
2993 /// and what the code under test reads is the cells — through the daemon's
2994 /// encoder and the replica's decoder, which is the only way a client grid is
2995 /// ever filled. Re-mirrored on every feed, so the two never drift.
2996 const AuthoredScreen = struct {
2997 eng: *Engine,
2998 grid: *Grid,
2999
3000 fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !AuthoredScreen {
3001 const g = try Grid.init(alloc, cols, rows);
3002 errdefer g.deinit();
3003 const e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
3004 return .{ .eng = e, .grid = g };
3005 }
3006
3007 fn feed(self: *AuthoredScreen, bytes: []const u8) void {
3008 self.eng.feed(bytes);
3009 self.eng.mirrorInto(self.grid) catch unreachable;
3010 }
3011
3012 fn deinit(self: *AuthoredScreen) void {
3013 self.eng.deinit();
3014 self.grid.deinit();
3015 }
3016 };
3017
3018 /// One CellRow of default-styled ASCII: the shape a delta row carries, for
3019 /// a test that hands a Core a frame rather than a screen.
3020 fn cellRow(alloc: std.mem.Allocator, text: []const u8) ![]u8 {
3021 var out: std.ArrayList(u8) = .empty;
3022 errdefer out.deinit(alloc);
3023 var w = try proto.CellRowWriter.begin(&out, alloc);
3024 errdefer w.deinit();
3025 for (text) |ch| try w.cell(.{}, .narrow, &[_]u8{ch});
3026 w.finish();
3027 return out.toOwnedSlice(alloc);
3028 }
3029
3030 /// Author a screen straight into a grid somebody else owns — a Core's
3031 /// replica, where a test wants the Core to hold content it never received.
3032 fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
3033 const e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
3034 defer e.deinit();
3035 e.feed(bytes);
3036 try e.mirrorInto(g);
3037 }
3038
2981 test "prediction: prev_ch is read at the predicted cursor, not the replica's" { 3039 test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
2982 const alloc = std.testing.allocator; 3040 const alloc = std.testing.allocator;
2983 const null_fd = try devNull(); 3041 const null_fd = try devNull();
@@ -2986,14 +3044,15 @@ test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
2986 // A real engine, fed real VT bytes — the one part of the prediction 3044 // A real engine, fed real VT bytes — the one part of the prediction
2987 // contract no test inside predict.zig can reach, because that module 3045 // contract no test inside predict.zig can reach, because that module
2988 // has never heard of an engine. 3046 // has never heard of an engine.
2989 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3047 var scr = try AuthoredScreen.init(alloc, 80, 24);
2990 defer replica.deinit(); 3048 defer scr.deinit();
3049 const replica = scr.grid;
2991 // Content with the cursor parked ON a character and a DIFFERENT 3050 // Content with the cursor parked ON a character and a DIFFERENT
2992 // character in the cell after it. That difference is the whole test: 3051 // character in the cell after it. That difference is the whole test:
2993 // with nothing pending the replica's cursor and the predicted one agree, 3052 // with nothing pending the replica's cursor and the predicted one agree,
2994 // and mid-burst they do not. 3053 // and mid-burst they do not.
2995 replica.feed("abcXY\x1b[1;4H"); 3054 scr.feed("abcXY\x1b[1;4H");
2996 try std.testing.expectEqual(@as(u16, 3), replica.cursorPos().x); 3055 try std.testing.expectEqual(@as(u16, 3), replica.cursor.x);
2997 3056
2998 var ov = predict.Overlay.init(alloc, 80, 24); 3057 var ov = predict.Overlay.init(alloc, 80, 24);
2999 defer ov.deinit(); 3058 defer ov.deinit();
@@ -3021,7 +3080,7 @@ test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
3021 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); 3080 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
3022 3081
3023 // And when the daemon does answer, they confirm against the real grid. 3082 // And when the daemon does answer, they confirm against the real grid.
3024 replica.feed("\x1b[1;4Hde"); 3083 scr.feed("\x1b[1;4Hde");
3025 try std.testing.expectEqual( 3084 try std.testing.expectEqual(
3026 predict.Verdict.confirmed, 3085 predict.Verdict.confirmed,
3027 reconcileOverlay(alloc, &ov, replica, 3, 0), 3086 reconcileOverlay(alloc, &ov, replica, 3, 0),
@@ -3035,8 +3094,9 @@ test "prediction: a burst advances the predicted cursor one cell per keystroke"
3035 const null_fd = try devNull(); 3094 const null_fd = try devNull();
3036 defer std.posix.close(null_fd); 3095 defer std.posix.close(null_fd);
3037 3096
3038 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3097 var scr = try AuthoredScreen.init(alloc, 80, 24);
3039 defer replica.deinit(); 3098 defer scr.deinit();
3099 const replica = scr.grid;
3040 3100
3041 var ov = predict.Overlay.init(alloc, 80, 24); 3101 var ov = predict.Overlay.init(alloc, 80, 24);
3042 defer ov.deinit(); 3102 defer ov.deinit();
@@ -3046,7 +3106,7 @@ test "prediction: a burst advances the predicted cursor one cell per keystroke"
3046 3106
3047 // The replica's own cursor has not moved — the daemon has answered 3107 // The replica's own cursor has not moved — the daemon has answered
3048 // nothing — so every one of these came from the overlay. 3108 // nothing — so every one of these came from the overlay.
3049 try std.testing.expectEqual(@as(u16, 0), replica.cursorPos().x); 3109 try std.testing.expectEqual(@as(u16, 0), replica.cursor.x);
3050 try std.testing.expectEqual(@as(usize, 5), ov.pendingCount()); 3110 try std.testing.expectEqual(@as(usize, 5), ov.pendingCount());
3051 for ("hello", 0..) |ch, i| { 3111 for ("hello", 0..) |ch, i| {
3052 const p = ov.pendingAt(i); 3112 const p = ov.pendingAt(i);
@@ -3065,9 +3125,10 @@ test "prediction paints underlined, and parks the cursor past what it drew" {
3065 const p = try std.posix.pipe(); 3125 const p = try std.posix.pipe();
3066 defer std.posix.close(p[0]); 3126 defer std.posix.close(p[0]);
3067 3127
3068 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3128 var scr = try AuthoredScreen.init(alloc, 80, 24);
3069 defer replica.deinit(); 3129 defer scr.deinit();
3070 replica.feed("\x1b[1;4H"); // cursor at column 3 (0-based) 3130 const replica = scr.grid;
3131 scr.feed("\x1b[1;4H"); // cursor at column 3 (0-based)
3071 3132
3072 var ov = predict.Overlay.init(alloc, 80, 24); 3133 var ov = predict.Overlay.init(alloc, 80, 24);
3073 defer ov.deinit(); 3134 defer ov.deinit();
@@ -3103,8 +3164,9 @@ test "a keystroke's prediction lands in the typist's own tile" {
3103 const alloc = std.testing.allocator; 3164 const alloc = std.testing.allocator;
3104 const p = try std.posix.pipe(); 3165 const p = try std.posix.pipe();
3105 defer std.posix.close(p[0]); 3166 defer std.posix.close(p[0]);
3106 const replica = try Engine.init(alloc, .{ .cols = tile_cols, .rows = tile_rows }); 3167 var scr = try AuthoredScreen.init(alloc, tile_cols, tile_rows);
3107 defer replica.deinit(); 3168 defer scr.deinit();
3169 const replica = scr.grid;
3108 var ov = predict.Overlay.init(alloc, tile_cols, tile_rows); 3170 var ov = predict.Overlay.init(alloc, tile_cols, tile_rows);
3109 defer ov.deinit(); 3171 defer ov.deinit();
3110 ov.setMode(.{ .icanon = true, .echo = true }); 3172 ov.setMode(.{ .icanon = true, .echo = true });
@@ -3146,8 +3208,9 @@ test "prediction: nothing is drawn for a context that has not earned it" {
3146 const p = try std.posix.pipe(); 3208 const p = try std.posix.pipe();
3147 defer std.posix.close(p[0]); 3209 defer std.posix.close(p[0]);
3148 3210
3149 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3211 var scr = try AuthoredScreen.init(alloc, 80, 24);
3150 defer replica.deinit(); 3212 defer scr.deinit();
3213 const replica = scr.grid;
3151 3214
3152 var ov = predict.Overlay.init(alloc, 80, 24); 3215 var ov = predict.Overlay.init(alloc, 80, 24);
3153 defer ov.deinit(); 3216 defer ov.deinit();
@@ -3171,8 +3234,9 @@ test "prediction: a chunk that is not one printable byte is never speculated abo
3171 const null_fd = try devNull(); 3234 const null_fd = try devNull();
3172 defer std.posix.close(null_fd); 3235 defer std.posix.close(null_fd);
3173 3236
3174 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3237 var scr = try AuthoredScreen.init(alloc, 80, 24);
3175 defer replica.deinit(); 3238 defer scr.deinit();
3239 const replica = scr.grid;
3176 3240
3177 var ov = predict.Overlay.init(alloc, 80, 24); 3241 var ov = predict.Overlay.init(alloc, 80, 24);
3178 defer ov.deinit(); 3242 defer ov.deinit();
@@ -3208,8 +3272,9 @@ test "prediction: a repaint never reveals what was never shown" {
3208 const p = try std.posix.pipe(); 3272 const p = try std.posix.pipe();
3209 defer std.posix.close(p[0]); 3273 defer std.posix.close(p[0]);
3210 3274
3211 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3275 var scr = try AuthoredScreen.init(alloc, 80, 24);
3212 defer replica.deinit(); 3276 defer scr.deinit();
3277 const replica = scr.grid;
3213 const null_fd = try devNull(); 3278 const null_fd = try devNull();
3214 defer std.posix.close(null_fd); 3279 defer std.posix.close(null_fd);
3215 3280
@@ -3224,7 +3289,7 @@ test "prediction: a repaint never reveals what was never shown" {
3224 // Every authoritative paint re-lays the overlay on top, because a delta's 3289 // Every authoritative paint re-lays the overlay on top, because a delta's
3225 // row content wipes anything drawn over it — a second chance to show a 3290 // row content wipes anything drawn over it — a second chance to show a
3226 // prediction, so it asks the keystroke path's question and gets its answer. 3291 // prediction, so it asks the keystroke path's question and gets its answer.
3227 paintOverlay(alloc, &ov, replica.cursorPos(), full_vp, p[1]); 3292 paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
3228 std.posix.close(p[1]); 3293 std.posix.close(p[1]);
3229 3294
3230 var rbuf: [64]u8 = undefined; 3295 var rbuf: [64]u8 = undefined;
@@ -3238,8 +3303,9 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
3238 const null_fd = try devNull(); 3303 const null_fd = try devNull();
3239 defer std.posix.close(null_fd); 3304 defer std.posix.close(null_fd);
3240 3305
3241 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 3306 var scr = try AuthoredScreen.init(alloc, 80, 24);
3242 defer replica.deinit(); 3307 defer scr.deinit();
3308 const replica = scr.grid;
3243 3309
3244 var ov = predict.Overlay.init(alloc, 80, 24); 3310 var ov = predict.Overlay.init(alloc, 80, 24);
3245 defer ov.deinit(); 3311 defer ov.deinit();
@@ -3252,7 +3318,7 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
3252 3318
3253 // One confirm banked, one short of promotion. 3319 // One confirm banked, one short of promotion.
3254 offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd); 3320 offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd);
3255 replica.feed("a"); 3321 scr.feed("a");
3256 try std.testing.expectEqual( 3322 try std.testing.expectEqual(
3257 predict.Verdict.confirmed, 3323 predict.Verdict.confirmed,
3258 reconcileOverlay(alloc, &ov, replica, 1, later), 3324 reconcileOverlay(alloc, &ov, replica, 1, later),
@@ -3263,7 +3329,7 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
3263 offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd); 3329 offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd);
3264 offerKeystroke(alloc, &ov, replica, "c", full_vp, null_fd); 3330 offerKeystroke(alloc, &ov, replica, "c", full_vp, null_fd);
3265 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); 3331 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
3266 replica.feed("b"); 3332 scr.feed("b");
3267 try std.testing.expectEqual( 3333 try std.testing.expectEqual(
3268 predict.Verdict.confirmed, 3334 predict.Verdict.confirmed,
3269 reconcileOverlay(alloc, &ov, replica, 2, later), 3335 reconcileOverlay(alloc, &ov, replica, 2, later),
@@ -3277,7 +3343,7 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
3277 // The post-frame re-lay is where it reaches the screen — nobody typed 3343 // The post-frame re-lay is where it reaches the screen — nobody typed
3278 // anything to make that happen, so counting only at prediction time 3344 // anything to make that happen, so counting only at prediction time
3279 // would lose it. 3345 // would lose it.
3280 paintOverlay(alloc, &ov, replica.cursorPos(), full_vp, p[1]); 3346 paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
3281 std.posix.close(p[1]); 3347 std.posix.close(p[1]);
3282 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); 3348 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
3283 3349
@@ -3763,7 +3829,7 @@ fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
3763 // Row 4 carries wide cells, kept OFF the rows the column assertions use: 3829 // Row 4 carries wide cells, kept OFF the rows the column assertions use:
3764 // a wide glyph shifts every column right of it, so folding one into row 1 3830 // a wide glyph shifts every column right of it, so folding one into row 1
3765 // means re-deriving fifteen hand-checked numbers. 3831 // means re-deriving fifteen hand-checked numbers.
3766 core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x"); 3832 try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x");
3767 return core; 3833 return core;
3768 } 3834 }
3769 3835
@@ -3788,8 +3854,17 @@ test "interact: a drag at the focus tile inverts what it crossed, and a click do
3788 const painted = drainPipe(p[0], &buf); 3854 const painted = drainPipe(p[0], &buf);
3789 // Columns 2..6 of grid row 1, addressed by column and inverted: the 3855 // Columns 2..6 of grid row 1, addressed by column and inverted: the
3790 // span opens at column 3 (1-based) and the tail resumes at column 8. 3856 // span opens at column 3 (1-based) and the tail resumes at column 8.
3791 try std.testing.expect(std.mem.indexOf(u8, painted, comptime cha(3) ++ "\x1b[0m\x1b[7m") != null); 3857 // The head is plain, the span opens at column 3 (1-based) with a CHA and
3792 try std.testing.expect(std.mem.indexOf(u8, painted, comptime "\x1b[0m" ++ cha(8)) != null); 3858 // an inversion, and the row closes with a full reset so the inversion
3859 // cannot run on into whatever the terminal draws next. There is no tail
3860 // to resume: columns 2 to 6 are the last of `row-one`, and a row stops at
3861 // its last non-blank cell. The test below covers a selection that leaves
3862 // content after it.
3863 try std.testing.expect(std.mem.indexOf(
3864 u8,
3865 painted,
3866 comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-one\x1b[0m",
3867 ) != null);
3793 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m")); 3868 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
3794 // The neighbouring rows are not touched at all: only the row whose 3869 // The neighbouring rows are not touched at all: only the row whose
3795 // span changed is redrawn, so an untouched row keeps whatever it 3870 // span changed is redrawn, so an untouched row keeps whatever it
@@ -3818,6 +3893,34 @@ test "interact: a drag at the focus tile inverts what it crossed, and a click do
3818 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); 3893 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3819 } 3894 }
3820 3895
3896 test "interact: a selection that ends mid-row resumes the tail plain, at its own column" {
3897 // The three-piece shape: plain head, inverted span, plain tail. The tail
3898 // is what a substring search cannot see going missing — the cells are
3899 // painted either way, and only the CHA says the cursor went back to the
3900 // column they belong in rather than running on from the span.
3901 const alloc = std.testing.allocator;
3902 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3903 defer std.posix.close(p[0]);
3904 defer std.posix.close(p[1]);
3905 var core = try dragFixture(alloc, p[1]);
3906 defer core.deinit();
3907 var tr: NullTransport = .{};
3908 var buf: [8192]u8 = undefined;
3909 _ = drainPipe(p[0], &buf); // the claim
3910
3911 // Row 1 is `row-one`; the drag covers columns 2 to 4, leaving `n` and `e`
3912 // after it.
3913 try mouse(&core, &tr, 0, 3, 2, 'M');
3914 try mouse(&core, &tr, 32, 5, 2, 'M');
3915 const painted = drainPipe(p[0], &buf);
3916 try std.testing.expect(std.mem.indexOf(
3917 u8,
3918 painted,
3919 comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-o\x1b[0m" ++ cha(6) ++ "ne\x1b[0m",
3920 ) != null);
3921 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
3922 }
3923
3821 test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" { 3924 test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" {
3822 const alloc = std.testing.allocator; 3925 const alloc = std.testing.allocator;
3823 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 3926 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
@@ -4056,13 +4159,16 @@ fn tileCore(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
4056 core.col_off = drag_col_off; 4159 core.col_off = drag_col_off;
4057 core.owns_screen = false; 4160 core.owns_screen = false;
4058 _ = core.claimTerminal(); 4161 _ = core.claimTerminal();
4162 var body: std.ArrayList(u8) = .empty;
4163 defer body.deinit(alloc);
4059 var y: u16 = 0; 4164 var y: u16 = 0;
4060 while (y < tile_rows) : (y += 1) { 4165 while (y < tile_rows) : (y += 1) {
4061 var row: [tile_cols]u8 = undefined; 4166 var row: [tile_cols]u8 = undefined;
4062 @memset(&row, '0' + @as(u8, @intCast(y))); 4167 @memset(&row, '0' + @as(u8, @intCast(y)));
4063 core.rep.eng.feed(&row); 4168 try body.appendSlice(alloc, &row);
4064 if (y + 1 < tile_rows) core.rep.eng.feed("\r\n"); 4169 if (y + 1 < tile_rows) try body.appendSlice(alloc, "\r\n");
4065 } 4170 }
4171 try authorScreen(alloc, core.rep.grid, body.items);
4066 return core; 4172 return core;
4067 } 4173 }
4068 4174
@@ -4241,7 +4347,9 @@ test "interact: a delta under a held selection stays a delta" {
4241 .cursor_y = 0, 4347 .cursor_y = 0,
4242 .row_count = 1, 4348 .row_count = 1,
4243 }); 4349 });
4244 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0mrow-one"); 4350 const delta_row = try cellRow(alloc, "row-one");
4351 defer alloc.free(delta_row);
4352 try proto.appendDeltaRow(&payload, alloc, 1, delta_row);
4245 _ = try core.frame(.delta, payload.items); 4353 _ = try core.frame(.delta, payload.items);
4246 4354
4247 const painted = drainPipe(p[0], &buf); 4355 const painted = drainPipe(p[0], &buf);
@@ -4387,8 +4495,8 @@ test "interact: a row past the grid names no line, and a column past it clamps"
4387 _ = drainPipe(p[0], &buf); 4495 _ = drainPipe(p[0], &buf);
4388 4496
4389 // The grid is smaller than the Core's clip size: 20x6 against 40x12. 4497 // The grid is smaller than the Core's clip size: 20x6 against 40x12.
4390 try core.rep.eng.resize(20, 6); 4498 try core.rep.grid.resize(20, 6);
4391 core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two"); 4499 try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two");
4392 4500
4393 // Row 8 is inside the terminal and past the grid: `renderClipped` 4501 // Row 8 is inside the terminal and past the grid: `renderClipped`
4394 // never painted a session line there. 4502 // never painted a session line there.
src/tui/paint.zig
Old New
@@ -1,10 +1,12 @@
1 //! Painting the replica to a tty: clipped renders, delta rows, banner, 1 //! Painting the replica to a tty: clipped renders, delta rows, banner,
2 //! scrollback — pure fd-out, no transport knowledge. `paintDeltaClipped` is 2 //! scrollback — pure fd-out, no transport knowledge. `paintDeltaClipped` is
3 //! the exception: its payload is raw wire, decoded here, so this module knows 3 //! the exception: its payload is raw wire, read here for the row indices it
4 //! the delta FORMAT without knowing what carried it. Also the single home of 4 //! names, so this module knows the delta FORMAT without knowing what carried
5 //! the synchronized-update bracket, for every caller. 5 //! it. Also the single home of the synchronized-update bracket, for every
6 //! caller, and the only place a grid cell becomes a terminal escape.
6 const std = @import("std"); 7 const std = @import("std");
7 const Engine = @import("term").engine.Engine; 8 const grid = @import("term").grid;
9 const Grid = grid.Grid;
8 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
9 11
10 /// The synchronized-update bracket. Exactly once, because a dropped half is 12 /// The synchronized-update bracket. Exactly once, because a dropped half is
@@ -23,15 +25,121 @@ pub const Highlight = struct {
23 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, 25 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
24 }; 26 };
25 27
28 /// One grid row as VT for a terminal: an SGR wherever the style changes, a
29 /// wide glyph written once with its spacer skipped, a space for an empty
30 /// cell, and a stop at the last cell that is not a default blank, because
31 /// the caller's ECH has already cleared the rest of the row.
32 ///
33 /// `span` is inclusive GRID columns, painted inverted and PLAIN and snapped
34 /// outward to whole glyphs, closed by a full reset. Its three pieces are
35 /// positioned with CHA, which is screen-absolute, so every column it emits
36 /// carries `view.col_off` or the row lands in the neighbour's pane.
37 pub fn rowToVtFrom(
38 alloc: std.mem.Allocator,
39 r: *const grid.Row,
40 cols: u16,
41 view: grid.RowView,
42 span: ?Span,
43 ) ![]u8 {
44 var out: std.ArrayList(u8) = .empty;
45 errdefer out.deinit(alloc);
46 try out.appendSlice(alloc, "\x1b[0m");
47 const last = grid.clipColOf(r, cols, view) orelse return out.toOwnedSlice(alloc);
48 const snapped: ?grid.ColSpan = if (span) |s| blk: {
49 if (s.from > last) break :blk null;
50 break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
51 } else null;
52 var end: u16 = last;
53 // Trailing default blanks are the caller's clear, not ours — but a
54 // selected blank is a cell the user can see they selected, so the span
55 // holds the stop open past it.
56 while (end > 0 and r.cells[end].isBlank() and (snapped == null or end > snapped.?.to)) end -= 1;
57 var cur: proto.CellStyle = .{};
58 var x: u16 = 0;
59 var inverted = false;
60 while (x <= end) : (x += 1) {
61 const c = r.cells[x];
62 if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
63 const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
64 if (in_span and !inverted) {
65 try out.writer(alloc).print("\x1b[{d}G\x1b[0m\x1b[7m", .{x + 1 + view.col_off});
66 inverted = true;
67 cur = .{};
68 } else if (!in_span and inverted) {
69 try out.writer(alloc).print("\x1b[0m\x1b[{d}G", .{x + 1 + view.col_off});
70 inverted = false;
71 cur = .{};
72 }
73 if (!in_span and !c.style.eql(cur)) {
74 try appendSgr(&out, alloc, c.style);
75 cur = c.style;
76 }
77 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
78 }
79 try out.appendSlice(alloc, "\x1b[0m");
80 return out.toOwnedSlice(alloc);
81 }
82
83 pub fn rowToVt(alloc: std.mem.Allocator, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8 {
84 return rowToVtFrom(alloc, g.row(y), g.cols, view, span);
85 }
86
87 /// One colour parameter. `base` is 30 foreground, 40 background, 58
88 /// underline; the palette's first sixteen entries have their own short
89 /// codes, and the underline colour has none of them.
90 fn appendColor(out: *std.ArrayList(u8), alloc: std.mem.Allocator, base: u8, packed_col: u32) !void {
91 const w = out.writer(alloc);
92 switch (packed_col >> 24) {
93 0 => try w.print(";{d}", .{base + 9}), // default: 39 / 49 / 59
94 1 => {
95 const idx: u8 = @truncate(packed_col);
96 if (base != 58 and idx < 8) {
97 try w.print(";{d}", .{base + idx});
98 } else if (base != 58 and idx < 16) {
99 try w.print(";{d}", .{base + 60 + (idx - 8)});
100 } else try w.print(";{d};5;{d}", .{ base + 8, idx });
101 },
102 else => try w.print(";{d};2;{d};{d};{d}", .{ base + 8, (packed_col >> 16) & 0xff, (packed_col >> 8) & 0xff, packed_col & 0xff }),
103 }
104 }
105
106 /// A full SGR for `s` from a reset: every attribute the wire carries, so a
107 /// host terminal never inherits a neighbouring cell's style. Written as one
108 /// sequence starting at 0 rather than as a diff against the previous cell,
109 /// because a diff would have to reason about what the terminal is in and a
110 /// row is repainted from a reset anyway.
111 fn appendSgr(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: proto.CellStyle) !void {
112 const w = out.writer(alloc);
113 try w.writeAll("\x1b[0");
114 if (s.flags & (1 << 0) != 0) try w.writeAll(";1");
115 if (s.flags & (1 << 2) != 0) try w.writeAll(";2");
116 if (s.flags & (1 << 1) != 0) try w.writeAll(";3");
117 switch ((s.flags >> 8) & 0x7) {
118 0 => {},
119 1 => try w.writeAll(";4"),
120 2 => try w.writeAll(";4:2"),
121 3 => try w.writeAll(";4:3"),
122 4 => try w.writeAll(";4:4"),
123 5 => try w.writeAll(";4:5"),
124 else => try w.writeAll(";4"),
125 }
126 if (s.flags & (1 << 3) != 0) try w.writeAll(";5");
127 if (s.flags & (1 << 4) != 0) try w.writeAll(";7");
128 if (s.flags & (1 << 5) != 0) try w.writeAll(";8");
129 if (s.flags & (1 << 6) != 0) try w.writeAll(";9");
130 if (s.flags & (1 << 7) != 0) try w.writeAll(";53");
131 if (s.fg != 0) try appendColor(out, alloc, 30, s.fg);
132 if (s.bg != 0) try appendColor(out, alloc, 40, s.bg);
133 if (s.ul != 0) try appendColor(out, alloc, 58, s.ul);
134 try w.writeAll("m");
135 }
136
26 /// One grid row, inverted where the highlight says so, bounded to the pane's 137 /// One grid row, inverted where the highlight says so, bounded to the pane's
27 /// own columns. The single place the two painters agree about what a selection 138 /// own columns. The single place the two painters agree about what a
28 /// does to a row, and about where a row STOPS: DECAWM off clips at the screen's 139 /// selection does to a row, and about where a row STOPS.
29 /// edge, which is the pane's only when the pane owns the screen. 140 fn dumpRow(alloc: std.mem.Allocator, g: *const Grid, y: u16, hl: Highlight, view: grid.RowView) ![]u8 {
30 fn dumpRow(alloc: std.mem.Allocator, replica: *Engine, y: u16, hl: Highlight, view: Engine.RowView) ![]u8 { 141 const ask = hl.span orelse return rowToVt(alloc, g, y, view, null);
31 const ask = hl.span orelse return replica.dumpVtRowClipped(alloc, y, view); 142 return rowToVt(alloc, g, y, view, ask(hl.ctx, y, g.cols));
32 const s = ask(hl.ctx, y, @intCast(replica.term.cols)) orelse
33 return replica.dumpVtRowClipped(alloc, y, view);
34 return replica.dumpVtRowSpan(alloc, y, s.from, s.to, view);
35 } 143 }
36 144
37 // Four positional scalars said this before, two of them one value at every 145 // Four positional scalars said this before, two of them one value at every
@@ -47,7 +155,7 @@ pub const Viewport = struct {
47 cols: u16, 155 cols: u16,
48 }; 156 };
49 157
50 pub fn clampCursor(cur: Engine.CursorPos, vp: Viewport) Engine.CursorPos { 158 pub fn clampCursor(cur: grid.CursorPos, vp: Viewport) grid.CursorPos {
51 return .{ 159 return .{
52 .x = @min(cur.x, vp.cols -| 1), 160 .x = @min(cur.x, vp.cols -| 1),
53 .y = @min(cur.y, vp.rows -| 1), 161 .y = @min(cur.y, vp.rows -| 1),
@@ -77,7 +185,7 @@ fn appendRowAt(
77 fn finishPaint( 185 fn finishPaint(
78 paint: *std.ArrayList(u8), 186 paint: *std.ArrayList(u8),
79 alloc: std.mem.Allocator, 187 alloc: std.mem.Allocator,
80 cur: Engine.CursorPos, 188 cur: grid.CursorPos,
81 vp: Viewport, 189 vp: Viewport,
82 out_fd: std.posix.fd_t, 190 out_fd: std.posix.fd_t,
83 ) !void { 191 ) !void {
@@ -89,10 +197,10 @@ fn finishPaint(
89 } 197 }
90 198
91 /// The replica may exceed the tty under latest-wins; rows clip at the 199 /// The replica may exceed the tty under latest-wins; rows clip at the
92 /// right edge (DECAWM off). `rows` null is every row of the viewport. 200 /// right edge. `rows` null is every row of the viewport.
93 pub fn renderClipped( 201 pub fn renderClipped(
94 alloc: std.mem.Allocator, 202 alloc: std.mem.Allocator,
95 replica: *Engine, 203 g: *const Grid,
96 vp: Viewport, 204 vp: Viewport,
97 hl: Highlight, 205 hl: Highlight,
98 rows: ?[]const u16, 206 rows: ?[]const u16,
@@ -106,9 +214,8 @@ pub fn renderClipped(
106 // without owning the screen. A full-screen clear wipes every other tile. 214 // without owning the screen. A full-screen clear wipes every other tile.
107 try paint.appendSlice(alloc, if (owns_screen) sync_begin ++ "\x1b[H\x1b[2J" else sync_begin); 215 try paint.appendSlice(alloc, if (owns_screen) sync_begin ++ "\x1b[H\x1b[2J" else sync_begin);
108 216
109 const grid_rows: u16 = @intCast(replica.term.rows); 217 const limit = @min(g.rows, vp.rows);
110 const limit = @min(grid_rows, vp.rows); 218 const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
111 const view: Engine.RowView = .{ .col_off = vp.left, .cols = vp.cols };
112 var ech_buf: [16]u8 = undefined; 219 var ech_buf: [16]u8 = undefined;
113 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch ""; 220 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
114 // Span-bounded ECH per row where the screen was not cleared, so a 221 // Span-bounded ECH per row where the screen was not cleared, so a
@@ -120,83 +227,75 @@ pub fn renderClipped(
120 const y: u16 = if (rows) |r| r[k] else @intCast(k); 227 const y: u16 = if (rows) |r| r[k] else @intCast(k);
121 if (y >= limit) continue; 228 if (y >= limit) continue;
122 try appendRowAt(&paint, alloc, y, vp, clear); 229 try appendRowAt(&paint, alloc, y, vp, clear);
123 const row = try dumpRow(alloc, replica, y, hl, view); 230 const row = try dumpRow(alloc, g, y, hl, view);
124 defer alloc.free(row); 231 defer alloc.free(row);
125 try paint.appendSlice(alloc, row); 232 try paint.appendSlice(alloc, row);
126 } 233 }
127 234
128 try finishPaint(&paint, alloc, replica.cursorPos(), vp, out_fd); 235 try finishPaint(&paint, alloc, g.cursor, vp, out_fd);
129 } 236 }
130 237
131 /// What a MOVING selection needs: a drag changes one or two rows, and a 238 /// What a MOVING selection needs: a drag changes one or two rows, and a
132 /// full repaint per cell crossed costs the whole screen. 239 /// full repaint per cell crossed costs the whole screen.
133 pub fn renderRowsClipped( 240 pub fn renderRowsClipped(
134 alloc: std.mem.Allocator, 241 alloc: std.mem.Allocator,
135 replica: *Engine, 242 g: *const Grid,
136 vp: Viewport, 243 vp: Viewport,
137 hl: Highlight, 244 hl: Highlight,
138 rows: []const u16, 245 rows: []const u16,
139 out_fd: std.posix.fd_t, 246 out_fd: std.posix.fd_t,
140 ) !void { 247 ) !void {
141 if (rows.len == 0) return; 248 if (rows.len == 0) return;
142 return renderClipped(alloc, replica, vp, hl, rows, false, out_fd); 249 return renderClipped(alloc, g, vp, hl, rows, false, out_fd);
143 } 250 }
144 251
145 /// Bounded by the REPLICA's grid, not the tty: the answer feeds 252 /// The rows a delta frame names, painted FROM THE GRID the replica has just
146 /// `dumpVtRowSpan`, which asserts its row exists, and under latest-wins 253 /// been fed. The frame's own bytes are cells, not paintable VT, so there is
147 /// the grid can be smaller than the terminal it is painted on. 254 /// no verbatim path left: every row is re-serialized against this pane's
148 fn deltaRowSpan(hl: Highlight, row: u16, grid_rows: u16, grid_cols: u16) ?Span { 255 /// width, which is also what keeps a grid-wide row off a narrower pane's
149 if (row >= grid_rows) return null; 256 /// neighbour.
150 const ask = hl.span orelse return null;
151 return ask(hl.ctx, row, grid_cols);
152 }
153
154 /// The replica is updated separately by `composeDelta`, unclipped; it is
155 /// read here only for selected rows, after being fed this frame.
156 pub fn paintDeltaClipped( 257 pub fn paintDeltaClipped(
157 alloc: std.mem.Allocator, 258 alloc: std.mem.Allocator,
158 payload: []const u8, 259 payload: []const u8,
159 replica: *Engine, 260 g: *const Grid,
160 vp: Viewport, 261 vp: Viewport,
161 hl: Highlight, 262 hl: Highlight,
162 out_fd: std.posix.fd_t, 263 out_fd: std.posix.fd_t,
163 ) !void { 264 ) !void {
164 const hdr = try proto.readDeltaHeader(payload); 265 const hdr = try proto.readDeltaHeader(payload);
266 var rows_buf: [max_delta_rows]u16 = undefined;
267 var n: usize = 0;
268 var it = proto.deltaRowIterator(payload);
269 while (try it.next()) |row| {
270 if (n == rows_buf.len) break;
271 rows_buf[n] = row.row;
272 n += 1;
273 }
165 var paint: std.ArrayList(u8) = .empty; 274 var paint: std.ArrayList(u8) = .empty;
166 defer paint.deinit(alloc); 275 defer paint.deinit(alloc);
167 try paint.appendSlice(alloc, sync_begin); 276 try paint.appendSlice(alloc, sync_begin);
168 277
169 const grid_cols: u16 = @intCast(replica.term.cols); 278 const limit = @min(g.rows, vp.rows);
170 const grid_rows: u16 = @intCast(replica.term.rows); 279 const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
171 const view: Engine.RowView = .{ .col_off = vp.left, .cols = vp.cols };
172 var ech_buf: [16]u8 = undefined; 280 var ech_buf: [16]u8 = undefined;
173 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch ""; 281 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
174 var it = proto.deltaRowIterator(payload); 282 for (rows_buf[0..n]) |y| {
175 while (try it.next()) |row| { 283 if (y >= limit) continue;
176 if (row.row >= vp.rows) continue; 284 try appendRowAt(&paint, alloc, y, vp, ech);
177 try appendRowAt(&paint, alloc, row.row, vp, ech); 285 const row = try dumpRow(alloc, g, y, hl, view);
178 // A row under the selection is redrawn from the replica, which has been 286 defer alloc.free(row);
179 // fed this very frame; every other row keeps the daemon's bytes verbatim, 287 try paint.appendSlice(alloc, row);
180 // so a held selection costs one dumped row per covered row. Verbatim is
181 // only safe while the pane is as WIDE as the grid — the daemon's rows are
182 // grid-wide, and a narrower pane's surplus lands on the neighbour.
183 const overwide = grid_cols > vp.cols and row.row < grid_rows;
184 if (deltaRowSpan(hl, row.row, grid_rows, grid_cols)) |s| {
185 const inverted = try replica.dumpVtRowSpan(alloc, row.row, s.from, s.to, view);
186 defer alloc.free(inverted);
187 try paint.appendSlice(alloc, inverted);
188 } else if (overwide) {
189 const clipped = try replica.dumpVtRowClipped(alloc, row.row, view);
190 defer alloc.free(clipped);
191 try paint.appendSlice(alloc, clipped);
192 } else {
193 try paint.appendSlice(alloc, row.bytes);
194 }
195 } 288 }
196 289
197 try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd); 290 try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd);
198 } 291 }
199 292
293 /// The row indices one delta paint will hold on the stack. A frame naming
294 /// more rows than this is a frame naming more rows than any grid mux serves
295 /// has, so the surplus is dropped rather than allocated for; the replica has
296 /// already taken every row, and the next full repaint carries them.
297 const max_delta_rows = 1024;
298
200 /// An inverse status marker parked in the top-right corner: `[scroll]` when 299 /// An inverse status marker parked in the top-right corner: `[scroll]` when
201 /// viewing history, `[reconnecting]` when the transport is being rebuilt. 300 /// viewing history, `[reconnecting]` when the transport is being rebuilt.
202 /// Text only, so a caller mid-repaint can append it into its own paint 301 /// Text only, so a caller mid-repaint can append it into its own paint
@@ -227,75 +326,49 @@ pub fn paintBanner(out_fd: std.posix.fd_t, view_cols: u16, label: []const u8, ro
227 proto.writeAllFd(out_fd, text) catch {}; 326 proto.writeAllFd(out_fd, text) catch {};
228 } 327 }
229 328
230 /// The daemon's history rows are grid-wide with no engine behind them. 329 /// A page of history: the rows a `scrollback_chunk` carried, decoded, and
231 /// Replayed into one of the grid's width, each re-emits pane-bounded. 330 /// the inverse [scroll] marker top-right saying this is not live. `g_cols`
232 fn appendClippedHistory( 331 /// is the daemon's grid width, which the rows were encoded at; a pane
233 paint: *std.ArrayList(u8), 332 /// narrower than that clips at its own edge like any live row.
234 alloc: std.mem.Allocator,
235 rows_vt: []const u8,
236 vp: Viewport,
237 grid_cols: u16,
238 ech: []const u8,
239 ) !void {
240 const body = std.mem.trimRight(u8, rows_vt, "\r\n");
241 const count = std.mem.count(u8, body, "\n") + 1;
242 const rows: u16 = @intCast(@min(count, 4096));
243 var scratch = try Engine.init(alloc, .{ .cols = grid_cols, .rows = rows });
244 defer scratch.deinit();
245 scratch.feed(body);
246 const view: Engine.RowView = .{ .col_off = vp.left, .cols = vp.cols };
247 var n: u16 = 0;
248 while (n < @min(rows, vp.rows)) : (n += 1) {
249 try appendRowAt(paint, alloc, n, vp, ech);
250 const seg = try scratch.dumpVtRowClipped(alloc, n, view);
251 defer alloc.free(seg);
252 try paint.appendSlice(alloc, seg);
253 }
254 }
255
256 /// The inverse [scroll] marker top-right says this is not live.
257 pub fn renderScrollback( 333 pub fn renderScrollback(
258 alloc: std.mem.Allocator, 334 alloc: std.mem.Allocator,
259 rows_vt: []const u8, 335 rows: []const grid.Row,
336 g_cols: u16,
260 vp: Viewport, 337 vp: Viewport,
261 grid_cols: u16,
262 owns_screen: bool, 338 owns_screen: bool,
263 out_fd: std.posix.fd_t, 339 out_fd: std.posix.fd_t,
264 ) !void { 340 ) !void {
265 var paint: std.ArrayList(u8) = .empty; 341 var paint: std.ArrayList(u8) = .empty;
266 defer paint.deinit(alloc); 342 defer paint.deinit(alloc);
267 // `owns_screen` is the caller's contract: a whole-screen clear wipes 343 // `owns_screen` is the caller's contract: a whole-screen clear wipes
268 // every other tile, so only a tile that has the whole screen blits the 344 // every other tile, so only a tile that has the whole screen takes one.
269 // daemon's blob verbatim. An offset splits on row breaks and clears 345 // A tile at an offset clears per row instead — the pattern
270 // per row — the pattern renderClipped uses for a live tile. 346 // `renderClipped` uses for a live tile.
347 var ech_buf: [16]u8 = undefined;
348 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
349 const clear: []const u8 = if (owns_screen) "" else ech;
271 if (owns_screen) { 350 if (owns_screen) {
272 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J"); 351 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J");
273 try paint.appendSlice(alloc, rows_vt);
274 } else { 352 } else {
275 try paint.appendSlice(alloc, sync_begin); 353 try paint.appendSlice(alloc, sync_begin);
276 var ech_buf: [16]u8 = undefined; 354 }
277 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch ""; 355 const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
278 // The blob is the answer to a request for `vp.rows` of history, so 356 // The chunk is the answer to a request for `vp.rows` of history, so a
279 // a longer one is a daemon disagreeing with this client about the 357 // longer one is a daemon disagreeing with this client about the tile's
280 // tile's height — and every surplus row would land on the tile 358 // height and every surplus row would land on the tile below. The request
281 // below. The request is not the bound; the rect is. 359 // is not the bound; the rect is.
282 if (grid_cols > vp.cols) { 360 var n: u16 = 0;
283 try appendClippedHistory(&paint, alloc, rows_vt, vp, grid_cols, ech); 361 while (n < vp.rows and n < rows.len) : (n += 1) {
284 } else { 362 try appendRowAt(&paint, alloc, n, vp, clear);
285 var rest = rows_vt; 363 const seg = try rowToVtFrom(alloc, &rows[n], g_cols, view, null);
286 var n: u16 = 0; 364 defer alloc.free(seg);
287 while (rest.len > 0 and n < vp.rows) : (n += 1) { 365 try paint.appendSlice(alloc, seg);
288 // dumpScrollback separates rows with \r\n; a cell never holds a 366 }
289 // newline, so the split lands on the row breaks only. 367 // A chunk shorter than the tile is a page near the top of history. On a
290 const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; 368 // cleared screen those rows are already blank; on a shared one they
291 var seg = rest[0..nl]; 369 // still hold the live text this page replaced, so each is erased.
292 if (seg.len > 0 and seg[seg.len - 1] == '\r') seg = seg[0 .. seg.len - 1]; 370 if (!owns_screen) {
293 try appendRowAt(&paint, alloc, n, vp, ech); 371 while (n < vp.rows) : (n += 1) try appendRowAt(&paint, alloc, n, vp, clear);
294 try paint.appendSlice(alloc, seg);
295 if (nl == rest.len) break;
296 rest = rest[nl + 1 ..];
297 }
298 }
299 } 372 }
300 var mark_buf: [96]u8 = undefined; 373 var mark_buf: [96]u8 = undefined;
301 try paint.appendSlice(alloc, try bannerText(&mark_buf, vp.cols, "[scroll]", vp.top, vp.left)); 374 try paint.appendSlice(alloc, try bannerText(&mark_buf, vp.cols, "[scroll]", vp.top, vp.left));
@@ -306,6 +379,26 @@ pub fn renderScrollback(
306 try proto.writeAllFd(out_fd, paint.items); 379 try proto.writeAllFd(out_fd, paint.items);
307 } 380 }
308 381
382 // ---------------------------------------------------------------------------
383 // Tests. A client grid can only be filled from encoded cells, so every screen
384 // under test is AUTHORED through an engine and mirrored in — the same path the
385 // daemon's encoder and the replica's decoder take on the wire.
386
387 const Engine = @import("term").engine.Engine;
388
389 /// A grid holding what an engine that size shows after `bytes`, cursor
390 /// included. The bridge between a screen a test wants to describe in VT and
391 /// the cells a painter reads.
392 fn authoredGrid(alloc: std.mem.Allocator, cols: u16, rows: u16, bytes: []const u8) !*Grid {
393 const g = try Grid.init(alloc, cols, rows);
394 errdefer g.deinit();
395 const e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
396 defer e.deinit();
397 e.feed(bytes);
398 try e.mirrorInto(g);
399 return g;
400 }
401
309 /// A highlight of two fixed rows, standing in for what `select.Drag` 402 /// A highlight of two fixed rows, standing in for what `select.Drag`
310 /// answers: rows 1 and 2, from column 3 to the width the painter offers. 403 /// answers: rows 1 and 2, from column 3 to the width the painter offers.
311 const TestHighlight = struct { 404 const TestHighlight = struct {
@@ -323,16 +416,31 @@ const TestHighlight = struct {
323 } 416 }
324 }; 417 };
325 418
419 test "rowToVt: an SGR per style change, a wide glyph once, a trailing-blank stop" {
420 const alloc = std.testing.allocator;
421 const g = try authoredGrid(alloc, 12, 2, "\x1b[1;31mab\x1b[0m\u{6f22}c");
422 defer g.deinit();
423
424 const row = try rowToVt(alloc, g, 0, .{ .col_off = 0, .cols = 12 }, null);
425 defer alloc.free(row);
426 // Bold red for `ab`, a reset-shaped SGR for `漢c`, the wide glyph written
427 // once with its spacer skipped, and nothing for the eight blank columns
428 // after it: the caller's ECH cleared those.
429 try std.testing.expectEqualStrings(
430 "\x1b[0m\x1b[0;1;31mab\x1b[0m\u{6f22}c\x1b[0m",
431 row,
432 );
433 }
434
326 test "renderClipped inverts the highlighted rows and leaves the rest alone" { 435 test "renderClipped inverts the highlighted rows and leaves the rest alone" {
327 const alloc = std.testing.allocator; 436 const alloc = std.testing.allocator;
328 var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 }); 437 const g = try authoredGrid(alloc, 12, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
329 defer replica.deinit(); 438 defer g.deinit();
330 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
331 439
332 const pipe = try std.posix.pipe(); 440 const pipe = try std.posix.pipe();
333 defer std.posix.close(pipe[0]); 441 defer std.posix.close(pipe[0]);
334 var h: TestHighlight = .{}; 442 var h: TestHighlight = .{};
335 try renderClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), null, true, pipe[1]); 443 try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), null, true, pipe[1]);
336 std.posix.close(pipe[1]); 444 std.posix.close(pipe[1]);
337 445
338 var out: [8192]u8 = undefined; 446 var out: [8192]u8 = undefined;
@@ -464,9 +572,11 @@ test "paint: a banner parks in its own tile's corner, not the screen's" {
464 572
465 test "renderScrollback paints rows with an inverse scroll marker" { 573 test "renderScrollback paints rows with an inverse scroll marker" {
466 const alloc = std.testing.allocator; 574 const alloc = std.testing.allocator;
575 const hist = try authoredGrid(alloc, 80, 2, "old-row-1\r\nold-row-2");
576 defer hist.deinit();
467 const pipe = try std.posix.pipe(); 577 const pipe = try std.posix.pipe();
468 defer std.posix.close(pipe[0]); 578 defer std.posix.close(pipe[0]);
469 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, 80, true, pipe[1]); 579 try renderScrollback(alloc, hist.lines, 80, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, true, pipe[1]);
470 std.posix.close(pipe[1]); 580 std.posix.close(pipe[1]);
471 581
472 var out: [4096]u8 = undefined; 582 var out: [4096]u8 = undefined;
@@ -480,17 +590,22 @@ test "renderScrollback paints rows with an inverse scroll marker" {
480 // change to the offset path cannot drift the row 0 path past it. 590 // change to the offset path cannot drift the row 0 path past it.
481 test "renderScrollback at row 0 is byte-identical to the plain client" { 591 test "renderScrollback at row 0 is byte-identical to the plain client" {
482 const alloc = std.testing.allocator; 592 const alloc = std.testing.allocator;
593 const hist = try authoredGrid(alloc, 80, 2, "old-row-1\r\nold-row-2");
594 defer hist.deinit();
483 const pipe = try std.posix.pipe(); 595 const pipe = try std.posix.pipe();
484 defer std.posix.close(pipe[0]); 596 defer std.posix.close(pipe[0]);
485 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, 80, true, pipe[1]); 597 try renderScrollback(alloc, hist.lines, 80, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, true, pipe[1]);
486 std.posix.close(pipe[1]); 598 std.posix.close(pipe[1]);
487 599
488 var out: [4096]u8 = undefined; 600 var out: [4096]u8 = undefined;
489 const n = try std.posix.read(pipe[0], &out); 601 const n = try std.posix.read(pipe[0], &out);
490 // Home + whole-screen clear once, the blob verbatim, the banner at the 602 // Home + whole-screen clear once, one addressed row per history row with
603 // no clear behind it (the screen is already blank), the banner at the
491 // screen's top-right (row 1, col 80 - len("[scroll]") = 73), then the 604 // screen's top-right (row 1, col 80 - len("[scroll]") = 73), then the
492 // unpaired close that hides the cursor for a history page. 605 // unpaired close that hides the cursor for a history page.
493 const expected = "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2Jold-row-1\r\nold-row-2" ++ 606 const expected = "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J" ++
607 "\x1b[1;1H\x1b[0mold-row-1\x1b[0m" ++
608 "\x1b[2;1H\x1b[0mold-row-2\x1b[0m" ++
494 "\x1b[1;72H\x1b[7m[scroll]\x1b[0m\x1b[?2026l"; 609 "\x1b[1;72H\x1b[7m[scroll]\x1b[0m\x1b[?2026l";
495 try std.testing.expectEqualStrings(expected, out[0..n]); 610 try std.testing.expectEqualStrings(expected, out[0..n]);
496 } 611 }
@@ -504,12 +619,12 @@ test "renderScrollback at a row_off owns only its sub-rect" {
504 defer std.posix.close(pipe[0]); 619 defer std.posix.close(pipe[0]);
505 const row_off: u16 = 5; 620 const row_off: u16 = 5;
506 const size: proto.Size = .{ .cols = 80, .rows = 24 }; 621 const size: proto.Size = .{ .cols = 80, .rows = 24 };
507 // CRLF-separated rows, the shape `dumpScrollback` composes. Three rows MORE 622 // Three rows MORE than the tile is tall: the ASK is not what bounds the
508 // than the tile is tall: the ASK is not what bounds the paint, so a daemon 623 // paint, so a daemon that disagreed about the height would land its
509 // that disagrees about the height would land its surplus on the tile below, 624 // surplus on the tile below, and a chunk shorter than the band could
510 // and a blob shorter than the band could never say so. 625 // never say so.
511 const blob = comptime blk: { 626 const blob = comptime blk: {
512 var s: []const u8 = "\x1b[0m"; 627 var s: []const u8 = "";
513 var r: u16 = 1; 628 var r: u16 = 1;
514 while (r <= 27) : (r += 1) { 629 while (r <= 27) : (r += 1) {
515 s = s ++ std.fmt.comptimePrint("old-row-{d}", .{r}); 630 s = s ++ std.fmt.comptimePrint("old-row-{d}", .{r});
@@ -517,16 +632,18 @@ test "renderScrollback at a row_off owns only its sub-rect" {
517 } 632 }
518 break :blk s; 633 break :blk s;
519 }; 634 };
520 try renderScrollback(alloc, blob, .{ .top = row_off, .left = 0, .rows = size.rows, .cols = 80 }, 80, false, pipe[1]); 635 const hist = try authoredGrid(alloc, 80, 27, blob);
636 defer hist.deinit();
637 try renderScrollback(alloc, hist.lines, 80, .{ .top = row_off, .left = 0, .rows = size.rows, .cols = 80 }, false, pipe[1]);
521 std.posix.close(pipe[1]); 638 std.posix.close(pipe[1]);
522 639
523 var out: [4096]u8 = undefined; 640 var out: [8192]u8 = undefined;
524 const n = try std.posix.read(pipe[0], &out); 641 const n = try std.posix.read(pipe[0], &out);
525 const text = out[0..n]; 642 const text = out[0..n];
526 643
527 // No whole-screen clear and no home to row 1: either would wipe the 644 // No whole-screen clear and no home to row 1: either would wipe the
528 // tile's neighbours. A per-row clear stands in instead, the same 645 // tile's neighbours. A per-row clear stands in instead, the same
529 // pattern `renderClipped` uses for a live tile at an offset. 646 // pattern `renderClipped` uses for a live tile.
530 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2J") == null); 647 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2J") == null);
531 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[H") == null); 648 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[H") == null);
532 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[80X") != null); 649 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[80X") != null);
@@ -556,28 +673,71 @@ test "renderScrollback at a row_off owns only its sub-rect" {
556 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;72H\x1b[7m[scroll]\x1b[0m") != null); 673 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;72H\x1b[7m[scroll]\x1b[0m") != null);
557 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;72H") == null); 674 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;72H") == null);
558 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;1H\x1b[80X\x1b[0mold-row-1") != null); 675 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;1H\x1b[80X\x1b[0mold-row-1") != null);
559 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7;1H\x1b[80Xold-row-2") != null); 676 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7;1H\x1b[80X\x1b[0mold-row-2") != null);
560 // The last row the tile has room for is painted; the next is not sent 677 // The last row the tile has room for is painted; the next is not sent
561 // to a row it does not own, it is not sent at all. 678 // to a row it does not own, it is not sent at all.
562 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[29;1H\x1b[80Xold-row-24") != null); 679 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[29;1H\x1b[80X\x1b[0mold-row-24") != null);
563 try std.testing.expect(std.mem.indexOf(u8, text, "old-row-25") == null); 680 try std.testing.expect(std.mem.indexOf(u8, text, "old-row-25") == null);
564 // The unpaired close is unchanged: scroll mode hides the cursor. 681 // The unpaired close is unchanged: scroll mode hides the cursor.
565 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[?2026l")); 682 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[?2026l"));
566 } 683 }
567 684
685 test "renderScrollback: a page shorter than the tile erases the rows it does not fill" {
686 // A page near the top of history answers with fewer rows than the tile
687 // is tall. On a shared screen those rows still hold the live text this
688 // page replaced, so each one is erased rather than left showing.
689 const alloc = std.testing.allocator;
690 const hist = try authoredGrid(alloc, beside_width, 2, "hh\r\nii");
691 defer hist.deinit();
692 const pipe = try std.posix.pipe();
693 defer std.posix.close(pipe[0]);
694 try renderScrollback(
695 alloc,
696 hist.lines,
697 beside_width,
698 .{ .top = 0, .left = beside_off, .rows = 4, .cols = beside_width },
699 false,
700 pipe[1],
701 );
702 std.posix.close(pipe[1]);
703 var out: [8192]u8 = undefined;
704 const n = try std.posix.read(pipe[0], &out);
705
706 const screen = try screenAfter(alloc, out[0..n]);
707 defer alloc.free(screen);
708 var it = std.mem.splitScalar(u8, screen, '\n');
709 var y: u16 = 0;
710 while (it.next()) |line| : (y += 1) {
711 const lhs = "L" ** (beside_off - 1) ++ "|";
712 const want = switch (y) {
713 1 => lhs ++ "ii",
714 // Rows 2 and 3 are the tile's and hold no history: erased, and
715 // the neighbour across the rail is untouched. The oracle's dump
716 // ends a row at its last written cell, so an erased tail reads
717 // as nothing rather than as spaces.
718 2, 3 => lhs,
719 4...7 => beside_seed_row,
720 else => continue,
721 };
722 try std.testing.expectEqualStrings(want, line);
723 }
724 }
725
568 test "renderClipped paints only rows that fit and clamps the cursor" { 726 test "renderClipped paints only rows that fit and clamps the cursor" {
569 const alloc = std.testing.allocator; 727 const alloc = std.testing.allocator;
570 var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 }); 728 var body: std.ArrayList(u8) = .empty;
571 defer replica.deinit(); 729 defer body.deinit(alloc);
572 replica.feed("top row\r\n"); 730 try body.appendSlice(alloc, "top row\r\n");
573 var i: usize = 0; 731 var i: usize = 0;
574 while (i < 28) : (i += 1) replica.feed("mid\r\n"); 732 while (i < 28) : (i += 1) try body.appendSlice(alloc, "mid\r\n");
575 replica.feed("bottom row\x1b[30;100H"); // cursor parked at grid corner 733 try body.appendSlice(alloc, "bottom row\x1b[30;100H"); // cursor parked at grid corner
734 const g = try authoredGrid(alloc, 100, 30, body.items);
735 defer g.deinit();
576 736
577 const pipe = try std.posix.pipe(); 737 const pipe = try std.posix.pipe();
578 defer std.posix.close(pipe[0]); 738 defer std.posix.close(pipe[0]);
579 // Local tty is smaller than the 100x30 grid. 739 // Local tty is smaller than the 100x30 grid.
580 try renderClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]); 740 try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]);
581 std.posix.close(pipe[1]); 741 std.posix.close(pipe[1]);
582 742
583 var out: std.ArrayList(u8) = .empty; 743 var out: std.ArrayList(u8) = .empty;
@@ -605,16 +765,15 @@ test "renderClipped paints only rows that fit and clamps the cursor" {
605 } 765 }
606 766
607 test "renderClipped stops at the grid when the tty is the larger one" { 767 test "renderClipped stops at the grid when the tty is the larger one" {
608 // The other direction of the clip: dumpVtRow asserts y < term.rows, so 768 // The other direction of the clip: `rowToVt` indexes the grid's own
609 // the row loop must bound on the grid, not just on the tty. 769 // lines, so the row loop must bound on the grid, not just on the tty.
610 const alloc = std.testing.allocator; 770 const alloc = std.testing.allocator;
611 var replica = try Engine.init(alloc, .{ .cols = 40, .rows = 10 }); 771 const g = try authoredGrid(alloc, 40, 10, "small grid\x1b[10;40H");
612 defer replica.deinit(); 772 defer g.deinit();
613 replica.feed("small grid\x1b[10;40H");
614 773
615 const pipe = try std.posix.pipe(); 774 const pipe = try std.posix.pipe();
616 defer std.posix.close(pipe[0]); 775 defer std.posix.close(pipe[0]);
617 try renderClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]); 776 try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]);
618 std.posix.close(pipe[1]); 777 std.posix.close(pipe[1]);
619 778
620 var out: std.ArrayList(u8) = .empty; 779 var out: std.ArrayList(u8) = .empty;
@@ -640,16 +799,15 @@ test "paint: a tile at a row and column offset paints only inside its rect" {
640 const alloc = std.testing.allocator; 799 const alloc = std.testing.allocator;
641 const row_off: u16 = 2; 800 const row_off: u16 = 2;
642 const tile_rows: u16 = 3; 801 const tile_rows: u16 = 3;
643 var replica = try Engine.init(alloc, .{ .cols = beside_width, .rows = tile_rows }); 802 const g = try authoredGrid(alloc, beside_width, tile_rows, "0" ** beside_width ++ "\r\n" ++
644 defer replica.deinit(); 803 "1" ** beside_width ++ "\r\n" ++ "2" ** beside_width ++ "\x1b[1;5H");
645 replica.feed("0" ** beside_width ++ "\r\n" ++ "1" ** beside_width ++ "\r\n" ++ 804 defer g.deinit();
646 "2" ** beside_width ++ "\x1b[1;5H");
647 805
648 const pipe = try std.posix.pipe(); 806 const pipe = try std.posix.pipe();
649 defer std.posix.close(pipe[0]); 807 defer std.posix.close(pipe[0]);
650 try renderClipped( 808 try renderClipped(
651 alloc, 809 alloc,
652 replica, 810 g,
653 .{ .top = row_off, .left = beside_off, .rows = tile_rows, .cols = beside_width }, 811 .{ .top = row_off, .left = beside_off, .rows = tile_rows, .cols = beside_width },
654 .{}, 812 .{},
655 null, 813 null,
@@ -684,8 +842,8 @@ test "paint: a tile at a row and column offset paints only inside its rect" {
684 842
685 test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" { 843 test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
686 const alloc = std.testing.allocator; 844 const alloc = std.testing.allocator;
687 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 30 }); 845 const g = try authoredGrid(alloc, 80, 30, "\x1b[4;1Hfits\x1b[29;1Hdoes-not-fit");
688 defer replica.deinit(); 846 defer g.deinit();
689 var payload: std.ArrayList(u8) = .empty; 847 var payload: std.ArrayList(u8) = .empty;
690 defer payload.deinit(alloc); 848 defer payload.deinit(alloc);
691 try proto.appendDeltaHeader(&payload, alloc, .{ 849 try proto.appendDeltaHeader(&payload, alloc, .{
@@ -695,12 +853,15 @@ test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
695 .cursor_y = 29, 853 .cursor_y = 29,
696 .row_count = 2, 854 .row_count = 2,
697 }); 855 });
698 try proto.appendDeltaRow(&payload, alloc, 3, "\x1b[0mfits"); 856 // The row bytes are cells the replica has already taken; the paint reads
699 try proto.appendDeltaRow(&payload, alloc, 28, "\x1b[0mdoes-not-fit"); 857 // the grid, so what they hold does not matter here — only which rows the
858 // frame names.
859 try proto.appendDeltaRow(&payload, alloc, 3, "");
860 try proto.appendDeltaRow(&payload, alloc, 28, "");
700 861
701 const pipe = try std.posix.pipe(); 862 const pipe = try std.posix.pipe();
702 defer std.posix.close(pipe[0]); 863 defer std.posix.close(pipe[0]);
703 try paintDeltaClipped(alloc, payload.items, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]); 864 try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]);
704 std.posix.close(pipe[1]); 865 std.posix.close(pipe[1]);
705 var out: [4096]u8 = undefined; 866 var out: [4096]u8 = undefined;
706 const n = try std.posix.read(pipe[0], &out); 867 const n = try std.posix.read(pipe[0], &out);
@@ -712,9 +873,8 @@ test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
712 873
713 test "paintDeltaClipped re-inverts a delta row the selection covers" { 874 test "paintDeltaClipped re-inverts a delta row the selection covers" {
714 const alloc = std.testing.allocator; 875 const alloc = std.testing.allocator;
715 var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 }); 876 const g = try authoredGrid(alloc, 12, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
716 defer replica.deinit(); 877 defer g.deinit();
717 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
718 878
719 var payload: std.ArrayList(u8) = .empty; 879 var payload: std.ArrayList(u8) = .empty;
720 defer payload.deinit(alloc); 880 defer payload.deinit(alloc);
@@ -727,26 +887,26 @@ test "paintDeltaClipped re-inverts a delta row the selection covers" {
727 }); 887 });
728 // Row 1 is under the highlight, row 0 is not — one frame carrying both 888 // Row 1 is under the highlight, row 0 is not — one frame carrying both
729 // is the case the two branches have to be told apart in. 889 // is the case the two branches have to be told apart in.
730 try proto.appendDeltaRow(&payload, alloc, 0, "\x1b[0mrow-zero"); 890 try proto.appendDeltaRow(&payload, alloc, 0, "");
731 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0mrow-one"); 891 try proto.appendDeltaRow(&payload, alloc, 1, "");
732 892
733 const pipe = try std.posix.pipe(); 893 const pipe = try std.posix.pipe();
734 defer std.posix.close(pipe[0]); 894 defer std.posix.close(pipe[0]);
735 var h: TestHighlight = .{}; 895 var h: TestHighlight = .{};
736 try paintDeltaClipped(alloc, payload.items, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), pipe[1]); 896 try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), pipe[1]);
737 std.posix.close(pipe[1]); 897 std.posix.close(pipe[1]);
738 var out: [8192]u8 = undefined; 898 var out: [8192]u8 = undefined;
739 const n = try std.posix.read(pipe[0], &out); 899 const n = try std.posix.read(pipe[0], &out);
740 const text = out[0..n]; 900 const text = out[0..n];
741 901
742 // A delta paints the daemon's bytes as sent, so a row under the selection 902 // Exactly the covered row is inverted: a delta paint that inverted every
743 // would come back un-inverted and the highlight would develop holes wherever 903 // row it touched would flash the whole frame under a held selection, and
744 // the session was writing. The covered row is redrawn from the replica. 904 // one that inverted none would leave the selection full of holes wherever
905 // the session was writing.
745 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\x1b[7m")); 906 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\x1b[7m"));
746 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null); 907 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null);
747 // The uncovered row is still the daemon's own bytes, verbatim: this 908 // The uncovered row is an ordinary clipped dump of the grid, addressed
748 // path exists to keep a held selection off the full-repaint arm, so it 909 // and cleared like any other painted row.
749 // must not turn every other row into a replica dump either.
750 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[80X\x1b[0mrow-zero") != null); 910 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[80X\x1b[0mrow-zero") != null);
751 } 911 }
752 912
@@ -756,8 +916,8 @@ test "paintDeltaClipped brackets the whole paint in one synchronized update" {
756 // so no rendered grid can tell a torn paint from a whole one. Asserted on 916 // so no rendered grid can tell a torn paint from a whole one. Asserted on
757 // the ENDS, since only position shows that every row lands inside. 917 // the ENDS, since only position shows that every row lands inside.
758 const alloc = std.testing.allocator; 918 const alloc = std.testing.allocator;
759 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 30 }); 919 const g = try authoredGrid(alloc, 80, 30, "\x1b[2;1Hrow");
760 defer replica.deinit(); 920 defer g.deinit();
761 var payload: std.ArrayList(u8) = .empty; 921 var payload: std.ArrayList(u8) = .empty;
762 defer payload.deinit(alloc); 922 defer payload.deinit(alloc);
763 try proto.appendDeltaHeader(&payload, alloc, .{ 923 try proto.appendDeltaHeader(&payload, alloc, .{
@@ -767,11 +927,11 @@ test "paintDeltaClipped brackets the whole paint in one synchronized update" {
767 .cursor_y = 1, 927 .cursor_y = 1,
768 .row_count = 1, 928 .row_count = 1,
769 }); 929 });
770 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0mrow"); 930 try proto.appendDeltaRow(&payload, alloc, 1, "");
771 931
772 const pipe = try std.posix.pipe(); 932 const pipe = try std.posix.pipe();
773 defer std.posix.close(pipe[0]); 933 defer std.posix.close(pipe[0]);
774 try paintDeltaClipped(alloc, payload.items, replica, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]); 934 try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]);
775 std.posix.close(pipe[1]); 935 std.posix.close(pipe[1]);
776 var out: [4096]u8 = undefined; 936 var out: [4096]u8 = undefined;
777 const n = try std.posix.read(pipe[0], &out); 937 const n = try std.posix.read(pipe[0], &out);
@@ -790,8 +950,9 @@ test "paint: a delta lands in the tile's rect, every row of it" {
790 // tiles. Judged on the grid — a misplaced row still emits the right address. 950 // tiles. Judged on the grid — a misplaced row still emits the right address.
791 const alloc = std.testing.allocator; 951 const alloc = std.testing.allocator;
792 const row_off: u16 = 2; 952 const row_off: u16 = 2;
793 var replica = try Engine.init(alloc, .{ .cols = beside_width, .rows = 4 }); 953 const g = try authoredGrid(alloc, beside_width, 4, "0" ** beside_width ++ "\r\n" ++
794 defer replica.deinit(); 954 "1" ** beside_width);
955 defer g.deinit();
795 956
796 var payload: std.ArrayList(u8) = .empty; 957 var payload: std.ArrayList(u8) = .empty;
797 defer payload.deinit(alloc); 958 defer payload.deinit(alloc);
@@ -802,15 +963,15 @@ test "paint: a delta lands in the tile's rect, every row of it" {
802 .cursor_y = 0, 963 .cursor_y = 0,
803 .row_count = 2, 964 .row_count = 2,
804 }); 965 });
805 try proto.appendDeltaRow(&payload, alloc, 0, "\x1b[0m" ++ "0" ** beside_width); 966 try proto.appendDeltaRow(&payload, alloc, 0, "");
806 try proto.appendDeltaRow(&payload, alloc, 1, "\x1b[0m" ++ "1" ** beside_width); 967 try proto.appendDeltaRow(&payload, alloc, 1, "");
807 968
808 const pipe = try std.posix.pipe(); 969 const pipe = try std.posix.pipe();
809 defer std.posix.close(pipe[0]); 970 defer std.posix.close(pipe[0]);
810 try paintDeltaClipped( 971 try paintDeltaClipped(
811 alloc, 972 alloc,
812 payload.items, 973 payload.items,
813 replica, 974 g,
814 .{ .top = row_off, .left = beside_off, .rows = 4, .cols = beside_width }, 975 .{ .top = row_off, .left = beside_off, .rows = 4, .cols = beside_width },
815 .{}, 976 .{},
816 pipe[1], 977 pipe[1],
@@ -844,13 +1005,12 @@ test "a pane off the left edge paints inside its own span" {
844 // col_off 40, 39 cols: CUP lands at column 41 and the erase covers 39 1005 // col_off 40, 39 cols: CUP lands at column 41 and the erase covers 39
845 // cells — the bytes a beside-neighbour's survival depends on. 1006 // cells — the bytes a beside-neighbour's survival depends on.
846 const alloc = std.testing.allocator; 1007 const alloc = std.testing.allocator;
847 var replica = try Engine.init(alloc, .{ .cols = 40, .rows = 4 }); 1008 const g = try authoredGrid(alloc, 40, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
848 defer replica.deinit(); 1009 defer g.deinit();
849 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
850 1010
851 const pipe = try std.posix.pipe(); 1011 const pipe = try std.posix.pipe();
852 defer std.posix.close(pipe[0]); 1012 defer std.posix.close(pipe[0]);
853 try renderClipped(alloc, replica, .{ .top = 0, .left = 40, .rows = 24, .cols = 39 }, .{}, null, false, pipe[1]); 1013 try renderClipped(alloc, g, .{ .top = 0, .left = 40, .rows = 24, .cols = 39 }, .{}, null, false, pipe[1]);
854 std.posix.close(pipe[1]); 1014 std.posix.close(pipe[1]);
855 1015
856 var out: [8192]u8 = undefined; 1016 var out: [8192]u8 = undefined;
@@ -928,13 +1088,12 @@ test "paint: a pane narrower than the grid paints no cell past its own edge" {
928 // this pane is 12. The surplus has nowhere to go but the rail and the 1088 // this pane is 12. The surplus has nowhere to go but the rail and the
929 // neighbour across it. 1089 // neighbour across it.
930 const alloc = std.testing.allocator; 1090 const alloc = std.testing.allocator;
931 var replica = try Engine.init(alloc, .{ .cols = 30, .rows = 4 }); 1091 const g = try authoredGrid(alloc, 30, 4, "x" ** 30);
932 defer replica.deinit(); 1092 defer g.deinit();
933 replica.feed("x" ** 30);
934 1093
935 const pipe = try std.posix.pipe(); 1094 const pipe = try std.posix.pipe();
936 defer std.posix.close(pipe[0]); 1095 defer std.posix.close(pipe[0]);
937 try renderClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]); 1096 try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]);
938 std.posix.close(pipe[1]); 1097 std.posix.close(pipe[1]);
939 var out: [8192]u8 = undefined; 1098 var out: [8192]u8 = undefined;
940 const n = try std.posix.read(pipe[0], &out); 1099 const n = try std.posix.read(pipe[0], &out);
@@ -949,13 +1108,12 @@ test "paint: a wide cell astride the pane's edge is dropped, not halved" {
949 // pane holds twelve columns, so the glyph does not fit. Emitting it 1108 // pane holds twelve columns, so the glyph does not fit. Emitting it
950 // spends a column of the rail; emitting half of it is not a character. 1109 // spends a column of the rail; emitting half of it is not a character.
951 const alloc = std.testing.allocator; 1110 const alloc = std.testing.allocator;
952 var replica = try Engine.init(alloc, .{ .cols = 30, .rows = 4 }); 1111 const g = try authoredGrid(alloc, 30, 4, "a" ** 11 ++ "\u{6f22}" ++ "b" ** 17);
953 defer replica.deinit(); 1112 defer g.deinit();
954 replica.feed("a" ** 11 ++ "\u{6f22}" ++ "b" ** 17);
955 1113
956 const pipe = try std.posix.pipe(); 1114 const pipe = try std.posix.pipe();
957 defer std.posix.close(pipe[0]); 1115 defer std.posix.close(pipe[0]);
958 try renderClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]); 1116 try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]);
959 std.posix.close(pipe[1]); 1117 std.posix.close(pipe[1]);
960 var out: [8192]u8 = undefined; 1118 var out: [8192]u8 = undefined;
961 const n = try std.posix.read(pipe[0], &out); 1119 const n = try std.posix.read(pipe[0], &out);
@@ -966,20 +1124,19 @@ test "paint: a wide cell astride the pane's edge is dropped, not halved" {
966 } 1124 }
967 1125
968 test "paint: a highlight in an offset pane inverts inside the pane" { 1126 test "paint: a highlight in an offset pane inverts inside the pane" {
969 // `dumpVtRowSpan` positions its three pieces with CHA, which is 1127 // The span's three pieces are positioned with CHA, which is
970 // screen-absolute: a pane at an offset that emits a GRID column walks 1128 // screen-absolute: a pane at an offset that emitted a GRID column would
971 // the cursor into its left neighbour and paints the row there. 1129 // walk the cursor into its left neighbour and paint the row there.
972 const alloc = std.testing.allocator; 1130 const alloc = std.testing.allocator;
973 var replica = try Engine.init(alloc, .{ .cols = beside_width, .rows = 4 }); 1131 const g = try authoredGrid(alloc, beside_width, 4, "\x1b[2;1H" ++ "y" ** beside_width);
974 defer replica.deinit(); 1132 defer g.deinit();
975 replica.feed("\x1b[2;1H" ++ "y" ** beside_width);
976 1133
977 const pipe = try std.posix.pipe(); 1134 const pipe = try std.posix.pipe();
978 defer std.posix.close(pipe[0]); 1135 defer std.posix.close(pipe[0]);
979 var h: TestHighlight = .{}; 1136 var h: TestHighlight = .{};
980 try renderClipped( 1137 try renderClipped(
981 alloc, 1138 alloc,
982 replica, 1139 g,
983 .{ .top = 0, .left = beside_off, .rows = 4, .cols = beside_width }, 1140 .{ .top = 0, .left = beside_off, .rows = 4, .cols = beside_width },
984 h.hl(), 1141 h.hl(),
985 null, 1142 null,
@@ -998,12 +1155,11 @@ test "paint: a highlight in an offset pane inverts inside the pane" {
998 } 1155 }
999 1156
1000 test "paint: a delta row wider than the pane stops at the pane's edge" { 1157 test "paint: a delta row wider than the pane stops at the pane's edge" {
1001 // The delta path appends the daemon's row bytes verbatim, and the 1158 // The daemon's rows are grid-wide. Every delta row is re-serialized at
1002 // daemon's rows are grid-wide. Verbatim is only safe while the pane is. 1159 // the pane's own width, so the surplus never reaches the rail.
1003 const alloc = std.testing.allocator; 1160 const alloc = std.testing.allocator;
1004 var replica = try Engine.init(alloc, .{ .cols = 30, .rows = 4 }); 1161 const g = try authoredGrid(alloc, 30, 4, "z" ** 30);
1005 defer replica.deinit(); 1162 defer g.deinit();
1006 replica.feed("z" ** 30);
1007 1163
1008 var payload: std.ArrayList(u8) = .empty; 1164 var payload: std.ArrayList(u8) = .empty;
1009 defer payload.deinit(alloc); 1165 defer payload.deinit(alloc);
@@ -1014,11 +1170,11 @@ test "paint: a delta row wider than the pane stops at the pane's edge" {
1014 .cursor_y = 0, 1170 .cursor_y = 0,
1015 .row_count = 1, 1171 .row_count = 1,
1016 }); 1172 });
1017 try proto.appendDeltaRow(&payload, alloc, 0, "\x1b[0m" ++ "z" ** 30); 1173 try proto.appendDeltaRow(&payload, alloc, 0, "");
1018 1174
1019 const pipe = try std.posix.pipe(); 1175 const pipe = try std.posix.pipe();
1020 defer std.posix.close(pipe[0]); 1176 defer std.posix.close(pipe[0]);
1021 try paintDeltaClipped(alloc, payload.items, replica, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, pipe[1]); 1177 try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, pipe[1]);
1022 std.posix.close(pipe[1]); 1178 std.posix.close(pipe[1]);
1023 var out: [8192]u8 = undefined; 1179 var out: [8192]u8 = undefined;
1024 const n = try std.posix.read(pipe[0], &out); 1180 const n = try std.posix.read(pipe[0], &out);
@@ -1029,17 +1185,19 @@ test "paint: a delta row wider than the pane stops at the pane's edge" {
1029 } 1185 }
1030 1186
1031 test "paint: a history row wider than the pane stops at the pane's edge" { 1187 test "paint: a history row wider than the pane stops at the pane's edge" {
1032 // `renderScrollback` splits the daemon's blob on row breaks and blits 1188 // `renderScrollback` re-serializes each fetched row at the pane's width:
1033 // each piece: same overrun, on the page the user reached for to copy. 1189 // same overrun, on the page the user reached for to copy.
1034 const alloc = std.testing.allocator; 1190 const alloc = std.testing.allocator;
1035 const pipe = try std.posix.pipe(); 1191 const pipe = try std.posix.pipe();
1036 defer std.posix.close(pipe[0]); 1192 defer std.posix.close(pipe[0]);
1037 // Six rows into a four-row tile: the [scroll] marker owns the first, so 1193 // Six rows into a four-row tile: the [scroll] marker owns the first, so
1038 // the second is the one with nothing but history on it, and the last two 1194 // the second is the one with nothing but history on it, and the last two
1039 // have nowhere to go but the tile below. 1195 // have nowhere to go but the tile below.
1040 const blob = "\x1b[0m" ++ "g" ** 30 ++ "\r\n" ++ "h" ** 30 ++ "\r\n" ++ "i" ** 30 ++ 1196 const blob = "g" ** 30 ++ "\r\n" ++ "h" ** 30 ++ "\r\n" ++ "i" ** 30 ++
1041 "\r\n" ++ "j" ** 30 ++ "\r\n" ++ "k" ** 30 ++ "\r\n" ++ "l" ** 30; 1197 "\r\n" ++ "j" ** 30 ++ "\r\n" ++ "k" ** 30 ++ "\r\n" ++ "l" ** 30;
1042 try renderScrollback(alloc, blob, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, 30, false, pipe[1]); 1198 const hist = try authoredGrid(alloc, 30, 6, blob);
1199 defer hist.deinit();
1200 try renderScrollback(alloc, hist.lines, 30, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, false, pipe[1]);
1043 std.posix.close(pipe[1]); 1201 std.posix.close(pipe[1]);
1044 var out: [8192]u8 = undefined; 1202 var out: [8192]u8 = undefined;
1045 const n = try std.posix.read(pipe[0], &out); 1203 const n = try std.posix.read(pipe[0], &out);
@@ -1065,13 +1223,12 @@ test "paint: a repainted row wider than the pane stops at the pane's edge" {
1065 // The drag path repaints single rows, and it is the one a selection 1223 // The drag path repaints single rows, and it is the one a selection
1066 // runs through on every motion report. 1224 // runs through on every motion report.
1067 const alloc = std.testing.allocator; 1225 const alloc = std.testing.allocator;
1068 var replica = try Engine.init(alloc, .{ .cols = 30, .rows = 4 }); 1226 const g = try authoredGrid(alloc, 30, 4, "\r\n" ++ "w" ** 30);
1069 defer replica.deinit(); 1227 defer g.deinit();
1070 replica.feed("\r\n" ++ "w" ** 30);
1071 1228
1072 const pipe = try std.posix.pipe(); 1229 const pipe = try std.posix.pipe();
1073 defer std.posix.close(pipe[0]); 1230 defer std.posix.close(pipe[0]);
1074 try renderRowsClipped(alloc, replica, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, &.{1}, pipe[1]); 1231 try renderRowsClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, &.{1}, pipe[1]);
1075 std.posix.close(pipe[1]); 1232 std.posix.close(pipe[1]);
1076 var out: [8192]u8 = undefined; 1233 var out: [8192]u8 = undefined;
1077 const n = try std.posix.read(pipe[0], &out); 1234 const n = try std.posix.read(pipe[0], &out);
src/tui/wallview.zig
Old New
@@ -10,7 +10,7 @@ const handoff = @import("client").handoff;
10 const askpass = @import("client").askpass; 10 const askpass = @import("client").askpass;
11 const spawn = @import("spawn"); 11 const spawn = @import("spawn");
12 const proxy = @import("proxy"); 12 const proxy = @import("proxy");
13 const Engine = @import("term").engine.Engine; 13 const grid_mod = @import("term").grid;
14 const paint = @import("paint.zig"); 14 const paint = @import("paint.zig");
15 const select = @import("select.zig"); 15 const select = @import("select.zig");
16 // Counters ride out through `Shared` because a detached pump never reaches 16 // Counters ride out through `Shared` because a detached pump never reaches
@@ -145,7 +145,7 @@ pub const Shared = struct {
145 sel: usize = 0, 145 sel: usize = 0,
146 /// Where the focused tile's last paint left the cursor. The cursor 146 /// Where the focused tile's last paint left the cursor. The cursor
147 /// belongs to the focus: an unfocused paint's last act puts it back here. 147 /// belongs to the focus: an unfocused paint's last act puts it back here.
148 cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, 148 cursor: grid_mod.CursorPos = .{ .x = 0, .y = 0 },
149 /// The container tree that owns every tile's rect. The keyboard thread 149 /// The container tree that owns every tile's rect. The keyboard thread
150 /// mutates it under `paint_mu` — the same single-writer rule as `sel` 150 /// mutates it under `paint_mu` — the same single-writer rule as `sel`
151 /// — and relayout flattens it to read rects. 151 /// — and relayout flattens it to read rects.
test/wsclient.zig
Old New
@@ -27,6 +27,8 @@
27 //! [--origin STR] < script 27 //! [--origin STR] < script
28 const std = @import("std"); 28 const std = @import("std");
29 const Engine = @import("term").engine.Engine; 29 const Engine = @import("term").engine.Engine;
30 const Grid = @import("term").grid.Grid;
31 const delta_mod = @import("term").delta;
30 const Replica = @import("term").replica.Replica; 32 const Replica = @import("term").replica.Replica;
31 const proto = @import("term").protocol; 33 const proto = @import("term").protocol;
32 // The script dialect this fixture and ptyclient both speak: the escape 34 // The script dialect this fixture and ptyclient both speak: the escape
@@ -420,9 +422,9 @@ pub fn main() !void {
420 fatal(EXIT_DIED, "sec-websocket-accept mismatch", .{}); 422 fatal(EXIT_DIED, "sec-websocket-accept mismatch", .{});
421 423
422 // --- replica + client --- 424 // --- replica + client ---
423 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 425 const g = try Grid.init(alloc, 80, 24);
424 defer eng.deinit(); 426 defer g.deinit();
425 var cl = Client{ .alloc = alloc, .sock = sock, .rep = Replica.init(alloc, eng) }; 427 var cl = Client{ .alloc = alloc, .sock = sock, .rep = Replica.init(alloc, g) };
426 defer cl.reader.buf.deinit(alloc); 428 defer cl.reader.buf.deinit(alloc);
427 // Bytes past the head are the first WS frames. 429 // Bytes past the head are the first WS frames.
428 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]); 430 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]);
@@ -488,7 +490,7 @@ pub fn main() !void {
488 defer alloc.free(needle); 490 defer alloc.free(needle);
489 const deadline = nowMs() + ms; 491 const deadline = nowMs() + ms;
490 while (true) { 492 while (true) {
491 const dump = cl.rep.eng.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{}); 493 const dump = cl.rep.grid.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{});
492 const hit = std.mem.indexOf(u8, dump, needle) != null; 494 const hit = std.mem.indexOf(u8, dump, needle) != null;
493 alloc.free(@constCast(dump)); 495 alloc.free(@constCast(dump));
494 if (hit) break; 496 if (hit) break;
@@ -571,7 +573,7 @@ pub fn main() !void {
571 if (cl.rep.last_seq != before) last_traffic = nowMs(); 573 if (cl.rep.last_seq != before) last_traffic = nowMs();
572 } 574 }
573 } else if (std.mem.eql(u8, verb, "dumpexit")) { 575 } else if (std.mem.eql(u8, verb, "dumpexit")) {
574 const dump = cl.rep.eng.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{}); 576 const dump = cl.rep.grid.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{});
575 defer alloc.free(@constCast(dump)); 577 defer alloc.free(@constCast(dump));
576 const out = std.fs.cwd().createFile(op, .{}) catch fatal(EXIT_USAGE, "cannot open --out", .{}); 578 const out = std.fs.cwd().createFile(op, .{}) catch fatal(EXIT_USAGE, "cannot open --out", .{});
577 defer out.close(); 579 defer out.close();
@@ -664,9 +666,9 @@ test "the resync re-attach quotes the TILE's size, never the grid it learned" {
664 defer std.posix.close(fds[0]); 666 defer std.posix.close(fds[0]);
665 defer std.posix.close(fds[1]); 667 defer std.posix.close(fds[1]);
666 668
667 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 669 const g = try Grid.init(alloc, 80, 24);
668 defer eng.deinit(); 670 defer g.deinit();
669 var cl = Client{ .alloc = alloc, .sock = fds[1], .rep = Replica.init(alloc, eng) }; 671 var cl = Client{ .alloc = alloc, .sock = fds[1], .rep = Replica.init(alloc, g) };
670 defer cl.reader.buf.deinit(alloc); 672 defer cl.reader.buf.deinit(alloc);
671 673
672 // Named, so the resync's re-attach is pinned to land on the same 674 // Named, so the resync's re-attach is pinned to land on the same
@@ -674,28 +676,28 @@ test "the resync re-attach quotes the TILE's size, never the grid it learned" {
674 // session would be diffing a different terminal from then on. 676 // session would be diffing a different terminal from then on.
675 cl.sendAttach(1, 1, false, "b"); 677 cl.sendAttach(1, 1, false, "b");
676 678
677 // The daemon's answer: a unicast snapshot carrying the true grid. 679 // The daemon's answer: a unicast snapshot carrying the true grid, built
678 var snap: std.ArrayList(u8) = .empty; 680 // the way the daemon builds it so the fixture cannot drift from the wire.
679 defer snap.deinit(alloc); 681 const daemon_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
680 try snap.appendSlice(alloc, &[_]u8{ 0x00, @intFromEnum(proto.MsgType.snapshot), 0, 0, 0, 0 }); 682 defer daemon_eng.deinit();
681 const state = try eng.dumpState(alloc); 683 const snap_body = try delta_mod.buildSnapshot(alloc, daemon_eng, .{
682 defer alloc.free(state);
683 const snap_payload_len = proto.snapshot_prefix_len + state.len;
684 try snap.appendNTimes(alloc, 0, proto.snapshot_prefix_len);
685 proto.writeSnapshotPrefix(snap.items[6..][0..proto.snapshot_prefix_len], .{
686 .seq = 7, 684 .seq = 7,
687 .history_rows = 0, 685 .history_rows = 0,
688 .cols = 80, 686 .cols = 80,
689 .rows = 24, 687 .rows = 24,
690 .epoch = 3, 688 .epoch = 3,
691 }); 689 });
692 try snap.appendSlice(alloc, state); 690 defer alloc.free(snap_body);
693 std.mem.writeInt(u32, snap.items[2..6], @intCast(snap_payload_len), .little); 691 var snap: std.ArrayList(u8) = .empty;
692 defer snap.deinit(alloc);
693 try snap.appendSlice(alloc, &[_]u8{ 0x00, @intFromEnum(proto.MsgType.snapshot), 0, 0, 0, 0 });
694 try snap.appendSlice(alloc, snap_body);
695 std.mem.writeInt(u32, snap.items[2..6], @intCast(snap_body.len), .little);
694 cl.handle(.{ .opcode = 0x2, .payload = snap.items, .consumed = snap.items.len }); 696 cl.handle(.{ .opcode = 0x2, .payload = snap.items, .consumed = snap.items.len });
695 try std.testing.expectEqual(@as(u16, 80), cl.rep.grid.cols); 697 try std.testing.expectEqual(@as(u16, 80), cl.rep.grid.cols);
696 698
697 // A delta whose header claims two rows and whose payload carries one: 699 // A delta whose header claims two rows and whose payload carries one:
698 // composeDelta rejects it and Replica reports .resync. 700 // the row_count check refuses it and Replica reports .resync.
699 var body: std.ArrayList(u8) = .empty; 701 var body: std.ArrayList(u8) = .empty;
700 defer body.deinit(alloc); 702 defer body.deinit(alloc);
701 try proto.appendDeltaHeader(&body, alloc, .{ 703 try proto.appendDeltaHeader(&body, alloc, .{
@@ -741,39 +743,57 @@ test "the resync re-attach quotes the TILE's size, never the grid it learned" {
741 } 743 }
742 744
743 test "the dump this exits with is the daemon's own dump format" { 745 test "the dump this exits with is the daemon's own dump format" {
744 // dumpexit writes Engine.dumpPlain — the SAME function mux d dump 746 // dumpexit writes the replica's dumpPlain, and the e2e diff compares it
745 // prints through — so the e2e diff cannot fail on formatting. The 747 // against what `mux d dump` prints, so the two must not differ on
746 // pin: feed both a daemon-side engine and this fixture's replica the 748 // formatting. The pin: build a real snapshot from a daemon-side engine,
747 // same snapshot; byte-identical dumps. 749 // apply it to this fixture's replica, and compare the dumps.
750 //
751 // Through a trailing-space trim on the ENGINE side, and only that: a
752 // daemon dumps with ghostty's trimming off, while a client never holds a
753 // trailing space — the encoder stops a row at its last non-blank cell,
754 // and the VT formatter that fed the old wire trimmed in the same place.
755 // `test/e2e_lib.sh assert_ws_converged` strips it on both sides too.
748 const alloc = std.testing.allocator; 756 const alloc = std.testing.allocator;
749 var daemon_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 757 const daemon_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
750 defer daemon_eng.deinit(); 758 defer daemon_eng.deinit();
751 daemon_eng.feed("convergence\r\nby construction"); 759 daemon_eng.feed("convergence \r\nby construction");
752 760
753 var fixture_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 761 const fixture_grid = try Grid.init(alloc, 80, 24);
754 defer fixture_eng.deinit(); 762 defer fixture_grid.deinit();
755 var rep = Replica.init(alloc, fixture_eng); 763 var rep = Replica.init(alloc, fixture_grid);
756 const state = try daemon_eng.dumpState(alloc); 764 const payload = try delta_mod.buildSnapshot(alloc, daemon_eng, .{
757 defer alloc.free(state);
758 var payload = try alloc.alloc(u8, proto.snapshot_prefix_len + state.len);
759 defer alloc.free(payload);
760 proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{
761 .seq = 1, 765 .seq = 1,
762 .history_rows = 0, 766 .history_rows = 0,
763 .cols = 80, 767 .cols = 80,
764 .rows = 24, 768 .rows = 24,
765 .epoch = 1, 769 .epoch = 1,
766 }); 770 });
767 @memcpy(payload[proto.snapshot_prefix_len..], state); 771 defer alloc.free(payload);
768 _ = try rep.apply(.snapshot, payload); 772 _ = try rep.apply(.snapshot, payload);
769 773
770 const a = try daemon_eng.dumpPlain(alloc); 774 const raw = try daemon_eng.dumpPlain(alloc);
775 defer alloc.free(raw);
776 const a = try trimRowTails(alloc, raw);
771 defer alloc.free(a); 777 defer alloc.free(a);
772 const b = try fixture_eng.dumpPlain(alloc); 778 const b = try fixture_grid.dumpPlain(alloc);
773 defer alloc.free(b); 779 defer alloc.free(b);
774 try std.testing.expectEqualStrings(a, b); 780 try std.testing.expectEqualStrings(a, b);
775 } 781 }
776 782
783 /// A copy of `text` with each row's trailing spaces removed.
784 fn trimRowTails(alloc: std.mem.Allocator, text: []const u8) ![]u8 {
785 var out: std.ArrayList(u8) = .empty;
786 errdefer out.deinit(alloc);
787 var it = std.mem.splitScalar(u8, text, '\n');
788 var first = true;
789 while (it.next()) |line| {
790 if (!first) try out.append(alloc, '\n');
791 first = false;
792 try out.appendSlice(alloc, std.mem.trimRight(u8, line, " "));
793 }
794 return out.toOwnedSlice(alloc);
795 }
796
777 // Forces semantic analysis of every pub decl under `zig build test`, so an 797 // Forces semantic analysis of every pub decl under `zig build test`, so an
778 // unreferenced decl must at least compile (the silent-module-loss hazard, 798 // unreferenced decl must at least compile (the silent-module-loss hazard,
779 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 799 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
web/mux.js
Old New
@@ -12,7 +12,7 @@
12 const MSG = { 12 const MSG = {
13 attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05, 13 attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05,
14 selection_req: 0x0b, 14 selection_req: 0x0b,
15 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, 15 snapshot: 0x95, exit_status: 0x82, scrollback_chunk: 0x97, delta: 0x96,
16 pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90, 16 pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90,
17 }; 17 };
18 const CLIENT_ACTION = { 18 const CLIENT_ACTION = {
@@ -766,9 +766,10 @@ class Tile {
766 // replies must never relabel the cells currently on the canvas. 766 // replies must never relabel the cells currently on the canvas.
767 const request = this.scrollRequest; 767 const request = this.scrollRequest;
768 if (!request || start !== request.start || count !== request.count) return; 768 if (!request || start !== request.start || count !== request.count) return;
769 const rows = payload.subarray(6); // echoed start+count stripped 769 // The WHOLE chunk, echoed header included: the rows are CellRows and
770 if (!this.stage(rows)) { this.scrollRequestFailed(request); return; } 770 // only that header says how many of them there are.
771 if (this.core.mux_scroll_feed(rows.length) === 0) { 771 if (!this.stage(payload)) { this.scrollRequestFailed(request); return; }
772 if (this.core.mux_scroll_feed(payload.length) === 0) {
772 this.clearScrollRequestTimer(); 773 this.clearScrollRequestTimer();
773 this.scrollRequest = null; 774 this.scrollRequest = null;
774 this.viewStartRow = start; 775 this.viewStartRow = start;
web/verify.js
Old New
@@ -978,7 +978,7 @@ async function verifySelectionShell(shell, html) {
978 chunk.set(body, 6); 978 chunk.set(body, 6);
979 const envelope = new Uint8Array(6 + chunk.length); 979 const envelope = new Uint8Array(6 + chunk.length);
980 envelope[0] = 0; 980 envelope[0] = 0;
981 envelope[1] = 0x85; 981 envelope[1] = 0x97;
982 new DataView(envelope.buffer).setUint32(2, chunk.length, true); 982 new DataView(envelope.buffer).setUint32(2, chunk.length, true);
983 envelope.set(chunk, 6); 983 envelope.set(chunk, 6);
984 return envelope; 984 return envelope;
@@ -1146,7 +1146,7 @@ async function verifySelectionShell(shell, html) {
1146 const realSnapshotLivePaint = h.Tile.prototype.paintLive.bind(snapshotHistory.tile); 1146 const realSnapshotLivePaint = h.Tile.prototype.paintLive.bind(snapshotHistory.tile);
1147 snapshotHistory.tile.paintLive = () => { snapshotLivePaints++; realSnapshotLivePaint(); }; 1147 snapshotHistory.tile.paintLive = () => { snapshotLivePaints++; realSnapshotLivePaint(); };
1148 snapshotHistory.tile.paintScroll = () => { snapshotScrollPaints++; }; 1148 snapshotHistory.tile.paintScroll = () => { snapshotScrollPaints++; };
1149 snapshotHistory.tile.onMessage(frameEnvelope(0x81, Uint8Array.from([1, 2, 3]))); 1149 snapshotHistory.tile.onMessage(frameEnvelope(0x95, Uint8Array.from([1, 2, 3])));
1150 check('authoritative snapshot exits settled history mode', snapshotHistory.tile.scrollPages, 0); 1150 check('authoritative snapshot exits settled history mode', snapshotHistory.tile.scrollPages, 0);
1151 check('authoritative snapshot clears old-grid selection', snapshotHistory.tile.selection, null); 1151 check('authoritative snapshot clears old-grid selection', snapshotHistory.tile.selection, null);
1152 check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1); 1152 check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1);
@@ -1169,7 +1169,7 @@ async function verifySelectionShell(shell, html) {
1169 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid', 1169 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid',
1170 }; 1170 };
1171 failedSnapshot.tile.core.mux_apply_frame = () => -3; 1171 failedSnapshot.tile.core.mux_apply_frame = () => -3;
1172 failedSnapshot.tile.onMessage(frameEnvelope(0x81, Uint8Array.from([9]))); 1172 failedSnapshot.tile.onMessage(frameEnvelope(0x95, Uint8Array.from([9])));
1173 check('failed authoritative snapshot follows reset to live mode', failedSnapshot.tile.scrollPages, 0); 1173 check('failed authoritative snapshot follows reset to live mode', failedSnapshot.tile.scrollPages, 0);
1174 check('failed authoritative snapshot clears stale selection', failedSnapshot.tile.selection, null); 1174 check('failed authoritative snapshot clears stale selection', failedSnapshot.tile.selection, null);
1175 check('failed authoritative snapshot repaints through reset path once', failedSnapshot.reflows(), 1); 1175 check('failed authoritative snapshot repaints through reset path once', failedSnapshot.reflows(), 1);
@@ -1331,7 +1331,7 @@ async function verifySelectionShell(shell, html) {
1331 chunk.set([1, 2, 3], 6); 1331 chunk.set([1, 2, 3], 6);
1332 const envelope = new Uint8Array(6 + chunk.length); 1332 const envelope = new Uint8Array(6 + chunk.length);
1333 envelope[0] = 0; 1333 envelope[0] = 0;
1334 envelope[1] = 0x85; 1334 envelope[1] = 0x97;
1335 new DataView(envelope.buffer).setUint32(2, chunk.length, true); 1335 new DataView(envelope.buffer).setUint32(2, chunk.length, true);
1336 envelope.set(chunk, 6); 1336 envelope.set(chunk, 6);
1337 scrolling.tile.onMessage(envelope); 1337 scrolling.tile.onMessage(envelope);
@@ -1484,7 +1484,7 @@ async function verifySelectionShell(shell, html) {
1484 ...pointer(24, 4, 0), clientY: 10, 1484 ...pointer(24, 4, 0), clientY: 10,
1485 }); 1485 });
1486 h.intervals[malformedAuto.tile.selectionScrollTimer - 1].fn(); 1486 h.intervals[malformedAuto.tile.selectionScrollTimer - 1].fn();
1487 const malformedScroll = new Uint8Array([0, 0x85, 1, 0, 0, 0, 0]); 1487 const malformedScroll = new Uint8Array([0, 0x97, 1, 0, 0, 0, 0]);
1488 malformedAuto.tile.onMessage(malformedScroll); 1488 malformedAuto.tile.onMessage(malformedScroll);
1489 check('malformed matching scroll lane releases pending page intent', malformedAuto.tile.scrollPages, 0); 1489 check('malformed matching scroll lane releases pending page intent', malformedAuto.tile.scrollPages, 0);
1490 check('malformed matching scroll lane preserves painted start', malformedAuto.tile.viewStartRow, 30); 1490 check('malformed matching scroll lane preserves painted start', malformedAuto.tile.viewStartRow, 30);
@@ -2625,17 +2625,91 @@ async function verifyStatusShell(shell) {
2625 } 2625 }
2626 2626
2627 // --- wire builders (layouts golden-pinned in protocol.zig) --- 2627 // --- wire builders (layouts golden-pinned in protocol.zig) ---
2628 function snapshotPayload({ seq, history, cols, rows, epoch }, state) { 2628 //
2629 const stateBytes = Buffer.from(state, 'utf8'); 2629 // A row on the wire is a CellRow: a u16 cell count, then runs of cells. A run
2630 const b = Buffer.alloc(24 + stateBytes.length); 2630 // is a u16 count, a u8 mask of the style fields that CHANGED since the run
2631 b.writeBigUInt64LE(BigInt(seq), 0); 2631 // before it in the same row, those fields, and then the cells. Each cell is a
2632 b.writeUInt32LE(history, 8); 2632 // head byte of `wide << 6 | text_len` followed by its UTF-8, except in an
2633 b.writeUInt16LE(cols, 12); 2633 // all-ASCII run (mask bit 7), where every cell is one bare byte.
2634 b.writeUInt16LE(rows, 14); 2634 //
2635 b.writeBigUInt64LE(BigInt(epoch), 16); 2635 // Colours pack as 0 none, (1 << 24) | index palette, (2 << 24) | rgb; on the
2636 stateBytes.copy(b, 24); 2636 // wire a present colour is a tag byte, 0 none, 1 palette + one byte, 2 rgb +
2637 return b; 2637 // three. Style flags are ghostty's bit order: bold 0, italic 1, faint 2,
2638 // blink 3, inverse 4, invisible 5, strikethrough 6, overline 7.
2639 const MASK_FLAGS = 1 << 0, MASK_FG = 1 << 1, MASK_BG = 1 << 2, MASK_UL = 1 << 3;
2640 const MASK_ASCII = 1 << 7;
2641
2642 function colorBytes(packed) {
2643 const tag = packed >>> 24;
2644 if (tag === 0) return [0];
2645 if (tag === 1) return [1, packed & 0xff];
2646 return [2, (packed >>> 16) & 0xff, (packed >>> 8) & 0xff, packed & 0xff];
2647 }
2648
2649 /// One run of cells sharing a style. `cells` are {text, wide} — wide 0 narrow,
2650 /// 1 wide, 2 spacer_tail, 3 spacer_head.
2651 function runBytes(style, cells, prev) {
2652 const out = [];
2653 out.push(cells.length & 0xff, (cells.length >> 8) & 0xff);
2654 const ascii = cells.every((c) => c.wide === 0 && c.text.length === 1 &&
2655 c.text.charCodeAt(0) >= 0x20 && c.text.charCodeAt(0) <= 0x7e);
2656 let mask = ascii ? MASK_ASCII : 0;
2657 const fields = [];
2658 if (style.flags !== prev.flags) {
2659 mask |= MASK_FLAGS;
2660 fields.push(style.flags & 0xff, (style.flags >> 8) & 0xff);
2661 }
2662 if (style.fg !== prev.fg) { mask |= MASK_FG; fields.push(...colorBytes(style.fg)); }
2663 if (style.bg !== prev.bg) { mask |= MASK_BG; fields.push(...colorBytes(style.bg)); }
2664 if (style.ul !== prev.ul) { mask |= MASK_UL; fields.push(...colorBytes(style.ul)); }
2665 out.push(mask, ...fields);
2666 for (const c of cells) {
2667 const bytes = [...Buffer.from(c.text, 'utf8')];
2668 if (ascii) out.push(bytes[0]);
2669 else out.push((c.wide << 6) | bytes.length, ...bytes);
2670 }
2671 return out;
2672 }
2673
2674 /// A CellRow from a list of runs, each {style, cells}. Styles default to
2675 /// plain, so a caller states only what it means to exercise.
2676 function cellRow(runs) {
2677 const plain = { fg: 0, bg: 0, ul: 0, flags: 0 };
2678 let ncells = 0;
2679 for (const r of runs) ncells += r.cells.length;
2680 const out = [ncells & 0xff, (ncells >> 8) & 0xff];
2681 let prev = plain;
2682 for (const r of runs) {
2683 const style = { ...plain, ...(r.style || {}) };
2684 out.push(...runBytes(style, r.cells, prev));
2685 prev = style;
2686 }
2687 return Buffer.from(out);
2638 } 2688 }
2689
2690 /// A CellRow of plain narrow ASCII: the common case, one run.
2691 function textRow(text) {
2692 if (text.length === 0) return Buffer.from([0, 0]);
2693 return cellRow([{ cells: [...text].map((ch) => ({ text: ch, wide: 0 })) }]);
2694 }
2695
2696 function snapshotPayload({ seq, history, cols, rows, epoch, cx = 0, cy = 0 }, rowList) {
2697 const head = Buffer.alloc(28);
2698 head.writeBigUInt64LE(BigInt(seq), 0);
2699 head.writeUInt32LE(history, 8);
2700 head.writeUInt16LE(cols, 12);
2701 head.writeUInt16LE(rows, 14);
2702 head.writeBigUInt64LE(BigInt(epoch), 16);
2703 head.writeUInt16LE(cx, 24);
2704 head.writeUInt16LE(cy, 26);
2705 const body = [];
2706 for (let y = 0; y < rows; y++) {
2707 const r = rowList[y];
2708 body.push(r === undefined ? textRow('') : (Buffer.isBuffer(r) ? r : textRow(r)));
2709 }
2710 return Buffer.concat([head, ...body]);
2711 }
2712
2639 function deltaPayload({ seq, history, cx, cy }, rowEntries) { 2713 function deltaPayload({ seq, history, cx, cy }, rowEntries) {
2640 const parts = []; 2714 const parts = [];
2641 const hdr = Buffer.alloc(18); 2715 const hdr = Buffer.alloc(18);
@@ -2645,8 +2719,8 @@ function deltaPayload({ seq, history, cx, cy }, rowEntries) {
2645 hdr.writeUInt16LE(cy, 14); 2719 hdr.writeUInt16LE(cy, 14);
2646 hdr.writeUInt16LE(rowEntries.length, 16); 2720 hdr.writeUInt16LE(rowEntries.length, 16);
2647 parts.push(hdr); 2721 parts.push(hdr);
2648 for (const [row, text] of rowEntries) { 2722 for (const [row, content] of rowEntries) {
2649 const bytes = Buffer.from(text, 'utf8'); 2723 const bytes = Buffer.isBuffer(content) ? content : textRow(content);
2650 const rh = Buffer.alloc(6); 2724 const rh = Buffer.alloc(6);
2651 rh.writeUInt16LE(row, 0); 2725 rh.writeUInt16LE(row, 0);
2652 rh.writeUInt32LE(bytes.length, 2); 2726 rh.writeUInt32LE(bytes.length, 2);
@@ -2655,6 +2729,14 @@ function deltaPayload({ seq, history, cx, cy }, rowEntries) {
2655 return Buffer.concat(parts); 2729 return Buffer.concat(parts);
2656 } 2730 }
2657 2731
2732 /// A scrollback_chunk payload: the echoed start and count, then the rows.
2733 function scrollChunk(start, rowList) {
2734 const head = Buffer.alloc(6);
2735 head.writeUInt32LE(start, 0);
2736 head.writeUInt16LE(rowList.length, 4);
2737 return Buffer.concat([head, ...rowList.map((r) => (Buffer.isBuffer(r) ? r : textRow(r)))]);
2738 }
2739
2658 // Settings: the browser's own theme and font. Neither is a protocol 2740 // Settings: the browser's own theme and font. Neither is a protocol
2659 // concern — colour is invented at paint time from a palette INDEX, and 2741 // concern — colour is invented at paint time from a palette INDEX, and
2660 // the daemon has no notion of a font — with one exception this file 2742 // the daemon has no notion of a font — with one exception this file
@@ -3188,7 +3270,7 @@ async function main() {
3188 check('text encode guard after selection', e.mux_text_encode(e.mux_input_cap() + 1), -2); 3270 check('text encode guard after selection', e.mux_text_encode(e.mux_input_cap() + 1), -2);
3189 check('text encode guard clears selection', e.mux_selection_len(), 0); 3271 check('text encode guard clears selection', e.mux_selection_len(), 0);
3190 populateSelection(209); 3272 populateSelection(209);
3191 check('apply frame guard after selection', e.mux_apply_frame(0x81, e.mux_input_cap() + 1), -2); 3273 check('apply frame guard after selection', e.mux_apply_frame(0x95, e.mux_input_cap() + 1), -2);
3192 check('apply frame guard clears selection', e.mux_selection_len(), 0); 3274 check('apply frame guard clears selection', e.mux_selection_len(), 0);
3193 populateSelection(210); 3275 populateSelection(210);
3194 check('scroll feed guard after selection', e.mux_scroll_feed(e.mux_input_cap() + 1), -2); 3276 check('scroll feed guard after selection', e.mux_scroll_feed(e.mux_input_cap() + 1), -2);
@@ -3216,12 +3298,20 @@ async function main() {
3216 check('attach epoch0', att.readBigUInt64LE(12), 0n); 3298 check('attach epoch0', att.readBigUInt64LE(12), 0n);
3217 3299
3218 // --- snapshot: styled text, adoption, full damage --- 3300 // --- snapshot: styled text, adoption, full damage ---
3301 // `hello` bold on palette red, then a plain ` world`: two runs in one row,
3302 // so the second run's mask says what it CHANGED and nothing else.
3219 const snap = snapshotPayload( 3303 const snap = snapshotPayload(
3220 { seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn }, 3304 { seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn, cx: 11, cy: 0 },
3221 '\x1b[1;31mhello\x1b[0m world', 3305 [cellRow([
3306 {
3307 style: { flags: 1 << 0, fg: (1 << 24) | 1 },
3308 cells: [...'hello'].map((ch) => ({ text: ch, wide: 0 })),
3309 },
3310 { cells: [...' world'].map((ch) => ({ text: ch, wide: 0 })) },
3311 ])],
3222 ); 3312 );
3223 populateSelection(215); 3313 populateSelection(215);
3224 check('apply snapshot', e.mux_apply_frame(0x81, stageThroughKnownPtr(snap)), 0); 3314 check('apply snapshot', e.mux_apply_frame(0x95, stageThroughKnownPtr(snap)), 0);
3225 check('apply snapshot clears borrowed selection', e.mux_selection_len(), 0); 3315 check('apply snapshot clears borrowed selection', e.mux_selection_len(), 0);
3226 check('seq adopted', resumeArgs().seq, 7n); 3316 check('seq adopted', resumeArgs().seq, 7n);
3227 check('epoch adopted', resumeArgs().epoch, 0xabcdn); 3317 check('epoch adopted', resumeArgs().epoch, 0xabcdn);
@@ -3232,14 +3322,17 @@ async function main() {
3232 check('cell h fg palette red', cell(0, 0).fg, (1 << 24) | 1); 3322 check('cell h fg palette red', cell(0, 0).fg, (1 << 24) | 1);
3233 check('cell w plain', cell(6, 0).flags & 0xffff, 0); 3323 check('cell w plain', cell(6, 0).flags & 0xffff, 0);
3234 check('cell w fg none', cell(6, 0).fg, 0); 3324 check('cell w fg none', cell(6, 0).fg, 0);
3235 check('cursor x', e.mux_cursor_x(), 11); // 11 visible cols; SGRs move nothing 3325 check('cursor x', e.mux_cursor_x(), 11); // where the snapshot's own cursor says
3236 3326
3237 // --- delta: rows 0 and 2 repainted, damage = their rows + cursor rows --- 3327 // --- delta: rows 0 and 2 repainted, damage = their rows + cursor rows ---
3238 const delta = deltaPayload( 3328 const delta = deltaPayload(
3239 { seq: 8, history: 4, cx: 2, cy: 2 }, 3329 { seq: 8, history: 4, cx: 2, cy: 2 },
3240 [[0, '\x1b[7myo\x1b[0m'], [2, 'row two']], 3330 [
3331 [0, cellRow([{ style: { flags: 1 << 4 }, cells: [...'yo'].map((ch) => ({ text: ch, wide: 0 })) }])],
3332 [2, 'row two'],
3333 ],
3241 ); 3334 );
3242 check('apply delta', e.mux_apply_frame(0x87, stage(delta)), 0); 3335 check('apply delta', e.mux_apply_frame(0x96, stage(delta)), 0);
3243 check('seq advanced', resumeArgs().seq, 8n); 3336 check('seq advanced', resumeArgs().seq, 8n);
3244 check('history follows', e.mux_history_rows(), 4); 3337 check('history follows', e.mux_history_rows(), 4);
3245 const ndirty = e.mux_read_viewport(); 3338 const ndirty = e.mux_read_viewport();
@@ -3267,31 +3360,38 @@ async function main() {
3267 // --- resync path: header lies about row count --- 3360 // --- resync path: header lies about row count ---
3268 const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]); 3361 const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]);
3269 bad.writeUInt16LE(2, 16); // row_count claims 2 3362 bad.writeUInt16LE(2, 16); // row_count claims 2
3270 check('resync', e.mux_apply_frame(0x87, stage(bad)), 1); 3363 check('resync', e.mux_apply_frame(0x96, stage(bad)), 1);
3271 check('resync holds seq', resumeArgs().seq, 8n); 3364 check('resync holds seq', resumeArgs().seq, 8n);
3272 3365
3273 // --- bad frames --- 3366 // --- bad frames ---
3274 check('not replay frame', e.mux_apply_frame(0x88, 1), -3); 3367 check('not replay frame', e.mux_apply_frame(0x88, 1), -3);
3275 check('unknown type', e.mux_apply_frame(0x40, 0), -3); 3368 check('unknown type', e.mux_apply_frame(0x40, 0), -3);
3276 check('short snapshot', e.mux_apply_frame(0x81, 10), -3); 3369 check('short snapshot', e.mux_apply_frame(0x95, 10), -3);
3277 3370
3278 // --- grid move via snapshot: readout follows, all dirty --- 3371 // --- grid move via snapshot: readout follows, all dirty ---
3279 const wide = snapshotPayload( 3372 const wide = snapshotPayload(
3280 { seq: 10, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, 3373 { seq: 10, history: 0, cols: 100, rows: 30, epoch: 0xabcdn },
3281 'wide', 3374 ['wide'],
3282 ); 3375 );
3283 check('apply wide', e.mux_apply_frame(0x81, stage(wide)), 0); 3376 check('apply wide', e.mux_apply_frame(0x95, stage(wide)), 0);
3284 check('cols follow', e.mux_cols(), 100); 3377 check('cols follow', e.mux_cols(), 100);
3285 check('rows follow', e.mux_rows(), 30); 3378 check('rows follow', e.mux_rows(), 30);
3286 check('grid move dirties all', e.mux_read_viewport(), 30); 3379 check('grid move dirties all', e.mux_read_viewport(), 30);
3287 check('cell after move', cell(0, 0).cp, 'w'.codePointAt(0)); 3380 check('cell after move', cell(0, 0).cp, 'w'.codePointAt(0));
3288 3381
3289 // --- wide CJK: wide flag + spacer --- 3382 // --- wide CJK: wide flag + spacer ---
3383 // A wide glyph, its spacer, then a second wide glyph: the head byte's
3384 // `wide` field is the only thing that says which is which.
3290 const cjk = snapshotPayload( 3385 const cjk = snapshotPayload(
3291 { seq: 11, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, 3386 { seq: 11, history: 0, cols: 100, rows: 30, epoch: 0xabcdn },
3292 '漢字', 3387 [cellRow([{ cells: [
3388 { text: '漢', wide: 1 },
3389 { text: '', wide: 2 },
3390 { text: '字', wide: 1 },
3391 { text: '', wide: 2 },
3392 ] }])],
3293 ); 3393 );
3294 e.mux_apply_frame(0x81, stage(cjk)); 3394 e.mux_apply_frame(0x95, stage(cjk));
3295 e.mux_read_viewport(); 3395 e.mux_read_viewport();
3296 check('cjk cp', cell(0, 0).cp, 0x6f22); 3396 check('cjk cp', cell(0, 0).cp, 0x6f22);
3297 check('cjk wide', cell(0, 0).flags & (1 << 16), 1 << 16); 3397 check('cjk wide', cell(0, 0).flags & (1 << 16), 1 << 16);
@@ -3339,8 +3439,9 @@ async function main() {
3339 // --- scroll scratch: never touches the live replica --- 3439 // --- scroll scratch: never touches the live replica ---
3340 check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates 3440 check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates
3341 populateSelection(217); 3441 populateSelection(217);
3342 stageThroughKnownPtr(Buffer.from('old history line')); 3442 const historyChunk = scrollChunk(0, ['old history line']);
3343 check('scroll feed', e.mux_scroll_feed(16), 0); 3443 stageThroughKnownPtr(historyChunk);
3444 check('scroll feed', e.mux_scroll_feed(historyChunk.length), 0);
3344 check('scroll feed clears borrowed selection', e.mux_selection_len(), 0); 3445 check('scroll feed clears borrowed selection', e.mux_selection_len(), 0);
3345 check('scroll read', e.mux_read_scroll_viewport(), 30); 3446 check('scroll read', e.mux_read_scroll_viewport(), 30);
3346 check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0)); 3447 check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0));
@@ -3614,7 +3715,7 @@ async function main() {
3614 check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); 3715 check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection);
3615 check('selection populated before lifecycle reset', e.mux_selection_len(), 5); 3716 check('selection populated before lifecycle reset', e.mux_selection_len(), 5);
3616 e.mux_deinit(); 3717 e.mux_deinit();
3617 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); 3718 check('apply after deinit', e.mux_apply_frame(0x95, 0), -1);
3618 check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1); 3719 check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1);
3619 check('selection id after deinit', e.mux_selection_id(), 0); 3720 check('selection id after deinit', e.mux_selection_id(), 0);
3620 check('selection status after deinit', e.mux_selection_status(), 3); 3721 check('selection status after deinit', e.mux_selection_status(), 3);