3c7beb0b
feat: every painter reads the grid, and the two browser clients hold one
a73x 2026-09-04 18:04
Commit message
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); |