63427849
feat: the client grid, pinned against the engine as its oracle
a73x 2026-09-04 18:04
Commit message
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,10 @@ | |||
| 4 | const std = @import("std"); | 4 | const std = @import("std"); |
| 5 | const vt = @import("ghostty-vt"); | 5 | const vt = @import("ghostty-vt"); |
| 6 | const proto = @import("protocol.zig"); | 6 | const proto = @import("protocol.zig"); |
| 7 | /// The client-side grid this engine is the oracle for. A direct file import | ||
| 8 | /// rather than the `term` root, so `grid.zig` never pulls ghostty into the | ||
| 9 | /// wasm build by importing back. | ||
| 10 | const Grid = @import("grid.zig").Grid; | ||
| 7 | 11 | ||
| 8 | /// MuxHandler's own `vt` method shadows the `vt` import inside its body, | 12 | /// MuxHandler's own `vt` method shadows the `vt` import inside its body, |
| 9 | /// so the dep types it names are spelled through these aliases. | 13 | /// so the dep types it names are spelled through these aliases. |
| @@ -350,7 +354,7 @@ pub const Engine = struct { | |||
| 350 | /// The last grid column of row `y` that fits in `view`, or null when not one | 354 | /// The last grid column of row `y` that fits in `view`, or null when not one |
| 351 | /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and | 355 | /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and |
| 352 | /// half a wide glyph past it is a column stolen from the neighbour. | 356 | /// half a wide glyph past it is a column stolen from the neighbour. |
| 353 | fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 { | 357 | pub fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 { |
| 354 | if (view.cols == 0 or self.term.cols == 0) return null; | 358 | if (view.cols == 0 or self.term.cols == 0) return null; |
| 355 | var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1))); | 359 | var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1))); |
| 356 | const screen = self.term.screens.active; | 360 | const screen = self.term.screens.active; |
| @@ -375,7 +379,7 @@ pub const Engine = struct { | |||
| 375 | /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three | 379 | /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three |
| 376 | /// pieces emits a straddling one TWICE. Outwards: half a character under the | 380 | /// pieces emits a straddling one TWICE. Outwards: half a character under the |
| 377 | /// pointer means the character is under the pointer. | 381 | /// pointer means the character is under the pointer. |
| 378 | fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { | 382 | pub fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { |
| 379 | const screen = self.term.screens.active; | 383 | const screen = self.term.screens.active; |
| 380 | const last: u16 = @intCast(self.term.cols - 1); | 384 | const last: u16 = @intCast(self.term.cols - 1); |
| 381 | var lo = from; | 385 | var lo = from; |
| @@ -695,6 +699,22 @@ pub const Engine = struct { | |||
| 695 | return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) }; | 699 | return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) }; |
| 696 | } | 700 | } |
| 697 | 701 | ||
| 702 | /// Encode every viewport row into `g` — the test bridge between an | ||
| 703 | /// authored screen and the grid a client would hold, and the only way a | ||
| 704 | /// harness gets a Grid from bytes without a daemon. | ||
| 705 | pub fn mirrorInto(self: *Engine, g: *Grid) !void { | ||
| 706 | if (g.cols != self.term.cols or g.rows != self.term.rows) | ||
| 707 | try g.resize(@intCast(self.term.cols), @intCast(self.term.rows)); | ||
| 708 | var y: u16 = 0; | ||
| 709 | while (y < self.term.rows) : (y += 1) { | ||
| 710 | const row = try self.encodeViewportRow(self.alloc, y); | ||
| 711 | defer self.alloc.free(row); | ||
| 712 | try g.applyRow(y, row); | ||
| 713 | } | ||
| 714 | const cur = self.cursorPos(); | ||
| 715 | g.cursor = .{ .x = cur.x, .y = cur.y }; | ||
| 716 | } | ||
| 717 | |||
| 698 | /// Screen-space rows, zero being the oldest retained. Formatting writes | 718 | /// Screen-space rows, zero being the oldest retained. Formatting writes |
| 699 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. | 719 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. |
| 700 | pub fn extractSelection( | 720 | pub fn extractSelection( |
| @@ -1976,3 +1996,91 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" { | |||
| 1976 | std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) }); | 1996 | std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) }); |
| 1977 | } | 1997 | } |
| 1978 | } | 1998 | } |
| 1999 | |||
| 2000 | /// The engine's plain dump with each row's trailing spaces removed, which is | ||
| 2001 | /// the form a client can hold. ghostty dumps with `trim = false`, so a space | ||
| 2002 | /// a program actually wrote at the end of a row survives into | ||
| 2003 | /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter | ||
| 2004 | /// that fed the old wire trims trailing whitespace (see `dumpVtRowSpan`) and | ||
| 2005 | /// the e2e convergence diff strips it as a formatting difference between two | ||
| 2006 | /// correct grids. The grid trims per row for the same reason, so the oracle | ||
| 2007 | /// compares through the same trim rather than pretending the two dumps agree | ||
| 2008 | /// on bytes nothing downstream distinguishes. | ||
| 2009 | fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 { | ||
| 2010 | var out: std.ArrayList(u8) = .empty; | ||
| 2011 | errdefer out.deinit(alloc); | ||
| 2012 | var it = std.mem.splitScalar(u8, s, '\n'); | ||
| 2013 | var first = true; | ||
| 2014 | while (it.next()) |line| { | ||
| 2015 | if (!first) try out.append(alloc, '\n'); | ||
| 2016 | first = false; | ||
| 2017 | try out.appendSlice(alloc, std.mem.trimRight(u8, line, " ")); | ||
| 2018 | } | ||
| 2019 | return out.toOwnedSlice(alloc); | ||
| 2020 | } | ||
| 2021 | |||
| 2022 | test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" { | ||
| 2023 | const alloc = std.testing.allocator; | ||
| 2024 | const screens = [_][]const u8{ | ||
| 2025 | "plain text\r\nsecond line", | ||
| 2026 | "w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end", | ||
| 2027 | "\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K", | ||
| 2028 | "short\r\n\r\n\r\nafter blanks", | ||
| 2029 | "\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here", | ||
| 2030 | "line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail | ||
| 2031 | "\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H", | ||
| 2032 | "hello \r\nworld ", // typed default spaces at both row ends | ||
| 2033 | "\x1b[1mbold \x1b[0m", // a styled trailing space: text on the wire, not a blank | ||
| 2034 | "\x1b[44mpad \x1b[0m", // a bg-coloured pad, the shape every status bar ends with | ||
| 2035 | }; | ||
| 2036 | for (screens) |s| { | ||
| 2037 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 2038 | defer e.deinit(); | ||
| 2039 | e.feed(s); | ||
| 2040 | const g = try Grid.init(alloc, 1, 1); | ||
| 2041 | defer g.deinit(); | ||
| 2042 | try e.mirrorInto(g); | ||
| 2043 | const raw = try e.dumpPlain(alloc); | ||
| 2044 | defer alloc.free(raw); | ||
| 2045 | const want = try trimRowTails(alloc, raw); | ||
| 2046 | defer alloc.free(want); | ||
| 2047 | const got = try g.dumpPlain(alloc); | ||
| 2048 | defer alloc.free(got); | ||
| 2049 | try std.testing.expectEqualStrings(want, got); | ||
| 2050 | try std.testing.expectEqual(e.cursorPos().x, g.cursor.x); | ||
| 2051 | try std.testing.expectEqual(e.cursorPos().y, g.cursor.y); | ||
| 2052 | } | ||
| 2053 | } | ||
| 2054 | |||
| 2055 | test "grid oracle: clipCol and snapWide agree with the engine's on rows with wide glyphs" { | ||
| 2056 | const alloc = std.testing.allocator; | ||
| 2057 | var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 }); | ||
| 2058 | defer e.deinit(); | ||
| 2059 | // Two rows whose wide glyphs sit in different columns, so an answer read | ||
| 2060 | // off row 0 is wrong for row 1 and the sweep says which row it asked. | ||
| 2061 | e.feed("\u{6f22}a\u{5b57}b\r\na\u{6f22}b\u{5b57}"); | ||
| 2062 | const g = try Grid.init(alloc, 1, 1); | ||
| 2063 | defer g.deinit(); | ||
| 2064 | try e.mirrorInto(g); | ||
| 2065 | // A pane that starts at screen column 7: both sides answer in GRID | ||
| 2066 | // columns, so the offset must not reach the answer. | ||
| 2067 | const col_off: u16 = 7; | ||
| 2068 | var y: u16 = 0; | ||
| 2069 | while (y < 2) : (y += 1) { | ||
| 2070 | var cols: u16 = 0; | ||
| 2071 | while (cols <= 10) : (cols += 1) { | ||
| 2072 | const view = Engine.RowView{ .col_off = col_off, .cols = cols }; | ||
| 2073 | try std.testing.expectEqual(e.clipCol(y, view), g.clipCol(y, .{ .col_off = col_off, .cols = cols })); | ||
| 2074 | } | ||
| 2075 | var from: u16 = 0; | ||
| 2076 | while (from < 6) : (from += 1) { | ||
| 2077 | var to: u16 = from; | ||
| 2078 | while (to < 6) : (to += 1) { | ||
| 2079 | const a = e.snapWide(y, from, to); | ||
| 2080 | const b = g.snapWide(y, from, to); | ||
| 2081 | try std.testing.expectEqual(a.from, b.from); | ||
| 2082 | try std.testing.expectEqual(a.to, b.to); | ||
| 2083 | } | ||
| 2084 | } | ||
| 2085 | } | ||
| 2086 | } | ||
src/engine/grid.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,338 @@ | |||
| 1 | //! The client's grid: what a replica holds instead of an emulator. Cells | ||
| 2 | //! arrive already parsed (protocol.CellRow) and are copied into rows; the | ||
| 3 | //! only rules here are the two wide-glyph rules a painter needs and the | ||
| 4 | //! plain-text dump every harness compares against the daemon's. | ||
| 5 | //! | ||
| 6 | //! Deliberately platform-free: no posix, no fds, no clocks, no escape | ||
| 7 | //! bytes — this compiles for wasm32-freestanding and sits under rule 4. | ||
| 8 | const std = @import("std"); | ||
| 9 | const proto = @import("protocol.zig"); | ||
| 10 | |||
| 11 | pub const CursorPos = struct { x: u16, y: u16 }; | ||
| 12 | |||
| 13 | pub const Cell = struct { | ||
| 14 | style: proto.CellStyle = .{}, | ||
| 15 | wide: proto.Wide = .narrow, | ||
| 16 | text_off: u32 = 0, | ||
| 17 | text_len: u8 = 0, | ||
| 18 | |||
| 19 | pub fn isBlank(self: Cell) bool { | ||
| 20 | return self.text_len == 0 and self.wide == .narrow and self.style.isDefault(); | ||
| 21 | } | ||
| 22 | }; | ||
| 23 | |||
| 24 | pub const Row = struct { | ||
| 25 | cells: []Cell, | ||
| 26 | /// Every cell's text, back to back; a cell slices into it. | ||
| 27 | text: std.ArrayListUnmanaged(u8) = .empty, | ||
| 28 | |||
| 29 | pub fn textOf(self: *const Row, c: Cell) []const u8 { | ||
| 30 | return self.text.items[c.text_off .. c.text_off + c.text_len]; | ||
| 31 | } | ||
| 32 | |||
| 33 | fn blank(self: *Row) void { | ||
| 34 | @memset(self.cells, .{}); | ||
| 35 | self.text.clearRetainingCapacity(); | ||
| 36 | } | ||
| 37 | |||
| 38 | pub fn deinit(self: *Row, alloc: std.mem.Allocator) void { | ||
| 39 | alloc.free(self.cells); | ||
| 40 | self.text.deinit(alloc); | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | pub const RowView = struct { col_off: u16, cols: u16 }; | ||
| 45 | |||
| 46 | /// Decode one CellRow into `into` (already sized to `cols`). Two passes on | ||
| 47 | /// purpose — validate, then fill — so a bad payload leaves the row as it | ||
| 48 | /// was without a scratch copy: the caller resyncs from a snapshot, and half | ||
| 49 | /// a row would paint as a lie until then. | ||
| 50 | pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 { | ||
| 51 | if (cols > proto.max_cols) return error.BadPayload; | ||
| 52 | var check = try proto.CellRowReader.init(bytes); | ||
| 53 | if (check.ncells > cols) return error.BadPayload; | ||
| 54 | var text_total: usize = 0; | ||
| 55 | while (try check.next()) |c| text_total += c.text.len; | ||
| 56 | // Every byte has been read once and is well-formed; nothing below can fail | ||
| 57 | // except the allocation, which happens before the row is touched. | ||
| 58 | try into.text.ensureTotalCapacity(alloc, text_total); | ||
| 59 | into.blank(); | ||
| 60 | var r = proto.CellRowReader.init(bytes) catch unreachable; | ||
| 61 | var x: usize = 0; | ||
| 62 | while (r.next() catch unreachable) |c| : (x += 1) { | ||
| 63 | into.cells[x] = .{ | ||
| 64 | .style = c.style, | ||
| 65 | .wide = c.wide, | ||
| 66 | .text_off = @intCast(into.text.items.len), | ||
| 67 | .text_len = @intCast(c.text.len), | ||
| 68 | }; | ||
| 69 | into.text.appendSliceAssumeCapacity(c.text); | ||
| 70 | } | ||
| 71 | return r.remaining(); | ||
| 72 | } | ||
| 73 | |||
| 74 | /// A column span, inclusive at both ends. | ||
| 75 | pub const ColSpan = struct { from: u16, to: u16 }; | ||
| 76 | |||
| 77 | /// The last column of `r` (a row `cols` wide) that fits in `view`, or null | ||
| 78 | /// when not one character does. Inward: half a wide glyph past a pane edge | ||
| 79 | /// is a column stolen from the neighbour. | ||
| 80 | pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 { | ||
| 81 | if (view.cols == 0 or cols == 0) return null; | ||
| 82 | var hi: u16 = @min(view.cols - 1, cols - 1); | ||
| 83 | if (r.cells[hi].wide == .wide) { | ||
| 84 | if (hi == 0) return null; | ||
| 85 | hi -= 1; | ||
| 86 | } | ||
| 87 | return hi; | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Widen [from, to] to whole glyphs. Outward: half a character under the | ||
| 91 | /// pointer means the character is under the pointer. | ||
| 92 | /// | ||
| 93 | /// The two ends are accepted in either order and normalised, where the | ||
| 94 | /// engine's snapWide requires from <= to and its caller asserts it: a drag | ||
| 95 | /// runs backwards as often as forwards, and the grid is what a painter calls. | ||
| 96 | /// A column at or past `cols` reads no cell and moves nothing, which is what | ||
| 97 | /// the engine does with one (its pin off the end of the row is null). | ||
| 98 | pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan { | ||
| 99 | var lo = @min(from, to); | ||
| 100 | var hi = @max(from, to); | ||
| 101 | if (lo > 0 and lo < cols and r.cells[lo].wide == .spacer_tail) lo -= 1; | ||
| 102 | if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1; | ||
| 103 | return .{ .from = lo, .to = hi }; | ||
| 104 | } | ||
| 105 | |||
| 106 | pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row { | ||
| 107 | const rows = try alloc.alloc(Row, count); | ||
| 108 | var made: usize = 0; | ||
| 109 | errdefer { | ||
| 110 | for (rows[0..made]) |*r| r.deinit(alloc); | ||
| 111 | alloc.free(rows); | ||
| 112 | } | ||
| 113 | var rest = bytes; | ||
| 114 | for (rows) |*r| { | ||
| 115 | r.* = .{ .cells = try alloc.alloc(Cell, cols) }; | ||
| 116 | made += 1; | ||
| 117 | @memset(r.cells, .{}); | ||
| 118 | rest = try decodeRow(alloc, r, rest, cols); | ||
| 119 | } | ||
| 120 | return rows; | ||
| 121 | } | ||
| 122 | |||
| 123 | pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void { | ||
| 124 | for (rows) |*r| r.deinit(alloc); | ||
| 125 | alloc.free(rows); | ||
| 126 | } | ||
| 127 | |||
| 128 | pub const Grid = struct { | ||
| 129 | alloc: std.mem.Allocator, | ||
| 130 | cols: u16, | ||
| 131 | rows: u16, | ||
| 132 | cursor: CursorPos = .{ .x = 0, .y = 0 }, | ||
| 133 | lines: []Row, | ||
| 134 | |||
| 135 | pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid { | ||
| 136 | const g = try alloc.create(Grid); | ||
| 137 | errdefer alloc.destroy(g); | ||
| 138 | g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} }; | ||
| 139 | try g.allocLines(cols, rows); | ||
| 140 | return g; | ||
| 141 | } | ||
| 142 | |||
| 143 | fn allocLines(self: *Grid, cols: u16, rows: u16) !void { | ||
| 144 | const lines = try self.alloc.alloc(Row, rows); | ||
| 145 | var made: usize = 0; | ||
| 146 | errdefer { | ||
| 147 | for (lines[0..made]) |*r| r.deinit(self.alloc); | ||
| 148 | self.alloc.free(lines); | ||
| 149 | } | ||
| 150 | for (lines) |*r| { | ||
| 151 | r.* = .{ .cells = try self.alloc.alloc(Cell, cols) }; | ||
| 152 | made += 1; | ||
| 153 | @memset(r.cells, .{}); | ||
| 154 | } | ||
| 155 | self.freeLines(); | ||
| 156 | self.lines = lines; | ||
| 157 | self.cols = cols; | ||
| 158 | self.rows = rows; | ||
| 159 | } | ||
| 160 | |||
| 161 | fn freeLines(self: *Grid) void { | ||
| 162 | for (self.lines) |*r| r.deinit(self.alloc); | ||
| 163 | self.alloc.free(self.lines); | ||
| 164 | self.lines = &.{}; | ||
| 165 | } | ||
| 166 | |||
| 167 | pub fn deinit(self: *Grid) void { | ||
| 168 | self.freeLines(); | ||
| 169 | self.alloc.destroy(self); | ||
| 170 | } | ||
| 171 | |||
| 172 | /// A resize is a blank grid: the snapshot that carries it repaints every row. | ||
| 173 | pub fn resize(self: *Grid, cols: u16, rows: u16) !void { | ||
| 174 | try self.allocLines(cols, rows); | ||
| 175 | self.cursor = .{ .x = 0, .y = 0 }; | ||
| 176 | } | ||
| 177 | |||
| 178 | pub fn clear(self: *Grid) void { | ||
| 179 | for (self.lines) |*r| r.blank(); | ||
| 180 | self.cursor = .{ .x = 0, .y = 0 }; | ||
| 181 | } | ||
| 182 | |||
| 183 | pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void { | ||
| 184 | if (y >= self.rows) return error.BadPayload; | ||
| 185 | _ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols); | ||
| 186 | } | ||
| 187 | |||
| 188 | pub fn row(self: *const Grid, y: u16) *const Row { | ||
| 189 | return &self.lines[y]; | ||
| 190 | } | ||
| 191 | |||
| 192 | /// The screen as text: a space per blank or spacer-less empty cell, | ||
| 193 | /// nothing for a spacer, rows joined by newlines, the trailing blank ROWS | ||
| 194 | /// dropped along with the newlines that would have separated them (a | ||
| 195 | /// screen is 24 rows tall whatever is on it, so an unwritten tail is not | ||
| 196 | /// text), and every row's trailing spaces trimmed. | ||
| 197 | /// | ||
| 198 | /// Interior blank rows stay: they are a gap the user typed. | ||
| 199 | /// | ||
| 200 | /// The trailing-space trim is where this differs from Engine.dumpPlain, | ||
| 201 | /// which is ghostty's plainString and dumps with `trim = false`, so a | ||
| 202 | /// space a program wrote at the end of a row survives it. A client never | ||
| 203 | /// holds such a space, and never did: the VT formatter that fed the old | ||
| 204 | /// wire trims trailing whitespace before a row leaves the daemon, and the | ||
| 205 | /// e2e convergence diff strips trailing whitespace on both sides as a | ||
| 206 | /// formatting difference between two correct grids. Trimming here makes | ||
| 207 | /// the two agree on everything a client can observe. The engine.zig | ||
| 208 | /// oracle test compares through the same trim and is the authority. | ||
| 209 | pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 { | ||
| 210 | var out: std.ArrayList(u8) = .empty; | ||
| 211 | errdefer out.deinit(alloc); | ||
| 212 | for (self.lines, 0..) |*r, y| { | ||
| 213 | const start = out.items.len; | ||
| 214 | for (r.cells) |c| { | ||
| 215 | switch (c.wide) { | ||
| 216 | .spacer_tail, .spacer_head => continue, | ||
| 217 | .narrow, .wide => {}, | ||
| 218 | } | ||
| 219 | if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c)); | ||
| 220 | } | ||
| 221 | while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1; | ||
| 222 | if (y + 1 < self.lines.len) try out.append(alloc, '\n'); | ||
| 223 | } | ||
| 224 | while (out.items.len > 0 and out.items[out.items.len - 1] == '\n') out.items.len -= 1; | ||
| 225 | return out.toOwnedSlice(alloc); | ||
| 226 | } | ||
| 227 | |||
| 228 | pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 { | ||
| 229 | return clipColOf(&self.lines[y], self.cols, view); | ||
| 230 | } | ||
| 231 | |||
| 232 | pub fn snapWide(self: *const Grid, y: u16, from: u16, to: u16) ColSpan { | ||
| 233 | return snapWideOf(&self.lines[y], self.cols, from, to); | ||
| 234 | } | ||
| 235 | }; | ||
| 236 | |||
| 237 | test "grid: applyRow fills the row and blanks the rest of the width" { | ||
| 238 | const alloc = std.testing.allocator; | ||
| 239 | const g = try Grid.init(alloc, 6, 2); | ||
| 240 | defer g.deinit(); | ||
| 241 | var list: std.ArrayList(u8) = .empty; | ||
| 242 | defer list.deinit(alloc); | ||
| 243 | var w = try proto.CellRowWriter.begin(&list, alloc); | ||
| 244 | try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h"); | ||
| 245 | try w.cell(.{}, .wide, "漢"); | ||
| 246 | try w.cell(.{}, .spacer_tail, ""); | ||
| 247 | w.finish(); | ||
| 248 | try g.applyRow(1, list.items); | ||
| 249 | const r = g.row(1); | ||
| 250 | try std.testing.expectEqualStrings("h", r.textOf(r.cells[0])); | ||
| 251 | try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg); | ||
| 252 | try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide); | ||
| 253 | try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide); | ||
| 254 | try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len); | ||
| 255 | try std.testing.expect(r.cells[5].style.isDefault()); | ||
| 256 | // Row 0 was never written and is blank. | ||
| 257 | try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len); | ||
| 258 | } | ||
| 259 | |||
| 260 | test "grid: a row wider than the grid is BadPayload and leaves the row untouched" { | ||
| 261 | const alloc = std.testing.allocator; | ||
| 262 | const g = try Grid.init(alloc, 2, 1); | ||
| 263 | defer g.deinit(); | ||
| 264 | var list: std.ArrayList(u8) = .empty; | ||
| 265 | defer list.deinit(alloc); | ||
| 266 | var w = try proto.CellRowWriter.begin(&list, alloc); | ||
| 267 | try w.cell(.{}, .narrow, "a"); | ||
| 268 | try w.cell(.{}, .narrow, "b"); | ||
| 269 | try w.cell(.{}, .narrow, "c"); | ||
| 270 | w.finish(); | ||
| 271 | try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items)); | ||
| 272 | try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len); | ||
| 273 | } | ||
| 274 | |||
| 275 | test "grid: dumpPlain trims trailing blanks per row, joins rows with newlines, and drops the blank tail" { | ||
| 276 | const alloc = std.testing.allocator; | ||
| 277 | const g = try Grid.init(alloc, 6, 3); | ||
| 278 | defer g.deinit(); | ||
| 279 | var list: std.ArrayList(u8) = .empty; | ||
| 280 | defer list.deinit(alloc); | ||
| 281 | var w = try proto.CellRowWriter.begin(&list, alloc); | ||
| 282 | try w.cell(.{}, .narrow, "a"); | ||
| 283 | try w.cell(.{}, .narrow, ""); | ||
| 284 | try w.cell(.{}, .narrow, "b"); | ||
| 285 | w.finish(); | ||
| 286 | try g.applyRow(0, list.items); | ||
| 287 | const s = try g.dumpPlain(alloc); | ||
| 288 | defer alloc.free(s); | ||
| 289 | // Rows 1 and 2 were never written, so the engine's dump ends at row 0. | ||
| 290 | try std.testing.expectEqualStrings("a b", s); | ||
| 291 | } | ||
| 292 | |||
| 293 | test "grid: clipCol steps inward off a wide glyph at the pane edge; snapWide steps outward" { | ||
| 294 | const alloc = std.testing.allocator; | ||
| 295 | const g = try Grid.init(alloc, 6, 2); | ||
| 296 | defer g.deinit(); | ||
| 297 | var list: std.ArrayList(u8) = .empty; | ||
| 298 | defer list.deinit(alloc); | ||
| 299 | var w = try proto.CellRowWriter.begin(&list, alloc); | ||
| 300 | try w.cell(.{}, .narrow, "a"); | ||
| 301 | try w.cell(.{}, .wide, "漢"); | ||
| 302 | try w.cell(.{}, .spacer_tail, ""); | ||
| 303 | try w.cell(.{}, .narrow, "b"); | ||
| 304 | w.finish(); | ||
| 305 | // Row 1, not row 0: both answers are for the row asked about. | ||
| 306 | try g.applyRow(1, list.items); | ||
| 307 | // The pane starts at screen column 4; every answer below is a GRID column, | ||
| 308 | // so the offset must not reach it. | ||
| 309 | // A pane 2 wide would cut 漢 in half: the last column that fits is 0. | ||
| 310 | try std.testing.expectEqual(@as(?u16, 0), g.clipCol(1, .{ .col_off = 4, .cols = 2 })); | ||
| 311 | // A pane as wide as the grid clips at the last COLUMN, not at the last | ||
| 312 | // written one: trailing blanks are cells a pane paints like any other. | ||
| 313 | try std.testing.expectEqual(@as(?u16, 5), g.clipCol(1, .{ .col_off = 4, .cols = 6 })); | ||
| 314 | try std.testing.expectEqual(@as(?u16, null), g.clipCol(1, .{ .col_off = 4, .cols = 0 })); | ||
| 315 | // The unwritten row 0 has no wide glyph, so nothing steps inward there. | ||
| 316 | try std.testing.expectEqual(@as(?u16, 1), g.clipCol(0, .{ .col_off = 4, .cols = 2 })); | ||
| 317 | // A drag from the spacer to the wide cell covers the whole glyph. | ||
| 318 | const s = g.snapWide(1, 2, 1); | ||
| 319 | try std.testing.expectEqual(@as(u16, 1), s.from); | ||
| 320 | try std.testing.expectEqual(@as(u16, 2), s.to); | ||
| 321 | } | ||
| 322 | |||
| 323 | test "grid: decodeRows reads a dense run and refuses a short one" { | ||
| 324 | const alloc = std.testing.allocator; | ||
| 325 | var list: std.ArrayList(u8) = .empty; | ||
| 326 | defer list.deinit(alloc); | ||
| 327 | var i: usize = 0; | ||
| 328 | while (i < 2) : (i += 1) { | ||
| 329 | var w = try proto.CellRowWriter.begin(&list, alloc); | ||
| 330 | try w.cell(.{}, .narrow, "x"); | ||
| 331 | w.finish(); | ||
| 332 | } | ||
| 333 | const rows = try decodeRows(alloc, list.items, 2, 4); | ||
| 334 | defer freeRows(alloc, rows); | ||
| 335 | try std.testing.expectEqual(@as(usize, 2), rows.len); | ||
| 336 | try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0])); | ||
| 337 | try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4)); | ||
| 338 | } | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -54,6 +54,9 @@ pub const MsgType = enum(u8) { | |||
| 54 | 54 | ||
| 55 | pub const max_payload = 16 * 1024 * 1024; | 55 | pub const max_payload = 16 * 1024 * 1024; |
| 56 | 56 | ||
| 57 | /// The widest row a client will decode; a wider claim is a bad payload, not an allocation. | ||
| 58 | pub const max_cols: u16 = 4096; | ||
| 59 | |||
| 57 | /// One type byte + u32 LE payload length. | 60 | /// One type byte + u32 LE payload length. |
| 58 | pub const frame_header_len = 5; | 61 | pub const frame_header_len = 5; |
| 59 | 62 | ||
src/engine/term.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,16 +1,18 @@ | |||
| 1 | //! The terminal component: the wire contract, the authoritative engine, | 1 | //! The terminal component: the wire contract, the authoritative engine, |
| 2 | //! the delta minting that feeds replicas, and the one replay core. One | 2 | //! the delta minting that feeds replicas, and the one replay core. One |
| 3 | //! table row — the four files are one owner, and only this root is a seam, | 3 | //! table row — every file below is one owner, and only this root is a seam, |
| 4 | //! so a second module claiming any of them is a file-in-multiple-modules | 4 | //! so a second module claiming any of them is a file-in-multiple-modules |
| 5 | //! compile error. | 5 | //! compile error. |
| 6 | pub const protocol = @import("protocol.zig"); | 6 | pub const protocol = @import("protocol.zig"); |
| 7 | pub const engine = @import("engine.zig"); | 7 | pub const engine = @import("engine.zig"); |
| 8 | pub const delta = @import("delta.zig"); | 8 | pub const delta = @import("delta.zig"); |
| 9 | pub const replica = @import("replica.zig"); | 9 | pub const replica = @import("replica.zig"); |
| 10 | pub const grid = @import("grid.zig"); | ||
| 10 | 11 | ||
| 11 | test { | 12 | test { |
| 12 | _ = protocol; | 13 | _ = protocol; |
| 13 | _ = engine; | 14 | _ = engine; |
| 14 | _ = delta; | 15 | _ = delta; |
| 15 | _ = replica; | 16 | _ = replica; |
| 17 | _ = grid; | ||
| 16 | } | 18 | } |