summaryrefslogtreecommitdiff
path: root/src/p4.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/p4.zig')
-rw-r--r--src/p4.zig578
1 files changed, 578 insertions, 0 deletions
diff --git a/src/p4.zig b/src/p4.zig
new file mode 100644
index 00000000..7356dc73
--- /dev/null
+++ b/src/p4.zig
@@ -0,0 +1,578 @@
+//! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out.
+//!
+//! This is the fourth platform, and the only one that is not an executable. `zig build
+//! -Dplatform=p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C
+//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script,
+//! and its UART driver. Nothing here knows what a UART is.
+//!
+//! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this
+//! package in its `build.zig.zon` and import `pardes_p4`. That was built, and it broke every build
+//! in that repo: nesting this package's ~30-package graph under one whose own claim is "host
+//! dependencies: Zig, that is the whole list" made `std/Build.zig:2091` exceed its 1000-branch
+//! comptime quota (through ghostty's `SharedDeps.zig:874` `lazyImport`), dragged in seven cached
+//! tree-sitter versions whose `build.zig` uses APIs removed in 0.16, and materialised 2.6 GB across
+//! 42,736 files into that repo's working copy. A linked object has none of those properties and one
+//! extra virtue: the seam is bytes, so neither side can accidentally depend on the other's types.
+//!
+//! **Where the terminal is.** On the host. The board writes ANSI and reads ANSI; the terminal
+//! emulator at the far end of the serial line does the font rendering, and answers this program's
+//! own capability queries. That is why `vaxis` works here unmodified: `Vaxis.render`,
+//! `queryTerminalSend` and `enableDetectedFeatures` all take a bare `*std.Io.Writer`
+//! (`Vaxis.zig:375,278,329`), so the transport is a parameter. `vaxis.Tty` and `vaxis.Loop` are
+//! termios/ioctl/SIGWINCH bound and are not used.
+//!
+//! **Where the memory is.** Not here either. The firmware measured its own RAM (240 KiB low,
+//! 384 KiB high, and a 128 KiB region that turned out to be L2 cache) and owns the allocator; this
+//! file receives four function pointers and rebuilds a `std.mem.Allocator` from them. Everything
+//! the editor allocates comes from there.
+//!
+//! **Window size** arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like
+//! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one.
+
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const vaxis = @import("vaxis");
+
+// ------------------------------------------------------------------ what a freestanding root owes
+//
+// These are ROOT-module declarations: std reads them off whichever file is the compilation root, and
+// as of the build change that emits this file as the object, that is this file. They are not
+// ceremony - each one was discovered by the build failing without it.
+
+/// The board has no MMU and no pages, but std derives allocator alignment from these two. 4 KiB is
+/// the ESP32-P4's cache and DMA granularity. Without them: "riscv32-freestanding has unknown
+/// page_size_min" from std/heap.zig:48.
+///
+/// `logFn` is the load-bearing one. std's DEFAULT log implementation reaches `std.debug_io`, which
+/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which
+/// exist on this target, and ONE `log.warn` anywhere in the core or in vaxis is enough to drag the
+/// whole thing in and fail the build with "no member named 'getrandom'".
+pub const std_options: std.Options = .{
+ .page_size_min = 4096,
+ .page_size_max = 4096,
+ .logFn = logFn,
+};
+
+/// Logs go out the same byte sink as the frames, which is the only sink there is. Truncated rather
+/// than allocated: a log line is never worth an allocation on a 384 KiB heap, and a logger that can
+/// fail on OOM is a logger that disappears exactly when it is needed.
+fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ if (out_ctx == null and @intFromPtr(out_write) == 0) return;
+ var buf: [256]u8 = undefined;
+ const line = std.fmt.bufPrint(
+ &buf,
+ "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n",
+ args,
+ ) catch "\r\n[log truncated]\r\n";
+ out_write(out_ctx, line.ptr, line.len);
+}
+
+pub const panic = std.debug.FullPanic(panicImpl);
+
+/// A panic here cannot unwind and has nowhere to go, so it reports through the write callback and
+/// stops. `@trap` and not a spin: the firmware's own panic handler prints through the mask ROM,
+/// which shares nothing with this path but the FIFO, so a trap leaves that diagnostic route intact.
+fn panicImpl(msg: []const u8, _: ?usize) noreturn {
+ const prefix = "\r\nMARK PARDES_CORE_PANIC ";
+ out_write(out_ctx, prefix.ptr, prefix.len);
+ out_write(out_ctx, msg.ptr, msg.len);
+ out_write(out_ctx, "\r\n", 2);
+ @trap();
+}
+
+// ---------------------------------------------------------------------------------- the C ABI
+//
+// Deliberately tiny, and versioned. Linkers do not type-check C symbols, so a signature that drifts
+// on one side of this seam links cleanly and then corrupts the stack. `pardes_p4_abi_version` is the
+// cheapest possible defence: the firmware calls it first and refuses to continue on a mismatch.
+
+/// Bumped whenever any signature below changes, including a type.
+const abi_version: u32 = 1;
+
+export fn pardes_p4_abi_version() callconv(.c) u32 {
+ return abi_version;
+}
+
+/// The firmware's allocator, as C function pointers. `alignment` is a log2 value, matching
+/// `std.mem.Alignment`'s own representation, so no translation table is needed.
+///
+/// `remap` is absent on purpose: this allocator cannot move a block without copying it, so
+/// `std.mem.Allocator`'s remap is implemented locally as "resize in place, or fail" and the caller's
+/// own alloc/copy/free path handles the rest.
+pub const Allocator = extern struct {
+ ctx: ?*anyopaque,
+ alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8,
+ resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool,
+ free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void,
+};
+
+/// How finished runs of ANSI leave this object.
+pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void;
+
+// ------------------------------------------------------------------- the allocator, rebuilt
+// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the
+// price of the seam and it is paid once per allocation, which on a first-fit heap is already the
+// cheap part (measured on the die: 8,229 cycles for one allocation across 257 free blocks).
+
+var host_alloc: Allocator = undefined;
+
+fn hostAlloc(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 {
+ return host_alloc.alloc(host_alloc.ctx, len, @intFromEnum(alignment));
+}
+
+fn hostResize(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) bool {
+ return host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len);
+}
+
+fn hostRemap(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) ?[*]u8 {
+ return if (host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len)) mem.ptr else null;
+}
+
+fn hostFree(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, _: usize) void {
+ host_alloc.free(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment));
+}
+
+const host_vtable: std.mem.Allocator.VTable = .{
+ .alloc = hostAlloc,
+ .resize = hostResize,
+ .remap = hostRemap,
+ .free = hostFree,
+};
+
+/// `ptr` is never dereferenced - the four forwarders read the file-scope `host_alloc` - but
+/// `std.mem.Allocator` requires a non-null context, so it points at the record itself.
+fn gpa() std.mem.Allocator {
+ return .{ .ptr = @ptrCast(&host_alloc), .vtable = &host_vtable };
+}
+
+// ------------------------------------------------------------------------------- the ANSI sink
+// A `std.Io.Writer` over the firmware's write callback. Buffered, because vaxis emits a frame as a
+// long run of small writes - cursor move, SGR run, grapheme, repeat - and an unbuffered writer would
+// make a C call per fragment.
+
+var out_write: WriteFn = undefined;
+var out_ctx: ?*anyopaque = null;
+var out_buf: [8192]u8 = undefined;
+var out: std.Io.Writer = undefined;
+
+fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ // The shape std documents at Io/Writer.zig:46-63: buffer first, then every slice of `data`, with
+ // the LAST slice repeated `splat` times, and the count returned excluding the buffered bytes.
+ if (w.end > 0) {
+ out_write(out_ctx, w.buffer.ptr, w.end);
+ w.end = 0;
+ }
+ const head = data[0 .. data.len - 1];
+ const pattern = data[head.len];
+ var written: usize = 0;
+ for (head) |bytes| {
+ if (bytes.len > 0) out_write(out_ctx, bytes.ptr, bytes.len);
+ written += bytes.len;
+ }
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ if (pattern.len > 0) out_write(out_ctx, pattern.ptr, pattern.len);
+ }
+ return written + pattern.len * splat;
+}
+
+// ------------------------------------------------------------------------------------ the state
+
+var core: ?*pardes.Pardes = null;
+var vx: vaxis.Vaxis = undefined;
+var parser: vaxis.Parser = .{};
+
+/// vaxis wants an environment map. There is no environment; an empty one is the honest answer and
+/// the only thing vaxis reads it for is TERM-derived heuristics, which the capability queries
+/// supersede.
+var env_map: std.process.Environ.Map = undefined;
+
+/// Input that arrived mid-sequence. An escape sequence can be split across UART reads, and the
+/// parser reports "incomplete" by consuming nothing, so the tail has to survive until more arrives.
+var in_buf: [1024]u8 = undefined;
+var in_len: usize = 0;
+
+/// Bracketed paste: between the markers, keys are DATA and never commands.
+var paste_buf: std.ArrayListUnmanaged(u8) = .empty;
+var in_paste: bool = false;
+
+/// Set by anything that could change the screen; cleared by a render. The firmware asks before
+/// rendering, because on a 115200-baud link an unconditional repaint per loop saturates the wire and
+/// starves input.
+var dirty: bool = true;
+
+/// The largest grid this board can render, and the reason it is not the host's terminal size.
+///
+/// Every cell is paid for four times over: vaxis keeps a `Screen` and an `InternalScreen`, pardes
+/// keeps its own `Surface` and `previous_cells`. Against a 384 KiB heap that puts a hard ceiling on
+/// the geometry, and it was measured rather than guessed - 40x12 initialises with room to spare,
+/// 80x24 exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left.
+///
+/// Raising these is what PSRAM would buy: this board has 32 MB fitted and untrained.
+pub const max_cols: u16 = 40;
+pub const max_rows: u16 = 12;
+
+var cur_winsize: vaxis.Winsize = .{ .rows = max_rows, .cols = max_cols, .x_pixel = 0, .y_pixel = 0 };
+
+
+// -------------------------------------------------------------------------------------- exports
+
+/// Hand over the allocator and the output sink, state the initial window size, and bring the editor
+/// up. Returns 0, or a small non-zero code the firmware can only report.
+export fn pardes_p4_init(
+ alloc: *const Allocator,
+ write: WriteFn,
+ ctx: ?*anyopaque,
+ cols: u16,
+ rows: u16,
+) callconv(.c) u32 {
+ host_alloc = alloc.*;
+ out_write = write;
+ out_ctx = ctx;
+ out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf };
+
+ const a = gpa();
+ env_map = .{ .array_hash_map = .empty, .allocator = a };
+ // Clamped, so a firmware asking for more than the heap affords still starts. See `max_cols`.
+ cur_winsize = .{
+ .rows = @min(rows, max_rows),
+ .cols = @min(cols, max_cols),
+ .x_pixel = 0,
+ .y_pixel = 0,
+ };
+
+ const allocs = pardes.allocators.init(a);
+ // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind
+ // the Host vtable, and the ones that are not are the ones this platform does not have.
+ pardes.image.start(std.Io.failing, allocs.image);
+ pardes.syntax.start(allocs.tree_sitter);
+
+ vx = vaxis.init(std.Io.failing, a, &env_map, .{}) catch |err| return errCode(err);
+ vx.resize(a, &out, cur_winsize) catch |err| return errCode(err);
+
+ // Ask the terminal what it is. Both halves are pure byte writers, which is the whole reason this
+ // works over a serial line: the replies arrive as ordinary input and are parsed like any key.
+ vx.enterAltScreen(&out) catch |err| return errCode(err);
+ vx.queryTerminalSend(&out) catch |err| return errCode(err);
+ out.flush() catch |err| return errCode(err);
+
+ // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just
+ // sized to `cur_winsize`.
+ core = pardes.Pardes.init(allocs.pardes, .{
+ .cols = cur_winsize.cols,
+ .rows = cur_winsize.rows,
+ .frame_allocator = allocs.frame,
+ .image_allocator = allocs.image,
+ .tree_sitter_allocator = allocs.tree_sitter,
+ }) catch |err| return errCode(err);
+
+ dirty = true;
+ return 0;
+}
+
+/// Raw bytes off the wire: keystrokes, capability replies, and in-band resize reports. All three are
+/// the same kind of thing to `vaxis.Parser`, and this function does not distinguish them.
+export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void {
+ const c = core orelse return;
+
+ // Append, dropping the oldest on overflow: a full buffer means the parser is stuck on a
+ // malformed sequence, and keeping the tail is what lets it resynchronise.
+ const room = in_buf.len - in_len;
+ const take = @min(room, len);
+ if (take < len) {
+ in_len = 0;
+ @memcpy(in_buf[0..@min(len, in_buf.len)], ptr[0..@min(len, in_buf.len)]);
+ in_len = @min(len, in_buf.len);
+ } else {
+ @memcpy(in_buf[in_len..][0..take], ptr[0..take]);
+ in_len += take;
+ }
+
+ var off: usize = 0;
+ while (off < in_len) {
+ const res = parser.parse(in_buf[off..in_len], gpa()) catch break;
+ if (res.n == 0) break; // incomplete: wait for more bytes
+ off += res.n;
+ if (res.event) |ev| apply(c, ev);
+ }
+ // Keep whatever was not consumed: the tail of a split escape sequence.
+ if (off > 0) {
+ std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]);
+ in_len -= off;
+ }
+}
+
+/// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply`
+/// (`src/tty/tty.zig:926-985`), minus everything that needs an OS.
+fn apply(c: *pardes.Pardes, ev: vaxis.Event) void {
+ switch (ev) {
+ .key_press => |key| if (in_paste) {
+ // Between the brackets a key is DATA, never a command. vaxis gives control bytes no
+ // text at all, so a line break inside a paste arrives as a bare CR (Key.enter) or, from
+ // a terminal that does not translate them, as ctrl+j.
+ const text = key.text orelse "";
+ const cp = mapKey(effCp(key));
+ const bytes: []const u8 = if (text.len > 0)
+ text
+ else if (cp == pardes.Key.tab)
+ "\t"
+ else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j'))
+ "\n"
+ else
+ "";
+ if (bytes.len > 0) paste_buf.appendSlice(gpa(), bytes) catch {};
+ } else {
+ c.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ .shift = key.mods.shift,
+ } });
+ dirty = true;
+ },
+ .paste_start => {
+ paste_buf.clearRetainingCapacity();
+ in_paste = true;
+ },
+ .paste_end => {
+ in_paste = false;
+ if (paste_buf.items.len > 0) {
+ c.update(.{ .paste = paste_buf.items });
+ dirty = true;
+ }
+ paste_buf.clearRetainingCapacity();
+ },
+ // OSC 52. The bytes are the parser's, allocated from our own allocator, so they are freed
+ // here rather than leaked - the core copies whatever it keeps.
+ .paste => |text| {
+ c.update(.{ .paste = text });
+ gpa().free(text);
+ dirty = true;
+ },
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ .wheel_right => .wheel_right,
+ .none => .none,
+ else => null,
+ };
+ if (button) |b| {
+ c.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ .ctrl = m.mods.ctrl,
+ } });
+ dirty = true;
+ }
+ },
+ // The only way this platform learns its size, and the one place a 384 KiB heap shows through
+ // to the user. Two things happen here that the tty shell does not need.
+ //
+ // CLAMPED, because the grids do not fit an arbitrary terminal: vaxis keeps a `Screen` and an
+ // `InternalScreen`, pardes keeps its own `Surface` and `previous_cells`, so every cell is
+ // paid for four times. Measured on the die - 40x12 initialises with room to spare, 80x24
+ // exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. The host's
+ // terminal is normally larger than the board can render, so the editor takes a corner of it
+ // instead of refusing to start.
+ //
+ // ATOMIC, because `Vaxis.resize` deinits both screens BEFORE allocating the replacements
+ // (Vaxis.zig:194-206), so a failed resize leaves vaxis with freed screens and renders
+ // nothing at all. That is exactly how this was found: the host bridge injects a size report
+ // on attach, the 80x24 it reported could not be allocated, and an editor that had just drawn
+ // its interface went silent. A failure now puts the previous geometry back.
+ .winsize => |ws| {
+ const want: vaxis.Winsize = .{
+ .rows = @min(ws.rows, max_rows),
+ .cols = @min(ws.cols, max_cols),
+ .x_pixel = ws.x_pixel,
+ .y_pixel = ws.y_pixel,
+ };
+ if (want.cols == cur_winsize.cols and want.rows == cur_winsize.rows) return;
+ const previous = cur_winsize;
+ vx.resize(gpa(), &out, want) catch {
+ vx.resize(gpa(), &out, previous) catch {};
+ return;
+ };
+ cur_winsize = want;
+ c.update(.{ .resize = .{ .cols = want.cols, .rows = want.rows } });
+ dirty = true;
+ },
+ // A TTY cannot report a pointer leaving its grid, so losing focus is the only reliable
+ // pointer-leave signal there is.
+ .focus_out => {
+ c.update(.pointer_leave);
+ dirty = true;
+ },
+ .focus_in, .mouse_leave => {},
+ // Capability replies. vaxis's own Loop sets these fields directly (`Loop.zig:377-403`);
+ // with no Loop, this is where they land. DA1 is the terminator: every terminal answers it
+ // last, so it is the signal that the whole handshake is in and the detected features can be
+ // switched on.
+ .cap_kitty_keyboard => vx.caps.kitty_keyboard = true,
+ .cap_kitty_graphics => vx.caps.kitty_graphics = true,
+ .cap_rgb => vx.caps.rgb = true,
+ .cap_unicode => {
+ vx.caps.unicode = .unicode;
+ vx.screen.width_method = .unicode;
+ },
+ .cap_sgr_pixels => vx.caps.sgr_pixels = true,
+ .cap_color_scheme_updates => vx.caps.color_scheme_updates = true,
+ .cap_multi_cursor => vx.caps.multi_cursor = true,
+ .cap_da1 => {
+ vx.enableDetectedFeatures(&out) catch {};
+ out.flush() catch {};
+ dirty = true;
+ },
+ .color_report, .color_scheme => {},
+ .key_release => {},
+ }
+}
+
+/// The effective codepoint the way vaxis's own `Key.matches` sees it: a single-character `text`
+/// wins, because the terminal has already resolved shift; otherwise the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |cp| {
+ if (it.nextCodepoint() == null) return cp;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core constants. The ASCII ones already coincide, so
+/// enter/tab/escape/backspace pass straight through.
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ vaxis.Key.delete => pardes.Key.delete,
+ else => cp,
+ };
+}
+
+export fn pardes_p4_tick(now_ms: u64) callconv(.c) void {
+ const c = core orelse return;
+ _ = now_ms;
+ if (c.animationActive()) {
+ c.update(.tick);
+ dirty = true;
+ }
+}
+
+export fn pardes_p4_wants_frame() callconv(.c) bool {
+ const c = core orelse return false;
+ return dirty or c.animationActive();
+}
+
+export fn pardes_p4_render() callconv(.c) u32 {
+ const c = core orelse return 0;
+ c.pump(.{ .ctx = null, .vtable = &pardes_host }) catch |err| return errCode(err);
+ dirty = false;
+ return 0;
+}
+
+export fn pardes_p4_quit() callconv(.c) bool {
+ const c = core orelse return true;
+ return c.quit;
+}
+
+// ------------------------------------------------------------------------------------ the host
+
+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.
+fn present(_: ?*anyopaque, surface: *const pardes.Surface) void {
+ const win = vx.window();
+ win.clear();
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ // `at` takes a mutable Surface but only reads; the tty shell does the same const-cast
+ // for the same reason (src/tty/tty.zig:1105).
+ const cell = @constCast(surface).at(x, y);
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+ if (surface.cursor) |cur| {
+ win.showCursor(cur.x, cur.y);
+ } else win.hideCursor();
+
+ // 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;
+ out.flush() catch return;
+}
+
+fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
+ return .{
+ .fg = vaxisColor(s.fg),
+ .bg = vaxisColor(s.bg),
+ .bold = s.bold,
+ .dim = s.dim,
+ .italic = s.italic,
+ .blink = s.blink,
+ .reverse = s.reverse,
+ .invisible = s.invisible,
+ .strikethrough = s.strikethrough,
+ .ul_style = switch (s.ul) {
+ .off => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+}
+
+fn vaxisColor(c: pardes.Color) vaxis.Color {
+ return switch (c) {
+ .default => .default,
+ .index => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = rgb },
+ };
+}
+
+/// Errors cross the ABI as small non-zero integers. `@intFromError` is not stable across builds, so
+/// it is not used: the firmware only reports the number, and a stable-looking value that silently
+/// changed meaning would be worse than an opaque one.
+fn errCode(err: anyerror) u32 {
+ return switch (err) {
+ error.OutOfMemory => 1,
+ error.WriteFailed => 2,
+ else => 255,
+ };
+}