diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/p4.zig | 223 |
1 files changed, 206 insertions, 17 deletions
@@ -507,9 +507,10 @@ export fn pardes_p4_quit() callconv(.c) bool { const pardes_host: pardes.Host.VTable = .{ .push_present = present }; -/// The canonical surface -> vaxis, cell for cell, then one render. Same shape as the tty shell's -/// (`src/tty/tty.zig:1096`) minus the panel compositor and the kitty image path: neither has a -/// reason to exist on a board with no pixels. +/// The canonical surface -> the wire. Same shape as the tty shell's (`src/tty/tty.zig:1096`) minus +/// the panel compositor and the kitty image path: neither has a reason to exist on a board with no +/// pixels. Where the tty shell hands every cell to vaxis and lets it diff, this diffs against the +/// Surface itself and can then emit the ANSI directly - see `direct_emit`. fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { const t0 = cycles(); const win = vx.window(); @@ -532,10 +533,16 @@ fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { // bytes where it had emitted 1,392. The grid is bounded by `max_cols` x `max_rows` at comptime, // so it belongs in `.bss` where it cannot compete with anything. const full = !shadow_grid or prev_cols != surface.cols or prev_rows != surface.rows; + emit_bytes = 0; if (full) { prev_cols = surface.cols; prev_rows = surface.rows; - win.clear(); + if (direct_emit) { + // Reset first: a `2J` while a non-default background is active fills the screen with it. + emitRaw("\x1b[0m\x1b[2J") catch return; + emit_style = .{}; + emit_col = -1; + } else win.clear(); } const usable = shadow_grid and n <= prev_cells.len; @@ -565,26 +572,28 @@ fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { prev_cells[idx] = cell.*; } else if (cell.default) continue; - if (cell.default) { - // Changed TO default. `win.clear()` is what used to blank these, and it is not run - // on an incremental frame, so say it explicitly. - win.writeCell(x, y, .{ .char = .{ .grapheme = " " }, .style = .{} }); - continue; - } - win.writeCell(x, y, .{ - .char = .{ .grapheme = cell.grapheme() }, - .style = vaxisStyle(cell.style), - }); + writeOne(win, x, y, cell, surface.cols) catch return; } } - if (surface.cursor) |cur| { + if (direct_emit) { + if (surface.cursor) |cur| { + cup(cur.y, cur.x) catch return; + emitRaw("\x1b[?25h") catch return; + emit_col = -1; + // Up to the bridge's packet boundary, and no further. See `emit_min_frame`: this is the + // one place that knows how many bytes the frame came to, and repeating the sequence the + // frame already ended on is the only filler that cannot change what is on the screen. + while (emit_bytes < emit_min_frame) cup(cur.y, cur.x) catch return; + } else emitRaw("\x1b[?25l") catch return; + } else if (surface.cursor) |cur| { win.showCursor(cur.x, cur.y); } else win.hideCursor(); const t1 = cycles(); // vaxis diffs against its own shadow grid, so this writes only what changed - which is what - // makes an editor usable at 11.9 KB/s. - vx.render(&out) catch return; + // makes an editor usable at 11.9 KB/s. With `direct_emit` that diff has already happened, one + // stage earlier and against the Surface itself, so there is nothing left here to do. + if (!direct_emit) vx.render(&out) catch return; const t2 = cycles(); out.flush() catch return; const t3 = cycles(); @@ -594,6 +603,186 @@ fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { prof_flush_cy = t3 -% t2; } +/// One cell to the wire, either through vaxis or straight out. +inline fn writeOne(win: vaxis.Window, x: u16, y: u16, cell: *const pardes.Cell, cols: u16) !void { + if (!direct_emit) { + // Changed TO default. `win.clear()` is what used to blank these, and it is not run on an + // incremental frame, so say it explicitly. + if (cell.default) return win.writeCell(x, y, .{ .char = .{ .grapheme = " " }, .style = .{} }); + return win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); + } + + if (emit_row != y or emit_col != x) { + try cup(y, x); + emit_row = y; + emit_col = @intCast(x); + } + + const style: pardes.CellStyle = if (cell.default) .{} else cell.style; + if (!std.meta.eql(emit_style, style)) { + try emitStyle(style); + emit_style = style; + } + + try emitRaw(if (cell.default) " " else cell.grapheme()); + + // Where the terminal's cursor now is. A single printable ASCII byte advanced it exactly one + // column; anything else - a wide glyph, a cluster, the spacer cell pardes writes after a wide + // one - is not worth predicting, so give up and let the next cell emit an absolute CUP. The last + // column is given up on too, because whether the cursor rests on it or has wrapped past it + // depends on the terminal's deferred-wrap behaviour, and the two disagree by a whole row. + if (x + 1 < cols and cell.len == 1 and cell.text[0] >= 0x20 and cell.text[0] < 0x7f) { + emit_col += 1; + } else emit_col = -1; +} + +/// A style as an absolute SGR, always opening with a reset. +/// +/// Absolute rather than a delta from whatever is currently on, and that is what keeps it short +/// enough to be worth having: no per-attribute off-codes, no state to keep beyond the last style +/// emitted, and a frame that gets cut off cannot leave a later cell wearing an earlier one's colour. +/// It costs a few bytes on a style change, against the ~9 of CUP a changed cell is paying anyway. +fn emitStyle(s: pardes.CellStyle) !void { + try emitRaw("\x1b[0"); + if (s.bold) try emitRaw(";1"); + if (s.dim) try emitRaw(";2"); + if (s.italic) try emitRaw(";3"); + if (s.blink) try emitRaw(";5"); + if (s.reverse) try emitRaw(";7"); + if (s.invisible) try emitRaw(";8"); + if (s.strikethrough) try emitRaw(";9"); + try emitRaw(switch (s.ul) { + .off => "", + .single => ";4", + .double => ";4:2", + .curly => ";4:3", + .dotted => ";4:4", + .dashed => ";4:5", + }); + try emitColor(s.fg, 30); + try emitColor(s.bg, 40); + try emitRaw("m"); +} + +/// `base` is 30 for a foreground and 40 for a background, which is the only thing separating the two +/// in every form SGR has for a colour: 30-37 against 40-47, 90-97 against 100-107, 38 against 48. +fn emitColor(c: pardes.Color, comptime base: u16) !void { + var b: [20]u8 = undefined; + var i: usize = 0; + switch (c) { + // Already said by the reset this SGR opens with. + .default => return, + .index => |n| { + b[i] = ';'; + i += 1; + if (n < 8) { + i += dec(b[i..], base + n); + } else if (n < 16) { + i += dec(b[i..], base + 60 + (n - 8)); + } else { + i += dec(b[i..], base + 8); + i += lit(b[i..], ";5;"); + i += dec(b[i..], n); + } + }, + .rgb => |v| { + b[i] = ';'; + i += 1; + i += dec(b[i..], base + 8); + i += lit(b[i..], ";2;"); + for (v, 0..) |component, k| { + if (k != 0) { + b[i] = ';'; + i += 1; + } + i += dec(b[i..], component); + } + }, + } + try emitRaw(b[0..i]); +} + +/// Absolute cursor positioning, hand-rolled rather than through `out.print`. +/// +/// Not for elegance: this is the single most frequent sequence the emitter produces, at least one per +/// changed run, and `std.fmt` brings a whole format-string interpreter to write at most two digits. +/// The grid is bounded by `max_cols` x `max_rows`, so nothing here can exceed three. +fn cup(row: u16, col: u16) !void { + var b: [12]u8 = undefined; + var i: usize = lit(&b, "\x1b["); + i += dec(b[i..], row + 1); + b[i] = ';'; + i += 1; + i += dec(b[i..], col + 1); + b[i] = 'H'; + i += 1; + try emitRaw(b[0..i]); +} + +fn dec(buf: []u8, v: u16) usize { + if (v >= 10000) return std.fmt.printInt(buf, v, 10, .lower, .{}); + var digits: [5]u8 = undefined; + var n: usize = 0; + var rest = v; + while (true) { + digits[n] = '0' + @as(u8, @intCast(rest % 10)); + n += 1; + rest /= 10; + if (rest == 0) break; + } + for (0..n) |k| buf[k] = digits[n - 1 - k]; + return n; +} + +inline fn lit(buf: []u8, comptime s: []const u8) usize { + @memcpy(buf[0..s.len], s); + return s.len; +} + +/// Every direct-emit byte goes through here, because the count is what the padding below needs. +inline fn emitRaw(bytes: []const u8) !void { + emit_bytes += bytes.len; + try out.writeAll(bytes); +} + +/// A/B switch for the emitter above, on the same terms as `shadow_grid`: false routes every cell back +/// through vaxis, which is the reference. vaxis's own diff measured 631 us of a 4.37 ms keystroke and +/// all of it was redundant - `present` has already worked out which cells moved, so vaxis was being +/// told the answer and then computing it again from scratch. +const direct_emit = true; + +/// THE FRAME HAS A MINIMUM SIZE, and it is the USB bridge's, not the terminal's. +/// +/// The board is wired to the host through a CH340, a full-speed device whose bulk IN endpoint takes +/// 32-byte packets. It forwards a packet when the packet is FULL, and a frame shorter than that sits +/// in the bridge until an internal timer gives up on more - which is worth about a millisecond, and +/// a millisecond is a quarter of the entire keystroke budget. +/// +/// Measured, at the same board cost and with the screen byte-identical: a 21-byte frame round-trips +/// in 4817 us and the same frame padded to 49 bytes in 3814 us. MORE BYTES, ARRIVING SOONER. It also +/// explains why routing through vaxis looked competitive - its frames are 81 bytes, so they fill a +/// packet by accident and never wait. +/// +/// So pad to the packet boundary. The filler is repeated absolute cursor positioning: idempotent, +/// already the sequence the emitter ends on, and it cannot alter a cell. This is the same bargain as +/// an Ethernet runt frame - the medium has a minimum and the sender pays it - and it is a real +/// trade, not free: the wasted bytes are wire time that delays a LATER frame, so it is only worth it +/// while the frame is small, which is exactly when it applies. +const emit_min_frame: usize = 32; + +/// Bytes emitted this frame, for `emit_min_frame`. +var emit_bytes: usize = 0; + +/// What the terminal is currently wearing and where its cursor is, so that a run of changed cells in +/// one row costs one CUP and one SGR rather than one of each per cell. `emit_col` is signed because +/// -1 means "no longer known" - see `writeOne`. +var emit_style: pardes.CellStyle = .{}; +var emit_row: u16 = 0; +var emit_col: i32 = -1; + /// A/B switch, kept because this optimisation is exactly the kind that can be right about latency /// and wrong about the screen. With it false, `present` behaves as it did before the shadow grid - /// clear and write every cell - which is the reference any measurement of it should be compared |
