docs/superpowers/plans/2026-09-04-native-client.md
Ref: Size: 127.9 KiB History
# Native Client (`muxg`) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A window, `muxg TARGET`, that shows one daemon session painted with OpenGL from the cells the daemon sends, types into it, follows the window's size, and prints a per-stage frame-timing table proving it painted under a flood.
**Architecture:** A pump thread under `src/client/` owns the transport (dial, attach, read frames, apply to the `Replica` under a mutex, redial) and wakes the window thread through a callback; the window thread (`src/gui/`) rebuilds instanced quads from the whole grid on each wake and draws them in one call. SDL3 is confined to `frame.zig` and the entry file; every other file under `src/gui/` is plain Zig over `term.grid`, freetype and GL function pointers. No wall policy exists in `src/gui/`: no tiles, no layout file, no picker, no host grade.
**Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), SDL3 3.4, OpenGL 3.3 core loaded through `SDL_GL_GetProcAddress`, freetype2, fontconfig, HarfBuzz — all system libraries through pkg-config, dynamically linked, opt-in under `make native`. HarfBuzz shapes each cell's complete text cluster; it does not change the daemon's cell placement.
**Spec:** `docs/superpowers/specs/2026-09-04-native-client-design.md`
## Delivery record — 2026-09-04
Implemented and reviewed in working sprints. The original checklist and code
examples below are retained as design history; the delivered code is authoritative.
Existing edits to this plan were preserved.
| Scope | Result |
|---|---|
| Task 1: opt-in build and GL probe | Complete (`b7dbcda`) |
| Tasks 2–6, including 5b: timing, atlas, full-cluster/style painter, GL | Complete |
| Task 7: cancellable session pump and reconnect | Complete |
| Task 8: real GUI, input, target parsing and lifecycle | Complete |
| Task 9: isolated native end-to-end journey | Complete; nine checkpoints |
| Task 10: README, module rules and reconciled design | Complete |
The integrated implementation is committed as `c49a06e`. Implementation and
adversarial review agents resolved their findings directly; an additional final
review found and verified a fix for glyphs overflowing their cell spans. No
blocking review findings remain.
Validation:
- `make check` passed on the final source.
- `zig build native native-test -Doptimize=ReleaseSafe` passed; 12 native tests.
- 18 focused pump tests passed, including partial frames, silent handoff,
reconnect/refusal, idle sockets/pipes, and bounded QUIC batches.
- `make native-e2e` passed all nine checkpoints: input, live flood painting,
real framebuffer pixels, resize, detach, terminal-client interoperability,
and named-session shell exit status. Final window-total p99 was 110 us
on this development machine against the 20,000 us budget.
- Real Wayland window and isolated local socket, QUIC and `--via` pipe GUI
smoke tests rendered styled text and accepted input; framebuffer captures
were inspected. The mux-e2e VM and macOS were not tested.
- The aggregate `make ci` invocation was terminated with exit 143 during
`12_panes`, with no reported test failure. Groups 01–11 had completed;
groups 12–16 were then run separately and passed. `make agent` passed
(10 scenarios), and `make throughput` passed. The aggregate count pin
was therefore not reached in one uninterrupted run.
Implementation corrections to the illustrative listings include incremental
nonblocking stream reads and cancellable dials, a main-thread test FIFO,
coalesced bounded window events, layout-translated modifier characters,
nominal monospace cell metrics, complete HarfBuzz runs, clipped glyph UVs,
real drawable-size queries, and build-mode verification from `--version`.
The timing total excludes independently sampled pump work.
Follow-up fixes from live use:
- `5570679` fixes `SnapshotAborted` after raw binary output. Ghostty can
retain DEL/C0 cells; shared wire readers and writers replace their text
with U+FFFD. An isolated pre-fix daemon passed live output and native
reattachment with the rebuilt client. The native leg now has ten
checkpoints, including retained DEL output and reattachment.
- High-DPI rendering now prefers native Wayland on Linux and rasterizes
fonts at SDL's display scale. The same window used an 850×1380 X11 buffer
before and a 1700×2760 Wayland buffer after, with a 32-pixel raster font
at 200% scale. Explicit SDL driver overrides and X11 fallback were checked.
Scale changes rebuild font resources and recalculate the session geometry.
Follow-up validation passed: `make check`, all 16 native unit tests in
ReleaseSafe, and all ten native end-to-end checkpoints. Implementation and
adversarial review agreed with no outstanding findings.
---
## Global Constraints
- **Prerequisite: MET.** Cells-on-the-wire landed on main 2026-09-04 (`f07a56c8` through `406628a2`) and this plan was rebased onto it. Every type it names is present as written: `term.grid.Grid` with `cursor`/`lines`/`row(y)`, `grid.Row` with `cells` and `text: ArrayListUnmanaged(u8)` read through `textOf`, `grid.Cell{style,wide,text_off,text_len}`, `proto.CellStyle`, `proto.Wide`, `Replica.apply` returning the enum `.painted | .resync`, `Replica.attachArgs`, `Replica.state_since_attach`.
- **`apply` has THREE outcomes, not two.** Besides the enum it can fail two ways, and they are not the same failure: `error.BadPayload` left the grid untouched (skip the frame), `error.SnapshotAborted` blanked the grid while `last_seq` claims to be current (end the session). Task 7's `onFrame` carries both arms. `interact.Core.frame` is the wall's copy of the same rule and is the reference if anything reads ambiguously.
- **`term` no longer links ghostty-vt.** The merge split the engine out: `term` is the wire, the grid and the replica, and the separate `engine` module is the emulator that imports it. So the `native` row importing `term` pulls in no emulator, and nothing under `src/gui/` should ever import `engine`.
- Toolchain is `deps/zig/zig` (0.15.2). System zig will not build this tree. Every command below spells `deps/zig/zig` or `make`. A fresh worktree has no `deps/`: link the main checkout's before anything else (`ln -s ../../../../deps/zig deps/zig; ln -s ../../../../deps/quic deps/quic` from a worktree under `.claude/worktrees/`), and never share `.zig-cache` between worktrees.
- `make build`, `make check`, `make test` and `make ci` never build or link `src/gui/`. `zig build native`, `zig build native-test` and `zig build native-e2e` are the only steps that touch it; `make native` and `make native-e2e` are their only callers.
- `src/gui/` imports `client` and `term` and nothing else of ours. `src/cli/muxg.zig` (the entry) may also import `cliflags` and `sockpath`.
- No wall policy under `src/gui/`: none of `@import("wall")`, `wall_host`, `wall_layout`, `wall_picker`, `layoutfile`, `SessionPoll` may appear there. Enforced by build.zig folder rule 8 (Task 1).
- SDL is confined: the needle `sdl` may appear only in `src/gui/frame.zig` and `src/cli/muxg.zig`, comments included (the ban lower-cases each line). Enforced by folder rule 9 (Task 1).
- The existing folder rules 5, 6 and 7 list their folders explicitly; `src/gui` is added to each (Task 1). Rule 4 (escape bytes, termios) covers `src/client/`, so `session_pump.zig` spells no escape byte.
- Every module must `@import` every table import it declares (`checkGrantsUsed` fails the build otherwise).
- Every hand-run rig exports an isolated `XDG_STATE_HOME`; the e2e leg sources `test/e2e_lib.sh`, which does that.
- Never quote a speed number measured in a Debug build. The e2e leg refuses to grade one.
- Commit subjects are `type: what changed` with no scope. Every commit ends with:
```
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW
```
- `make check` before every commit. It does not build `src/gui/`, so each task also runs `make native` (and from Task 2 on, `zig build native-test`) before committing.
- Never `pkill`/`pgrep`/`killall` anything named `mux` or `muxg`. Kill only a pid you captured with `$!`.
---
## File map
| File | Responsibility |
|---|---|
| `src/gui/native.zig` | Row root: the two folder rules in its header, re-exports, `run` |
| `src/gui/bench.zig` | Per-frame stage timing ring and the stats table |
| `src/gui/atlas.zig` | R8 glyph atlas: shelf packer, codepoint → entry map, growth |
| `src/gui/font.zig` | fontconfig match + freetype rasterise; cell metrics |
| `src/gui/quads.zig` | `Row` + column offset → instanced quads; colours |
| `src/gui/gl.zig` | GL 3.3 core objects loaded by name; one instanced draw |
| `src/gui/frame.zig` | SDL window, GL context, event loop, paint, test hook, SIGUSR1 |
| `src/cli/muxg.zig` | argv → `client.Target`, then `native.run` |
| `src/client/session_pump.zig` | The transport-owning thread, mailbox, doorbell, state |
| `build.zig` | rows `native` and `muxg`, steps `native` `native-test` `native-e2e`, rules 8 and 9, `src/gui` in rules 5–7 |
| `Makefile` | `native`, `native-e2e` |
| `test/native.sh` | The end-to-end leg |
| `README.md`, `CLAUDE.md` | The viewer's one section; the table row and rules |
---
### Task 1: Build wiring and the GL probe (the offscreen spike)
**Files:**
- Create: `src/gui/native.zig`, `src/cli/muxg.zig`
- Modify: `build.zig` (module table after the `mux` row; `checkSourceBans` rule table; a `native` block after the `agent` step), `Makefile` (after `agent:`), `docs/superpowers/specs/2026-09-04-native-client-design.md` (three lines, step 8)
**Interfaces:**
- Produces: module rows `native` (root `src/gui/native.zig`, imports `term` `client`) and `muxg` (root `src/cli/muxg.zig`, imports `native` `client` `term` `cliflags` `sockpath`); build steps `native`, `native-test`; `zig-out/bin/muxg`.
- [ ] **Step 1: Write the row root with the two rules in its header**
`src/gui/native.zig`:
```zig
//! The native client's painter: a window on ONE daemon session, painted
//! with OpenGL from the grid cells-on-the-wire delivers. A SESSION VIEWER,
//! not a wall.
//!
//! Two folder rules, enforced by build.zig (rules 8 and 9):
//!
//! - No wall policy enters src/gui/. No tiles, no layout file, no picker,
//! no host grade, no session list. The wall's policy lives under src/tui/
//! against a cell painter, and the hub's copy of its grade is already an
//! open issue; a third copy here would be the same mistake with pixels.
//! Native panes wait on lifting that policy into a painter-free module.
//! - The windowing library is confined to frame.zig and the entry file.
//! Every other file here is plain Zig over term.grid, freetype and GL
//! function pointers, so the painter can be tested with no window and
//! moved to another windowing layer without touching it.
//!
//! Imports `client` and `term` and nothing else of ours.
const std = @import("std");
const client = @import("client");
const term = @import("term");
pub const frame = @import("frame.zig");
/// The one entry: dial, attach, paint until the window closes or the
/// session ends. Returns the process exit code, which is the shell's when
/// the session exited, because a script can read it.
pub fn run(alloc: std.mem.Allocator, opts: frame.Options) !u8 {
return frame.run(alloc, opts);
}
test {
_ = client;
_ = term;
_ = frame;
}
```
- [ ] **Step 2: Write the probe entry**
`src/cli/muxg.zig` (this version is the spike; Task 8 replaces it):
```zig
//! `muxg`: the native client's entry. Parses the target, hands it to
//! src/gui/native.zig. Until Task 8 this file is the GL probe: it opens a
//! window on whatever video driver SDL picks and prints the GL version.
const std = @import("std");
const native = @import("native");
const client = @import("client");
const term = @import("term");
const cliflags = @import("cliflags");
const sockpath = @import("sockpath");
const c = @cImport({
@cInclude("SDL3/SDL.h");
});
pub fn main() !u8 {
if (!c.SDL_Init(c.SDL_INIT_VIDEO)) {
std.debug.print("muxg: SDL_Init: {s}\n", .{std.mem.span(c.SDL_GetError())});
return 2;
}
defer c.SDL_Quit();
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
const win = c.SDL_CreateWindow("muxg probe", 320, 200, c.SDL_WINDOW_OPENGL) orelse {
std.debug.print("muxg: SDL_CreateWindow: {s}\n", .{std.mem.span(c.SDL_GetError())});
return 2;
};
defer c.SDL_DestroyWindow(win);
const ctx = c.SDL_GL_CreateContext(win) orelse {
std.debug.print("muxg: SDL_GL_CreateContext: {s}\n", .{std.mem.span(c.SDL_GetError())});
return 2;
};
defer _ = c.SDL_GL_DestroyContext(ctx);
const get_string: ?*const fn (u32) callconv(.c) ?[*:0]const u8 =
@ptrCast(c.SDL_GL_GetProcAddress("glGetString"));
const f = get_string orelse {
std.debug.print("muxg: no glGetString\n", .{});
return 2;
};
const ver = f(0x1F02) orelse "?"; // GL_VERSION
std.debug.print("muxg probe: driver={s} GL_VERSION={s}\n", .{
std.mem.span(c.SDL_GetCurrentVideoDriver()),
std.mem.span(ver),
});
_ = native;
_ = client;
_ = term;
_ = cliflags;
_ = sockpath;
return 0;
}
```
- [ ] **Step 3: Add the two rows to the module table**
In `build.zig`, after the `mux` row of `mod_table`:
```zig
// Opt-in rows: nothing in test_order and nothing the `mux` binary links
// names them, so `zig build`, `test` and `check` never compile a line of
// src/gui/. Only the `native*` steps below reference them.
.{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "term", "client" } },
.{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath" } },
```
- [ ] **Step 4: Add `src/gui` to rules 5, 6 and 7 and add rules 8 and 9**
In `checkSourceBans`'s table: append `"src/gui"` to the `.folders` list of the rules whose `.rule` is `"5"`, `"6"` and `"7"`. Then append two entries:
```zig
.{
.rule = "8",
.folders = &.{"src/gui"},
.needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" },
.why = "src/gui/ is a session viewer and carries no wall policy — " ++
"tiles, the layout file, the picker and the host grade live under " ++
"src/tui/, and a second copy of them is the hub's open issue again",
},
.{
.rule = "9",
.folders = &.{"src/gui"},
.needles = &.{"sdl"},
.except = &.{"src/gui/frame.zig"},
.why = "the windowing library is confined to frame.zig, so every other " ++
"painter file tests with no window and survives a change of layer",
},
```
(Rule 9's folder is `src/gui` only; `src/cli/muxg.zig` is outside it and may spell SDL.)
- [ ] **Step 5: Add the `native` steps**
In `build()`, after the `agent_step` block:
```zig
// The native client: opt-in, dynamically linked against the system's
// SDL3, freetype, fontconfig and HarfBuzz through pkg-config. GL is loaded by
// name through SDL, so no -lGL. Nothing on the default path depends on
// these, so a box without SDL3 builds and gates mux as before.
const native_mod = mods[comptime idxOf("native")];
const muxg_mod = mods[comptime idxOf("muxg")];
const muxg_exe = b.addExecutable(.{ .name = "muxg", .root_module = muxg_mod });
linkerFor(muxg_exe);
muxg_exe.linkSystemLibrary2("sdl3", .{});
muxg_exe.linkSystemLibrary2("freetype2", .{});
muxg_exe.linkSystemLibrary2("fontconfig", .{});
muxg_exe.linkSystemLibrary2("harfbuzz", .{});
linkQuic(b, muxg_exe, quic);
const native_step = b.step("native", "Build the native client, muxg (opt-in; needs SDL3, freetype, fontconfig, HarfBuzz)");
native_step.dependOn(&b.addInstallArtifact(muxg_exe, .{}).step);
// Unit tests open no window but link the libraries: frame.zig's key
// table is tested through the same compilation unit that calls SDL.
const native_tests = b.addTest(.{ .root_module = native_mod });
linkerFor(native_tests);
native_tests.linkSystemLibrary2("sdl3", .{});
native_tests.linkSystemLibrary2("freetype2", .{});
native_tests.linkSystemLibrary2("fontconfig", .{});
native_tests.linkSystemLibrary2("harfbuzz", .{});
linkQuic(b, native_tests, quic);
const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)");
native_test_step.dependOn(&b.addRunArtifact(native_tests).step);
const native_e2e = b.addSystemCommand(&.{"test/native.sh"});
native_e2e.addArtifactArg(mux_exe);
native_e2e.addArtifactArg(muxg_exe);
const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
native_e2e_step.dependOn(&native_e2e.step);
```
`linkQuic` is needed because `client` links the QUIC transport; the `mux` binary already does the same.
- [ ] **Step 6: Add the Makefile targets**
After the `agent:` recipe in `Makefile`:
```makefile
# The native client (docs/superpowers/specs/2026-09-04-native-client-design.md):
# opt-in, because it links system SDL3, freetype, fontconfig and HarfBuzz and
# a box without them must still build and gate mux. Not part of ci.
native: mac-sdk
$(ZIG) build native native-test
native-e2e: mac-sdk
$(ZIG) build native-e2e
```
- [ ] **Step 7: Build and run the probe (this is the spike)**
Run:
```sh
make check; echo "check rc=$?"
make native; echo "native rc=$?"
SDL_VIDEODRIVER=offscreen ./zig-out/bin/muxg; echo "offscreen rc=$?"
./zig-out/bin/muxg; echo "display rc=$?"
```
Expected: `check rc=0`; `native rc=0`; the offscreen run prints `muxg probe: driver=offscreen GL_VERSION=3.3 ...` (or higher) and `offscreen rc=0`; the display run prints `driver=wayland` or `x11` and rc 0.
If the offscreen run prints `SDL_GL_CreateContext: ...` and rc 2, the offscreen driver on this SDL has no EGL: record that line, and in Task 9 the leg runs under `xvfb-run -a` with `SDL_VIDEODRIVER=x11` instead (`pacman -S xorg-server-xvfb` on Arch). Amend the spec's Testing section with which was true (step 8).
- [ ] **Step 8: Amend the spec's three stale lines**
In `docs/superpowers/specs/2026-09-04-native-client-design.md`:
- Section 1, the pkg-config sentence: replace `SDL3, libGL, freetype and fontconfig` with `SDL3, freetype, fontconfig and HarfBuzz (GL functions are loaded by name through SDL, so no libGL link)`.
- Section 6, `native-test builds src/gui/ without SDL and runs:` → `native-test links the same libraries, opens no window, and runs:`.
- Section 5, add a bullet: `A local dial to a socket nobody answers exits 2 with \`muxg: no daemon at PATH (run: mux d start -d --sock PATH)\`. muxg never starts a daemon: the self-exec rule says an auto-start may only run the image already running, and this image is not the daemon's.`
- Section 6, step 1 of the leg: record the offscreen or Xvfb outcome from step 7.
- [ ] **Step 9: Commit**
```sh
make check && git add build.zig Makefile src/gui/native.zig src/cli/muxg.zig docs/superpowers/specs/2026-09-04-native-client-design.md
git commit -m "build: opt-in native rows, steps and folder rules for the muxg viewer"
```
---
### Task 2: `bench.zig` — the frame-timing ring
**Files:**
- Create: `src/gui/bench.zig`
- Modify: `src/gui/native.zig` (add `pub const bench = @import("bench.zig");` and `_ = bench;` in the test block)
**Interfaces:**
- Produces:
- `pub const Frame = struct { apply_us: u32 = 0, rebuild_us: u32 = 0, atlas_us: u32 = 0, upload_us: u32 = 0, draw_us: u32 = 0 }`
- `pub const Ring = struct { pub fn record(self: *Ring, f: Frame) void; pub fn count(self: *const Ring) u64; pub fn report(self: *const Ring, buf: []u8) []const u8; }` with `pub const capacity = 256`
- `pub fn usSince(t: *std.time.Timer) u32` — microseconds since the timer's last lap, capped at `maxInt(u32)`.
- [ ] **Step 1: Write the failing tests**
Append to `src/gui/bench.zig` (the file does not exist yet; create it with just these tests and `const std = @import("std");` at the top):
```zig
test "an empty ring reports zero frames and no stats rows" {
var r: Ring = .{};
var buf: [2048]u8 = undefined;
const out = r.report(&buf);
try std.testing.expect(std.mem.indexOf(u8, out, "(0 frames)") != null);
}
/// The four numbers on the table row that starts with `name`, or null.
/// Tokenised rather than matched byte for byte, so the test pins the
/// stats and not the column widths.
fn statsRow(table: []const u8, name: []const u8) ?[4]u32 {
var lines = std.mem.splitScalar(u8, table, '\n');
while (lines.next()) |line| {
var toks = std.mem.tokenizeScalar(u8, line, ' ');
const first = toks.next() orelse continue;
if (!std.mem.eql(u8, first, name)) continue;
var out: [4]u32 = undefined;
for (&out) |*slot| slot.* = std.fmt.parseInt(u32, toks.next() orelse return null, 10) catch return null;
return out;
}
return null;
}
test "min avg p99 max per stage and a total row" {
var r: Ring = .{};
// 100 frames: rebuild 1..100 us, draw a flat 10 us.
var i: u32 = 1;
while (i <= 100) : (i += 1) r.record(.{ .rebuild_us = i, .draw_us = 10 });
try std.testing.expectEqual(@as(u64, 100), r.count());
var buf: [2048]u8 = undefined;
const out = r.report(&buf);
try std.testing.expect(std.mem.indexOf(u8, out, "(100 frames)") != null);
try std.testing.expectEqual([4]u32{ 1, 50, 99, 100 }, statsRow(out, "rebuild").?);
try std.testing.expectEqual([4]u32{ 10, 10, 10, 10 }, statsRow(out, "draw_swap").?);
// total per frame = rebuild + draw: min 11, avg 60, p99 109, max 110
try std.testing.expectEqual([4]u32{ 11, 60, 109, 110 }, statsRow(out, "total").?);
}
test "the ring keeps the newest capacity frames" {
var r: Ring = .{};
var i: u32 = 0;
while (i < Ring.capacity + 10) : (i += 1) r.record(.{ .apply_us = i });
try std.testing.expectEqual(@as(u64, Ring.capacity + 10), r.count());
var buf: [2048]u8 = undefined;
const out = r.report(&buf);
// The oldest ten (0..9) fell out: min is 10, max is 265.
const s = statsRow(out, "apply").?;
try std.testing.expectEqual(@as(u32, 10), s[0]);
try std.testing.expectEqual(@as(u32, Ring.capacity + 9), s[3]);
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `deps/zig/zig build native-test 2>&1 | tail -5`
Expected: compile error, `Ring` not found.
- [ ] **Step 3: Implement**
Prepend to `src/gui/bench.zig` (above the tests):
```zig
//! Per-frame stage timings in a ring, and the table that reports them.
//! Always compiled in: a timer read per stage is noise next to a draw, and
//! the table is how the waystty fault was found — a painter that is fast
//! per frame and never asked to paint. Idle passes are not recorded.
//! Pure: no clock of its own, no output; the caller times and prints.
const std = @import("std");
pub const Frame = struct {
apply_us: u32 = 0,
rebuild_us: u32 = 0,
atlas_us: u32 = 0,
upload_us: u32 = 0,
draw_us: u32 = 0,
fn total(self: Frame) u32 {
return self.apply_us +| self.rebuild_us +| self.atlas_us +| self.upload_us +| self.draw_us;
}
};
pub const Ring = struct {
pub const capacity = 256;
frames: [capacity]Frame = undefined,
next: usize = 0,
recorded: u64 = 0,
pub fn record(self: *Ring, f: Frame) void {
self.frames[self.next] = f;
self.next = (self.next + 1) % capacity;
self.recorded += 1;
}
pub fn count(self: *const Ring) u64 {
return self.recorded;
}
fn held(self: *const Ring) usize {
return @intCast(@min(self.recorded, capacity));
}
const Stats = struct { min: u32, avg: u32, p99: u32, max: u32 };
fn stats(self: *const Ring, comptime pick: fn (Frame) u32) Stats {
const n = self.held();
var vals: [capacity]u32 = undefined;
var sum: u64 = 0;
for (self.frames[0..n], 0..) |f, i| {
vals[i] = pick(f);
sum += vals[i];
}
std.mem.sort(u32, vals[0..n], {}, std.sort.asc(u32));
return .{
.min = vals[0],
.avg = @intCast(sum / n),
.p99 = vals[(n * 99) / 100 - @intFromBool((n * 99) % 100 == 0 and n * 99 / 100 > 0)],
.max = vals[n - 1],
};
}
const rows = [_]struct { name: []const u8, pick: fn (Frame) u32 }{
.{ .name = "apply", .pick = struct {
fn f(x: Frame) u32 {
return x.apply_us;
}
}.f },
.{ .name = "rebuild", .pick = struct {
fn f(x: Frame) u32 {
return x.rebuild_us;
}
}.f },
.{ .name = "atlas_upload", .pick = struct {
fn f(x: Frame) u32 {
return x.atlas_us;
}
}.f },
.{ .name = "instance_upload", .pick = struct {
fn f(x: Frame) u32 {
return x.upload_us;
}
}.f },
.{ .name = "draw_swap", .pick = struct {
fn f(x: Frame) u32 {
return x.draw_us;
}
}.f },
.{ .name = "total", .pick = Frame.total },
};
/// The table, into `buf`. A buffer too small truncates the table
/// rather than failing: a report is diagnostics, never a gate.
pub fn report(self: *const Ring, buf: []u8) []const u8 {
var w = std.io.Writer.fixed(buf);
w.print("=== muxg frame timing ({d} frames) ===\n", .{self.recorded}) catch return w.buffered();
w.print("{s:<16}{s:>6}{s:>7}{s:>7}{s:>7} (us)\n", .{ "stage", "min", "avg", "p99", "max" }) catch return w.buffered();
if (self.held() == 0) return w.buffered();
inline for (rows) |row| {
const s = self.stats(row.pick);
w.print("{s:<16}{d:>6}{d:>7}{d:>7}{d:>7}\n", .{ row.name, s.min, s.avg, s.p99, s.max }) catch return w.buffered();
}
return w.buffered();
}
};
/// Microseconds since the timer's last lap, capped so a stall cannot wrap.
pub fn usSince(t: *std.time.Timer) u32 {
const ns = t.lap();
return @intCast(@min(ns / std.time.ns_per_us, std.math.maxInt(u32)));
}
```
The p99 index: for n = 100 the test expects 99 (the 99th sorted value, index 98); `(n*99)/100 = 99`, minus 1 when `n*99` divides evenly, giving index 98 → value 99. For n = 256: `25344/100 = 253`, not even, index 253. For n = 1: `99/100 = 0`, value at index 0.
- [ ] **Step 4: Wire the re-export and run the tests**
In `src/gui/native.zig` add `pub const bench = @import("bench.zig");` under `pub const frame` and `_ = bench;` in the test block. (`frame.zig` does not exist until Task 8; until then keep `pub const frame` and `run` OUT of `native.zig` — add them in Task 8. For Tasks 2–7, `native.zig` re-exports only the files that exist.)
Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?`
Expected: rc 0, no output from the tests. The column widths are the spec's sample table (`stage` 16 wide, then 6, 7, 7, 7); the tests tokenise rows, so a width change never fails them.
- [ ] **Step 5: Commit**
```sh
make check && git add src/gui/bench.zig src/gui/native.zig
git commit -m "feat: the native client's per-frame stage timing ring and table"
```
---
### Task 3: `atlas.zig` — the glyph atlas
**Files:**
- Create: `src/gui/atlas.zig`
- Modify: `src/gui/native.zig` (re-export + test reach)
**Interfaces:**
- Produces:
- `pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }`
- `pub const Atlas = struct { width: u16, height: u16, pixels: []u8, dirty: bool, ... pub fn init(alloc, width: u16, height: u16) !Atlas; pub fn deinit(self: *Atlas, alloc) void; pub fn get(self: *const Atlas, cp: u21) ?Entry; pub fn put(self: *Atlas, alloc, cp: u21, w: u16, h: u16, left: i16, top: i16, bitmap: []const u8) !Entry; }`
- `put` copies `bitmap` (row-major, `w*h` bytes, one byte per pixel) into the atlas, growing its height by doubling when the current shelves are full, and sets `dirty = true`. The caller clears `dirty` after uploading.
- [ ] **Step 1: Write the failing tests**
Create `src/gui/atlas.zig` with `const std = @import("std");` and:
```zig
test "a second glyph lands beside the first on the same shelf" {
const alloc = std.testing.allocator;
var a = try Atlas.init(alloc, 64, 16);
defer a.deinit(alloc);
const px = [_]u8{0xff} ** (8 * 12);
const e1 = try a.put(alloc, 'A', 8, 12, 0, 12, &px);
const e2 = try a.put(alloc, 'B', 8, 12, 1, 11, &px);
try std.testing.expectEqual(@as(u16, 0), e1.x);
try std.testing.expectEqual(@as(u16, 8), e2.x);
try std.testing.expectEqual(e1.y, e2.y);
try std.testing.expectEqual(@as(i16, 1), e2.left);
try std.testing.expect(a.dirty);
try std.testing.expectEqual(e1, a.get('A').?);
try std.testing.expect(a.get('C') == null);
}
test "a row that overflows opens a new shelf below" {
const alloc = std.testing.allocator;
var a = try Atlas.init(alloc, 20, 64);
defer a.deinit(alloc);
const px = [_]u8{1} ** (8 * 10);
_ = try a.put(alloc, 'a', 8, 10, 0, 0, &px);
_ = try a.put(alloc, 'b', 8, 10, 0, 0, &px);
const e3 = try a.put(alloc, 'c', 8, 10, 0, 0, &px);
try std.testing.expectEqual(@as(u16, 0), e3.x);
try std.testing.expectEqual(@as(u16, 10), e3.y);
}
test "growth keeps every earlier entry's rectangle and pixels" {
const alloc = std.testing.allocator;
var a = try Atlas.init(alloc, 16, 6);
defer a.deinit(alloc);
const px1 = [_]u8{7} ** (4 * 4);
const e1 = try a.put(alloc, 'x', 4, 4, 0, 0, &px1);
const px2 = [_]u8{9} ** (4 * 8);
// 8 tall on a 6-high atlas: the shelf grows the atlas to 12 (doubling).
const e2 = try a.put(alloc, 'y', 4, 8, 0, 0, &px2);
try std.testing.expectEqual(@as(u16, 12), a.height);
try std.testing.expectEqual(@as(u16, 4), e2.x); // beside x, same shelf
try std.testing.expectEqual(e1, a.get('x').?);
try std.testing.expectEqual(@as(u8, 7), a.pixels[@as(usize, e1.y) * a.width + e1.x]);
try std.testing.expectEqual(@as(u8, 9), a.pixels[@as(usize, e2.y) * a.width + e2.x]);
}
test "a glyph wider than the atlas is refused, not wrapped" {
const alloc = std.testing.allocator;
var a = try Atlas.init(alloc, 8, 8);
defer a.deinit(alloc);
const px = [_]u8{1} ** (9 * 1);
try std.testing.expectError(error.GlyphTooWide, a.put(alloc, 'w', 9, 1, 0, 0, &px));
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `deps/zig/zig build native-test 2>&1 | tail -5` — after adding `pub const atlas = @import("atlas.zig");` and `_ = atlas;` to `native.zig`.
Expected: compile error, `Atlas` not found.
- [ ] **Step 3: Implement**
Prepend to `src/gui/atlas.zig`:
```zig
//! One R8 texture's worth of glyph bitmaps, shelf-packed: glyphs go left
//! to right along a shelf as tall as its tallest glyph, and a glyph that
//! does not fit opens the next shelf below. The atlas never shrinks and
//! never moves an entry — growth doubles the height and copies, so every
//! rectangle a quad already holds stays true. `dirty` says the texture on
//! the GPU is behind; the uploader clears it. No GL here.
const std = @import("std");
pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 };
pub const Atlas = struct {
width: u16,
height: u16,
pixels: []u8,
dirty: bool = false,
entries: std.AutoHashMapUnmanaged(u21, Entry) = .empty,
/// The open shelf: its top row, its height, and the next free x.
shelf_y: u16 = 0,
shelf_h: u16 = 0,
shelf_x: u16 = 0,
pub fn init(alloc: std.mem.Allocator, width: u16, height: u16) !Atlas {
const px = try alloc.alloc(u8, @as(usize, width) * height);
@memset(px, 0);
return .{ .width = width, .height = height, .pixels = px };
}
pub fn deinit(self: *Atlas, alloc: std.mem.Allocator) void {
alloc.free(self.pixels);
self.entries.deinit(alloc);
}
pub fn get(self: *const Atlas, cp: u21) ?Entry {
return self.entries.get(cp);
}
pub fn put(
self: *Atlas,
alloc: std.mem.Allocator,
cp: u21,
w: u16,
h: u16,
left: i16,
top: i16,
bitmap: []const u8,
) !Entry {
if (w > self.width) return error.GlyphTooWide;
std.debug.assert(bitmap.len >= @as(usize, w) * h);
// Wrap to a new shelf when this glyph does not fit beside the last.
if (self.shelf_x + w > self.width) {
self.shelf_y += self.shelf_h;
self.shelf_h = 0;
self.shelf_x = 0;
}
// Grow until the shelf's bottom (with this glyph on it) fits.
while (self.shelf_y + @max(self.shelf_h, h) > self.height) try self.grow(alloc);
const e: Entry = .{ .x = self.shelf_x, .y = self.shelf_y, .w = w, .h = h, .left = left, .top = top };
var row: usize = 0;
while (row < h) : (row += 1) {
const dst = (@as(usize, e.y) + row) * self.width + e.x;
@memcpy(self.pixels[dst .. dst + w], bitmap[row * w .. row * w + w]);
}
self.shelf_x += w;
self.shelf_h = @max(self.shelf_h, h);
self.dirty = true;
try self.entries.put(alloc, cp, e);
return e;
}
fn grow(self: *Atlas, alloc: std.mem.Allocator) !void {
const new_h: u16 = std.math.mul(u16, self.height, 2) catch return error.AtlasFull;
const px = try alloc.alloc(u8, @as(usize, self.width) * new_h);
@memset(px, 0);
@memcpy(px[0..self.pixels.len], self.pixels);
alloc.free(self.pixels);
self.pixels = px;
self.height = new_h;
self.dirty = true;
}
};
```
- [ ] **Step 4: Run the tests**
Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?`
Expected: rc 0.
- [ ] **Step 5: Commit**
```sh
make check && git add src/gui/atlas.zig src/gui/native.zig
git commit -m "feat: the native client's shelf-packed glyph atlas"
```
---
### Task 4: `quads.zig` — cells to instanced quads
**Files:**
- Create: `src/gui/quads.zig`
- Modify: `src/gui/native.zig` (re-export + test reach)
**Interfaces:**
- Consumes: `term.grid.Row`, `term.grid.Cell`, `term.protocol.CellStyle`, `term.protocol.Wide`, `term.protocol.color_none/colorPalette/colorRgb`; `atlas.Entry`.
- Produces:
- `pub const Instance = extern struct { x: f32, y: f32, w: f32, h: f32, u0: f32, v0: f32, u1: f32, v1: f32, rgba: u32, kind: u32 }` — `kind` 0 solid, 1 glyph.
- `pub const Glyphs = struct { ctx: *anyopaque, get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry }`
- `pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, default_fg: u32 = 0xd0d0d0ff, default_bg: u32 = 0x101010ff }`
- `pub fn rowInstances(out: *std.ArrayListUnmanaged(Instance), alloc, row: *const Row, cols: u16, col_off: u16, y: u16, ctx: Ctx) !void`
- `pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance`
- `pub fn rgbaOf(color: u32, default: u32) u32` — the wire colour (none / palette / rgb) to packed RGBA.
- [ ] **Step 1: Write the failing tests**
Create `src/gui/quads.zig` with the imports and tests:
```zig
const std = @import("std");
const term = @import("term");
const grid = term.grid;
const proto = term.protocol;
const atlas = @import("atlas.zig");
// --- tests ---
const StubGlyphs = struct {
fn get(_: *anyopaque, cp: u21) ?atlas.Entry {
// Every codepoint has a 6x10 glyph at (cp % 4) * 8 on shelf 0.
return .{ .x = @intCast((cp % 4) * 8), .y = 0, .w = 6, .h = 10, .left = 1, .top = 9 };
}
};
var stub_ctx_byte: u8 = 0;
fn testCtx() Ctx {
return .{
.cell_w = 8,
.cell_h = 16,
.ascent = 12,
.x0 = 0,
.y0 = 0,
.atlas_w = 64,
.atlas_h = 64,
.glyphs = .{ .ctx = @ptrCast(&stub_ctx_byte), .get = StubGlyphs.get },
};
}
/// A row built by hand: `spec` is one entry per cell, text "" for a blank.
const CellSpec = struct { text: []const u8 = "", style: proto.CellStyle = .{}, wide: proto.Wide = .narrow };
fn rowOf(alloc: std.mem.Allocator, spec: []const CellSpec) !grid.Row {
var r: grid.Row = .{ .cells = try alloc.alloc(grid.Cell, spec.len), .text = .empty };
for (spec, 0..) |s, i| {
const off: u32 = @intCast(r.text.items.len);
try r.text.appendSlice(alloc, s.text);
r.cells[i] = .{ .style = s.style, .wide = s.wide, .text_off = off, .text_len = @intCast(s.text.len) };
}
return r;
}
fn freeRow(alloc: std.mem.Allocator, r: *grid.Row) void {
alloc.free(r.cells);
r.text.deinit(alloc);
}
test "a blank cell emits nothing, text one glyph quad, a styled blank one background quad" {
const alloc = std.testing.allocator;
var row = try rowOf(alloc, &.{
.{},
.{ .text = "A" },
.{ .style = .{ .bg = proto.colorRgb(10, 20, 30) } },
});
defer freeRow(alloc, &row);
var out: std.ArrayListUnmanaged(Instance) = .empty;
defer out.deinit(alloc);
// Column offset 5, row 2: the fixture is off-origin on purpose.
try rowInstances(&out, alloc, &row, 3, 5, 2, testCtx());
try std.testing.expectEqual(@as(usize, 2), out.items.len);
const g = out.items[0];
try std.testing.expectEqual(@as(u32, 1), g.kind);
// cell (5+1, 2) at 8x16: x = 48 + left 1 = 49; y = 32 + (ascent 12 - top 9) = 35
try std.testing.expectEqual(@as(f32, 49), g.x);
try std.testing.expectEqual(@as(f32, 35), g.y);
try std.testing.expectEqual(@as(f32, 6), g.w);
try std.testing.expectEqual(@as(f32, 10), g.h);
// 'A' = 65, 65 % 4 = 1 → atlas x 8: u0 = 8/64
try std.testing.expectApproxEqAbs(@as(f32, 0.125), g.u0, 1e-6);
try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), g.rgba);
const b = out.items[1];
try std.testing.expectEqual(@as(u32, 0), b.kind);
try std.testing.expectEqual(@as(f32, 56), b.x);
try std.testing.expectEqual(@as(f32, 32), b.y);
try std.testing.expectEqual(@as(f32, 8), b.w);
try std.testing.expectEqual(@as(f32, 16), b.h);
try std.testing.expectEqual(@as(u32, 0x0a141eff), b.rgba);
}
test "a wide cell emits one two-column glyph quad and its tail emits nothing" {
const alloc = std.testing.allocator;
var row = try rowOf(alloc, &.{
.{ .text = "漢", .wide = .wide },
.{ .wide = .spacer_tail },
.{ .text = "x" },
});
defer freeRow(alloc, &row);
var out: std.ArrayListUnmanaged(Instance) = .empty;
defer out.deinit(alloc);
try rowInstances(&out, alloc, &row, 3, 0, 0, testCtx());
try std.testing.expectEqual(@as(usize, 2), out.items.len);
try std.testing.expectEqual(@as(f32, 1), out.items[0].x);
try std.testing.expectEqual(@as(f32, 16 + 1), out.items[1].x);
}
test "background quads come before glyph quads so a glyph is never covered" {
const alloc = std.testing.allocator;
var row = try rowOf(alloc, &.{
.{ .text = "q", .style = .{ .bg = proto.colorPalette(1) } },
});
defer freeRow(alloc, &row);
var out: std.ArrayListUnmanaged(Instance) = .empty;
defer out.deinit(alloc);
try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx());
try std.testing.expectEqual(@as(usize, 2), out.items.len);
try std.testing.expectEqual(@as(u32, 0), out.items[0].kind);
try std.testing.expectEqual(@as(u32, 1), out.items[1].kind);
try std.testing.expectEqual(@as(u32, 0xcc0000ff), out.items[0].rgba); // palette 1 = red
}
test "inverse swaps fg and bg" {
const alloc = std.testing.allocator;
var row = try rowOf(alloc, &.{
.{ .text = "i", .style = .{ .flags = 1 << 4 } },
});
defer freeRow(alloc, &row);
var out: std.ArrayListUnmanaged(Instance) = .empty;
defer out.deinit(alloc);
try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx());
try std.testing.expectEqual(@as(usize, 2), out.items.len);
try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), out.items[0].rgba); // bg painted in default fg
try std.testing.expectEqual(@as(u32, 0x101010ff), out.items[1].rgba); // glyph in default bg
}
test "the cursor quad covers its cell" {
const cur = cursorInstance(3, 1, testCtx());
try std.testing.expectEqual(@as(f32, 24), cur.x);
try std.testing.expectEqual(@as(f32, 16), cur.y);
try std.testing.expectEqual(@as(f32, 8), cur.w);
try std.testing.expectEqual(@as(f32, 16), cur.h);
try std.testing.expectEqual(@as(u32, 0), cur.kind);
}
test "wire colours: none is the default, palette indexes the table, rgb passes through" {
try std.testing.expectEqual(@as(u32, 0xabcdefff), rgbaOf(proto.color_none, 0xabcdefff));
try std.testing.expectEqual(@as(u32, 0x000000ff), rgbaOf(proto.colorPalette(0), 0xabcdefff));
try std.testing.expectEqual(@as(u32, 0x0000eeff), rgbaOf(proto.colorPalette(4), 0));
try std.testing.expectEqual(@as(u32, 0x5f0000ff), rgbaOf(proto.colorPalette(52), 0)); // cube (1,0,0)
try std.testing.expectEqual(@as(u32, 0x080808ff), rgbaOf(proto.colorPalette(232), 0)); // first grey
try std.testing.expectEqual(@as(u32, 0x112233ff), rgbaOf(proto.colorRgb(0x11, 0x22, 0x33), 0));
}
```
- [ ] **Step 2: Run to verify they fail**
Add `pub const quads = @import("quads.zig");` and `_ = quads;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
Expected: compile error, `Instance`/`rowInstances` not found.
- [ ] **Step 3: Implement**
Insert between the imports and `// --- tests ---` in `src/gui/quads.zig`:
```zig
//! A grid row becomes instanced quads: zero, one or two per cell — a
//! background quad when the cell's background is not the default, a glyph
//! quad when the cell has text. Backgrounds come first in the output so a
//! glyph is never covered by its own cell's background. A wide cell's
//! glyph spans two columns and its trailing half emits nothing. The port
//! of waystty's cell_instance.zig, rewritten over term.grid; the atlas
//! lookup is a callback so this file tests with no font and no GL.
pub const Instance = extern struct {
x: f32,
y: f32,
w: f32,
h: f32,
u0: f32,
v0: f32,
u1: f32,
v1: f32,
rgba: u32,
kind: u32,
pub const solid: u32 = 0;
pub const glyph: u32 = 1;
};
pub const Glyphs = struct {
ctx: *anyopaque,
get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry,
};
pub const Ctx = struct {
cell_w: u16,
cell_h: u16,
/// Baseline from the cell's top, in pixels: a glyph's `top` bearing is
/// measured from it.
ascent: u16,
x0: f32 = 0,
y0: f32 = 0,
atlas_w: f32,
atlas_h: f32,
glyphs: Glyphs,
default_fg: u32 = 0xd0d0d0ff,
default_bg: u32 = 0x101010ff,
};
/// ghostty Style.Flags bit 4 (see proto.CellStyle).
const flag_inverse: u16 = 1 << 4;
/// ghostty Style.Flags bit 5.
const flag_invisible: u16 = 1 << 5;
const ansi16 = [16]u32{
0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff,
0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff,
};
fn packRgb(r: u8, g: u8, b: u8) u32 {
return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff;
}
fn paletteRgba(i: u8) u32 {
if (i < 16) return ansi16[i];
if (i < 232) {
const n = i - 16;
const steps = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff };
return packRgb(steps[n / 36], steps[(n / 6) % 6], steps[n % 6]);
}
const g: u8 = 8 + 10 * (i - 232);
return packRgb(g, g, g);
}
/// A wire colour (proto.color_none, colorPalette, colorRgb) to packed RGBA.
pub fn rgbaOf(color: u32, default: u32) u32 {
return switch (color >> 24) {
1 => paletteRgba(@intCast(color & 0xff)),
2 => (color << 8) | 0xff,
else => default,
};
}
fn firstCodepoint(text: []const u8) ?u21 {
if (text.len == 0) return null;
const n = std.unicode.utf8ByteSequenceLength(text[0]) catch return null;
if (text.len < n) return null;
return std.unicode.utf8Decode(text[0..n]) catch null;
}
pub fn rowInstances(
out: *std.ArrayListUnmanaged(Instance),
alloc: std.mem.Allocator,
row: *const grid.Row,
cols: u16,
col_off: u16,
y: u16,
ctx: Ctx,
) !void {
const n: usize = @min(cols, row.cells.len);
const cell_h: f32 = @floatFromInt(ctx.cell_h);
const cell_w: f32 = @floatFromInt(ctx.cell_w);
const top = ctx.y0 + @as(f32, @floatFromInt(y)) * cell_h;
// Backgrounds first, then glyphs: two passes over the row, so the
// ordering rule holds without a sort.
var x: usize = 0;
while (x < n) : (x += 1) {
const c = row.cells[x];
if (c.wide == .spacer_tail) continue;
const inverse = c.style.flags & flag_inverse != 0;
const bg = if (inverse) rgbaOf(c.style.fg, ctx.default_fg) else rgbaOf(c.style.bg, ctx.default_bg);
if (!inverse and c.style.bg == proto.color_none) continue;
const span: f32 = if (c.wide == .wide) 2 else 1;
try out.append(alloc, .{
.x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w,
.y = top,
.w = cell_w * span,
.h = cell_h,
.u0 = 0,
.v0 = 0,
.u1 = 0,
.v1 = 0,
.rgba = bg,
.kind = Instance.solid,
});
}
x = 0;
while (x < n) : (x += 1) {
const c = row.cells[x];
if (c.wide == .spacer_tail or c.text_len == 0) continue;
if (c.style.flags & flag_invisible != 0) continue;
const cp = firstCodepoint(row.textOf(c)) orelse continue;
const e = ctx.glyphs.get(ctx.glyphs.ctx, cp) orelse continue;
const inverse = c.style.flags & flag_inverse != 0;
const fg = if (inverse) rgbaOf(c.style.bg, ctx.default_bg) else rgbaOf(c.style.fg, ctx.default_fg);
const cell_x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w;
try out.append(alloc, .{
.x = cell_x + @as(f32, @floatFromInt(e.left)),
.y = top + @as(f32, @floatFromInt(@as(i32, ctx.ascent) - e.top)),
.w = @floatFromInt(e.w),
.h = @floatFromInt(e.h),
.u0 = @as(f32, @floatFromInt(e.x)) / ctx.atlas_w,
.v0 = @as(f32, @floatFromInt(e.y)) / ctx.atlas_h,
.u1 = @as(f32, @floatFromInt(e.x + e.w)) / ctx.atlas_w,
.v1 = @as(f32, @floatFromInt(e.y + e.h)) / ctx.atlas_h,
.rgba = fg,
.kind = Instance.glyph,
});
}
}
pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance {
return .{
.x = ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)),
.y = ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)),
.w = @floatFromInt(ctx.cell_w),
.h = @floatFromInt(ctx.cell_h),
.u0 = 0,
.v0 = 0,
.u1 = 0,
.v1 = 0,
.rgba = ctx.default_fg,
.kind = Instance.solid,
};
}
```
- [ ] **Step 4: Run the tests**
Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
Expected: rc 0. `grid.Row.text` is an `ArrayListUnmanaged(u8)` on the landed tree and `rowOf` builds one directly, which is a test-only liberty: the production code reads a row's bytes only through `row.textOf`, so a later change to how a row stores its text cannot reach `quads.zig`.
- [ ] **Step 5: Commit**
```sh
make check && git add src/gui/quads.zig src/gui/native.zig
git commit -m "feat: grid rows become instanced quads for the native client"
```
---
### Task 5: `font.zig` — fontconfig and freetype
**Files:**
- Create: `src/gui/font.zig`
- Modify: `src/gui/native.zig` (re-export + test reach)
**Interfaces:**
- Produces:
- `pub const Glyph = struct { w: u16, h: u16, left: i16, top: i16, pixels: []u8, pub fn deinit(self: *Glyph, alloc) void }`
- `pub const Face = struct { cell_w: u16, cell_h: u16, ascent: u16, ... pub fn open(px: u16) !Face; pub fn deinit(self: *Face) void; pub fn render(self: *Face, alloc, cp: u21) !Glyph; }`
- `open` picks the system `monospace` face through fontconfig and sets the pixel size; `render` rasterises one codepoint to an 8-bit coverage bitmap (a missing codepoint renders the face's glyph 0, the missing-glyph box).
- [ ] **Step 1: Write the failing tests**
Create `src/gui/font.zig` with:
```zig
const std = @import("std");
const c = @cImport({
@cInclude("fontconfig/fontconfig.h");
@cInclude("ft2build.h");
@cInclude("freetype/freetype.h");
});
// --- tests ---
test "the system monospace face measures a cell and renders a glyph" {
var face = try Face.open(16);
defer face.deinit();
try std.testing.expect(face.cell_w > 0 and face.cell_w < 64);
try std.testing.expect(face.cell_h >= 16 and face.cell_h < 64);
try std.testing.expect(face.ascent > 0 and face.ascent <= face.cell_h);
var g = try face.render(std.testing.allocator, 'A');
defer g.deinit(std.testing.allocator);
try std.testing.expect(g.w > 0 and g.h > 0);
try std.testing.expectEqual(@as(usize, g.w) * g.h, g.pixels.len);
var ink: usize = 0;
for (g.pixels) |p| ink += @intFromBool(p > 0);
try std.testing.expect(ink > 0);
}
test "a space renders empty, a missing codepoint renders the box, not an error" {
var face = try Face.open(16);
defer face.deinit();
var sp = try face.render(std.testing.allocator, ' ');
defer sp.deinit(std.testing.allocator);
var ink: usize = 0;
for (sp.pixels) |p| ink += @intFromBool(p > 0);
try std.testing.expectEqual(@as(usize, 0), ink);
var box = try face.render(std.testing.allocator, 0x10FFFD);
defer box.deinit(std.testing.allocator);
_ = box.w;
}
```
- [ ] **Step 2: Run to verify they fail**
Add `pub const font = @import("font.zig");` and `_ = font;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
Expected: compile error, `Face` not found.
- [ ] **Step 3: Implement**
Insert between the imports and `// --- tests ---`:
```zig
//! The face and its glyphs: fontconfig chooses the system monospace face,
//! freetype rasterises one codepoint at a time to 8-bit coverage. The cell
//! is the face's advance by its line height, in whole pixels. One face,
//! no fallback chain, no colour emoji (deferred): a codepoint the face
//! lacks renders as its missing-glyph box, which is honest and visible.
pub const Glyph = struct {
w: u16,
h: u16,
left: i16,
top: i16,
pixels: []u8,
pub fn deinit(self: *Glyph, alloc: std.mem.Allocator) void {
alloc.free(self.pixels);
}
};
pub const Face = struct {
lib: c.FT_Library,
face: c.FT_Face,
cell_w: u16,
cell_h: u16,
ascent: u16,
pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, GlyphLoad, OutOfMemory };
/// The path and index fontconfig answers for `monospace`, into `buf`.
fn matchMonospace(buf: *[std.fs.max_path_bytes]u8, index_out: *c_int) Error![]const u8 {
if (c.FcInit() == c.FcFalse) return error.NoFontconfig;
const pat = c.FcNameParse("monospace") orelse return error.NoMonospaceFace;
defer c.FcPatternDestroy(pat);
_ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern);
c.FcDefaultSubstitute(pat);
var result: c.FcResult = undefined;
const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace;
defer c.FcPatternDestroy(found);
var file: [*c]c.FcChar8 = null;
if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace;
var index: c_int = 0;
if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, &index) != c.FcResultMatch) index = 0;
index_out.* = index;
const path = std.mem.span(@as([*:0]const u8, @ptrCast(file)));
if (path.len >= buf.len) return error.NoMonospaceFace;
@memcpy(buf[0..path.len], path);
buf[path.len] = 0;
return buf[0..path.len];
}
pub fn open(px: u16) Error!Face {
var lib: c.FT_Library = null;
if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit;
errdefer _ = c.FT_Done_FreeType(lib);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
var index: c_int = 0;
const path = try matchMonospace(&path_buf, &index);
var face: c.FT_Face = null;
if (c.FT_New_Face(lib, @ptrCast(path.ptr), index, &face) != 0) return error.FaceLoad;
errdefer _ = c.FT_Done_Face(face);
if (c.FT_Set_Pixel_Sizes(face, 0, px) != 0) return error.SizeSet;
const m = face.*.size.*.metrics;
// 26.6 fixed point; round up so no glyph is clipped at the cell edge.
const adv: i64 = @intCast((m.max_advance + 63) >> 6);
const height: i64 = @intCast((m.height + 63) >> 6);
const asc: i64 = @intCast((m.ascender + 63) >> 6);
return .{
.lib = lib,
.face = face,
.cell_w = @intCast(@max(adv, 1)),
.cell_h = @intCast(@max(height, 1)),
.ascent = @intCast(std.math.clamp(asc, 1, @max(height, 1))),
};
}
pub fn deinit(self: *Face) void {
_ = c.FT_Done_Face(self.face);
_ = c.FT_Done_FreeType(self.lib);
}
pub fn render(self: *Face, alloc: std.mem.Allocator, cp: u21) Error!Glyph {
var idx = c.FT_Get_Char_Index(self.face, cp);
if (idx == 0) idx = 0; // glyph 0 IS the missing-glyph box
if (c.FT_Load_Glyph(self.face, idx, c.FT_LOAD_RENDER) != 0) return error.GlyphLoad;
const slot = self.face.*.glyph;
const bm = slot.*.bitmap;
const w: u16 = @intCast(bm.width);
const h: u16 = @intCast(bm.rows);
const pixels = try alloc.alloc(u8, @as(usize, w) * h);
var row: usize = 0;
while (row < h) : (row += 1) {
const src: [*]const u8 = @ptrCast(bm.buffer + @as(usize, @intCast(bm.pitch)) * row);
@memcpy(pixels[row * w .. row * w + w], src[0..w]);
}
return .{
.w = w,
.h = h,
.left = @intCast(slot.*.bitmap_left),
.top = @intCast(slot.*.bitmap_top),
.pixels = pixels,
};
}
};
```
`bm.pitch` is positive for the top-down bitmaps `FT_LOAD_RENDER` produces; the `@intCast` traps in Debug if a face ever hands back a bottom-up one, which is the right place to learn that.
- [ ] **Step 4: Run the tests**
Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
Expected: rc 0. If the compile fails on a freetype field name (`face.*.size.*.metrics`), check `deps/zig/zig translate-c` output for `FT_FaceRec` and use the translated field spelling; the metrics fields are `max_advance`, `height`, `ascender` in `FT_Size_Metrics`.
- [ ] **Step 5: Commit**
```sh
make check && git add src/gui/font.zig src/gui/native.zig
git commit -m "feat: the native client's face and glyph rasteriser"
```
---
### Task 5b: Complete cell text and style rendering (required before integration)
**Files:** Modify `src/gui/font.zig`, `atlas.zig`, `quads.zig`, `native.zig`, and the Task 8 `GlyphCache`/rebuild integration; amend the spec's font and quads descriptions to name HarfBuzz shaping and variable instance counts. Update dependency documentation to include HarfBuzz. This implements existing text/style promises; these are not deferrals.
The Tasks 3–5 listings establish packing, metrics and positioning, but their codepoint-only cache and `firstCodepoint` helper are scaffolding. Replace them before Task 8 is considered complete:
- [ ] Replace the codepoint-only glyph lookup with a callback taking the complete `row.textOf(cell)` slice and its font style, returning a positioned glyph run. Shape the entire cell cluster with HarfBuzz using the selected FreeType face. Apply the returned offsets and advances within the cell; the daemon's narrow/wide span remains authoritative. Do not shape across cell boundaries. Remove `firstCodepoint`; combining marks must not disappear.
- [ ] Cache shaped runs by owned UTF-8 text plus font variant, and atlas bitmaps by face variant plus glyph ID. Distinguish regular, bold, italic and bold-italic. Match the appropriate monospace variants through fontconfig; if unavailable, use FreeType synthesis for the missing style. Rasterize glyph IDs rather than interpreting them as codepoints. Keep missing-glyph behavior, fallback-face and colour-emoji deferrals unchanged.
- [ ] Read every defined `CellStyle.flags` field from `proto.CellStyle`. Preserve inverse/invisible handling; render faint, bold and italic; add solid decoration instances for strikethrough, overline and the encoded underline variants (single, double, curly, dotted, dashed), with underline colour from `style.ul` and effective foreground as its default. Invisible suppresses glyphs and decorations. Add a blink phase and a timed repaint only while visible blinking cells exist, so ordinary idle windows still draw nothing.
- [ ] Replace the two-instance-per-cell assumption with background, positioned glyph run and decoration emission. Emit all backgrounds before all foreground instances for the frame. Extend the callback/atlas interfaces and their stubs in Tasks 3–5 and Task 8 to the run and glyph-key contracts above; do not retain the single-codepoint `GlyphCache.get` listing at integration.
- [ ] Add deterministic quad tests for every style flag, underline variant/colour, combined styles, invisible decorations, and the blink phases. Use a fake positioned glyph run for `e` plus a combining acute accent and verify every glyph and offset survives, including at a nonzero column origin and in a wide cell. Add a font integration check that compares the rendered run with shaping the complete cluster, without assuming a particular glyph count (composition may produce one glyph).
- [ ] Verify that differently styled copies of the same text cannot share an incorrect atlas entry, and that atlas growth preserves the whole run's coordinates. Rebuild normalized UVs before drawing after growth; do not knowingly display a frame using the old atlas dimensions.
- [ ] Run `make native` and `deps/zig/zig build native-test`, then `make check` before committing.
### Task 6: `gl.zig` — the GL 3.3 renderer
**Files:**
- Create: `src/gui/gl.zig`
- Modify: `src/gui/native.zig` (re-export + test reach)
**Interfaces:**
- Consumes: `quads.Instance`, `atlas.Atlas`.
- Produces:
- `pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque`
- `pub const Renderer = struct { pub fn init(get: GetProc) !Renderer; pub fn deinit(self: *Renderer) void; pub fn uploadAtlas(self: *Renderer, a: *atlas.Atlas) void; pub fn uploadInstances(self: *Renderer, items: []const quads.Instance) void; pub fn draw(self: *Renderer, count: usize, fb_w: i32, fb_h: i32, clear_rgba: u32) void; }`
- `uploadAtlas` re-uploads the whole texture when `a.dirty` and clears it. `draw` clears to `clear_rgba`, sets the viewport, draws `count` instances.
- [ ] **Step 1: Write the one test that runs without a context**
Create `src/gui/gl.zig` with the imports and, at the bottom:
```zig
test "the loader names every function it needs and refuses a missing one" {
const Stub = struct {
var missing: []const u8 = "";
fn get(name: [*:0]const u8) callconv(.c) ?*anyopaque {
if (std.mem.eql(u8, std.mem.span(name), missing)) return null;
return @ptrCast(@constCast(&get));
}
};
Stub.missing = "glDrawArraysInstanced";
try std.testing.expectError(error.MissingGlFunction, Fns.load(Stub.get));
Stub.missing = "";
_ = try Fns.load(Stub.get);
}
```
- [ ] **Step 2: Run to verify it fails**
Add `pub const gl = @import("gl.zig");` and `_ = gl;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
Expected: compile error, `Fns` not found.
- [ ] **Step 3: Implement**
`src/gui/gl.zig` above the test:
```zig
//! OpenGL 3.3 core, loaded by name: one program, one vertex array, one
//! instance buffer, one R8 atlas texture, one instanced draw. Every GL
//! function is a pointer fetched through the loader the window layer hands
//! in, so this file names no windowing library and links no libGL. The
//! quad is built in the vertex shader from gl_VertexID; the instance
//! carries its rectangle, its atlas rectangle, its colour and its kind.
const std = @import("std");
const quads = @import("quads.zig");
const atlas = @import("atlas.zig");
const c = @cImport({
@cInclude("GL/glcorearb.h");
});
pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque;
/// The function table. Field names are the GL names, so `load` can fetch
/// each by reflection and a missing one is an error by name.
pub const Fns = struct {
glGetString: c.PFNGLGETSTRINGPROC,
glCreateShader: c.PFNGLCREATESHADERPROC,
glShaderSource: c.PFNGLSHADERSOURCEPROC,
glCompileShader: c.PFNGLCOMPILESHADERPROC,
glGetShaderiv: c.PFNGLGETSHADERIVPROC,
glGetShaderInfoLog: c.PFNGLGETSHADERINFOLOGPROC,
glCreateProgram: c.PFNGLCREATEPROGRAMPROC,
glAttachShader: c.PFNGLATTACHSHADERPROC,
glLinkProgram: c.PFNGLLINKPROGRAMPROC,
glGetProgramiv: c.PFNGLGETPROGRAMIVPROC,
glGetProgramInfoLog: c.PFNGLGETPROGRAMINFOLOGPROC,
glUseProgram: c.PFNGLUSEPROGRAMPROC,
glDeleteShader: c.PFNGLDELETESHADERPROC,
glDeleteProgram: c.PFNGLDELETEPROGRAMPROC,
glGetUniformLocation: c.PFNGLGETUNIFORMLOCATIONPROC,
glUniform2f: c.PFNGLUNIFORM2FPROC,
glUniform1i: c.PFNGLUNIFORM1IPROC,
glGenVertexArrays: c.PFNGLGENVERTEXARRAYSPROC,
glBindVertexArray: c.PFNGLBINDVERTEXARRAYPROC,
glDeleteVertexArrays: c.PFNGLDELETEVERTEXARRAYSPROC,
glGenBuffers: c.PFNGLGENBUFFERSPROC,
glBindBuffer: c.PFNGLBINDBUFFERPROC,
glBufferData: c.PFNGLBUFFERDATAPROC,
glDeleteBuffers: c.PFNGLDELETEBUFFERSPROC,
glEnableVertexAttribArray: c.PFNGLENABLEVERTEXATTRIBARRAYPROC,
glVertexAttribPointer: c.PFNGLVERTEXATTRIBPOINTERPROC,
glVertexAttribIPointer: c.PFNGLVERTEXATTRIBIPOINTERPROC,
glVertexAttribDivisor: c.PFNGLVERTEXATTRIBDIVISORPROC,
glGenTextures: c.PFNGLGENTEXTURESPROC,
glBindTexture: c.PFNGLBINDTEXTUREPROC,
glDeleteTextures: c.PFNGLDELETETEXTURESPROC,
glTexImage2D: c.PFNGLTEXIMAGE2DPROC,
glTexParameteri: c.PFNGLTEXPARAMETERIPROC,
glPixelStorei: c.PFNGLPIXELSTOREIPROC,
glActiveTexture: c.PFNGLACTIVETEXTUREPROC,
glViewport: c.PFNGLVIEWPORTPROC,
glClearColor: c.PFNGLCLEARCOLORPROC,
glClear: c.PFNGLCLEARPROC,
glEnable: c.PFNGLENABLEPROC,
glBlendFunc: c.PFNGLBLENDFUNCPROC,
glDrawArraysInstanced: c.PFNGLDRAWARRAYSINSTANCEDPROC,
pub fn load(get: GetProc) error{MissingGlFunction}!Fns {
var f: Fns = undefined;
inline for (std.meta.fields(Fns)) |field| {
const p = get(field.name ++ "") orelse return error.MissingGlFunction;
@field(f, field.name) = @ptrCast(p);
}
return f;
}
};
const vertex_src: [*:0]const u8 =
\\#version 330 core
\\layout(location = 0) in vec4 rect; // x y w h in pixels
\\layout(location = 1) in vec4 uv; // u0 v0 u1 v1
\\layout(location = 2) in uint rgba;
\\layout(location = 3) in uint kind;
\\uniform vec2 viewport;
\\out vec2 v_uv;
\\flat out vec4 v_color;
\\flat out uint v_kind;
\\void main() {
\\ vec2 corner = vec2(gl_VertexID & 1, (gl_VertexID >> 1) & 1);
\\ vec2 px = rect.xy + corner * rect.zw;
\\ vec2 ndc = vec2(px.x / viewport.x * 2.0 - 1.0, 1.0 - px.y / viewport.y * 2.0);
\\ gl_Position = vec4(ndc, 0.0, 1.0);
\\ v_uv = mix(uv.xy, uv.zw, corner);
\\ v_color = vec4(float((rgba >> 24u) & 255u), float((rgba >> 16u) & 255u),
\\ float((rgba >> 8u) & 255u), float(rgba & 255u)) / 255.0;
\\ v_kind = kind;
\\}
;
const fragment_src: [*:0]const u8 =
\\#version 330 core
\\in vec2 v_uv;
\\flat in vec4 v_color;
\\flat in uint v_kind;
\\uniform sampler2D atlas;
\\out vec4 frag;
\\void main() {
\\ float a = (v_kind == 1u) ? texture(atlas, v_uv).r : 1.0;
\\ frag = vec4(v_color.rgb, v_color.a * a);
\\}
;
pub const Renderer = struct {
f: Fns,
program: c.GLuint,
vao: c.GLuint,
vbo: c.GLuint,
tex: c.GLuint,
u_viewport: c.GLint,
tex_w: u16 = 0,
tex_h: u16 = 0,
pub const Error = error{ MissingGlFunction, ShaderCompile, ProgramLink };
fn compile(f: *const Fns, kind: c.GLenum, src: [*:0]const u8) Error!c.GLuint {
const sh = f.glCreateShader.?(kind);
f.glShaderSource.?(sh, 1, @ptrCast(&src), null);
f.glCompileShader.?(sh);
var ok: c.GLint = 0;
f.glGetShaderiv.?(sh, c.GL_COMPILE_STATUS, &ok);
if (ok == 0) {
var log: [1024]u8 = undefined;
var n: c.GLsizei = 0;
f.glGetShaderInfoLog.?(sh, log.len, &n, &log);
std.debug.print("muxg: shader: {s}\n", .{log[0..@intCast(n)]});
return error.ShaderCompile;
}
return sh;
}
pub fn init(get: GetProc) Error!Renderer {
const f = try Fns.load(get);
const vs = try compile(&f, c.GL_VERTEX_SHADER, vertex_src);
defer f.glDeleteShader.?(vs);
const fs = try compile(&f, c.GL_FRAGMENT_SHADER, fragment_src);
defer f.glDeleteShader.?(fs);
const prog = f.glCreateProgram.?();
f.glAttachShader.?(prog, vs);
f.glAttachShader.?(prog, fs);
f.glLinkProgram.?(prog);
var ok: c.GLint = 0;
f.glGetProgramiv.?(prog, c.GL_LINK_STATUS, &ok);
if (ok == 0) {
var log: [1024]u8 = undefined;
var n: c.GLsizei = 0;
f.glGetProgramInfoLog.?(prog, log.len, &n, &log);
std.debug.print("muxg: program: {s}\n", .{log[0..@intCast(n)]});
return error.ProgramLink;
}
var vao: c.GLuint = 0;
f.glGenVertexArrays.?(1, &vao);
f.glBindVertexArray.?(vao);
var vbo: c.GLuint = 0;
f.glGenBuffers.?(1, &vbo);
f.glBindBuffer.?(c.GL_ARRAY_BUFFER, vbo);
const stride: c.GLsizei = @sizeOf(quads.Instance);
f.glEnableVertexAttribArray.?(0);
f.glVertexAttribPointer.?(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "x")));
f.glVertexAttribDivisor.?(0, 1);
f.glEnableVertexAttribArray.?(1);
f.glVertexAttribPointer.?(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "u0")));
f.glVertexAttribDivisor.?(1, 1);
f.glEnableVertexAttribArray.?(2);
f.glVertexAttribIPointer.?(2, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "rgba")));
f.glVertexAttribDivisor.?(2, 1);
f.glEnableVertexAttribArray.?(3);
f.glVertexAttribIPointer.?(3, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "kind")));
f.glVertexAttribDivisor.?(3, 1);
var tex: c.GLuint = 0;
f.glGenTextures.?(1, &tex);
f.glBindTexture.?(c.GL_TEXTURE_2D, tex);
f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_NEAREST);
f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_NEAREST);
f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE);
f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE);
f.glPixelStorei.?(c.GL_UNPACK_ALIGNMENT, 1);
f.glUseProgram.?(prog);
f.glUniform1i.?(f.glGetUniformLocation.?(prog, "atlas"), 0);
f.glEnable.?(c.GL_BLEND);
f.glBlendFunc.?(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA);
return .{
.f = f,
.program = prog,
.vao = vao,
.vbo = vbo,
.tex = tex,
.u_viewport = f.glGetUniformLocation.?(prog, "viewport"),
};
}
pub fn deinit(self: *Renderer) void {
self.f.glDeleteTextures.?(1, &self.tex);
self.f.glDeleteBuffers.?(1, &self.vbo);
self.f.glDeleteVertexArrays.?(1, &self.vao);
self.f.glDeleteProgram.?(self.program);
}
/// The whole texture, only when the atlas says it changed. A whole
/// re-upload on a new glyph is deliberate for v1: the timing table's
/// atlas_upload row says whether an incremental path is ever needed.
pub fn uploadAtlas(self: *Renderer, a: *atlas.Atlas) void {
if (!a.dirty) return;
self.f.glActiveTexture.?(c.GL_TEXTURE0);
self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex);
self.f.glTexImage2D.?(c.GL_TEXTURE_2D, 0, c.GL_R8, a.width, a.height, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, a.pixels.ptr);
self.tex_w = a.width;
self.tex_h = a.height;
a.dirty = false;
}
pub fn uploadInstances(self: *Renderer, items: []const quads.Instance) void {
self.f.glBindBuffer.?(c.GL_ARRAY_BUFFER, self.vbo);
self.f.glBufferData.?(c.GL_ARRAY_BUFFER, @intCast(items.len * @sizeOf(quads.Instance)), items.ptr, c.GL_STREAM_DRAW);
}
pub fn draw(self: *Renderer, count: usize, fb_w: i32, fb_h: i32, clear_rgba: u32) void {
const r: f32 = @as(f32, @floatFromInt((clear_rgba >> 24) & 0xff)) / 255.0;
const g: f32 = @as(f32, @floatFromInt((clear_rgba >> 16) & 0xff)) / 255.0;
const b: f32 = @as(f32, @floatFromInt((clear_rgba >> 8) & 0xff)) / 255.0;
self.f.glViewport.?(0, 0, fb_w, fb_h);
self.f.glClearColor.?(r, g, b, 1.0);
self.f.glClear.?(c.GL_COLOR_BUFFER_BIT);
self.f.glUseProgram.?(self.program);
self.f.glUniform2f.?(self.u_viewport, @floatFromInt(fb_w), @floatFromInt(fb_h));
self.f.glBindVertexArray.?(self.vao);
self.f.glActiveTexture.?(c.GL_TEXTURE0);
self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex);
self.f.glDrawArraysInstanced.?(c.GL_TRIANGLE_STRIP, 0, 4, @intCast(count));
}
};
```
- [ ] **Step 4: Run the tests**
Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?`
Expected: rc 0. If a `PFNGL...PROC` name is not in the installed `glcorearb.h` (check with `grep -c PFNGLVERTEXATTRIBDIVISORPROC /usr/include/GL/glcorearb.h`), that function is `GL_ARB_instanced_arrays`'s spelling — it is in glcorearb.h under GL 3.3 on every Mesa install since 2012; report the exact missing name rather than substituting.
- [ ] **Step 5: Commit**
```sh
make check && git add src/gui/gl.zig src/gui/native.zig
git commit -m "feat: the native client's GL 3.3 instanced renderer"
```
---
### Task 7: `session_pump.zig` — the transport-owning thread
**Files:**
- Create: `src/client/session_pump.zig`
- Modify: `src/client/client.zig` (add `pub const session_pump = @import("session_pump.zig");` beside `pub const hosts = @import("hosts.zig");` near line 26, and `_ = @import("session_pump.zig");` in the `test { std.testing.refAllDeclsRecursive(@This()); ... }` block near line 2904, beside `_ = @import("hosts.zig");`)
**Interfaces:**
- Consumes: `client.Transport.open(alloc, target, null, -1, null)`, `.adopt`, `.pollFd`, `.timeoutMs`, `.service`, `.readFrame`, `.writeFrame`, `.flushQuic`, `.close`; `client.nextBackoffMs`; `client.core.ClientCore.receive`; `term.replica.Replica`, `term.grid.Grid`; `proto.encodeAttachNamed`, `proto.encodeSize`, `proto.wireName`, `proto.attach_max_len`.
- Produces:
- `pub const Say = union(enum) { input: []const u8, resize: struct { cols: u16, rows: u16 }, detach, quit }` — `input` bytes are COPIED by `say`.
- `pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed }`
- `pub const State = struct { phase: Phase = .dialing, exit_code: u8 = 0, bell: bool = false, reason: [128]u8 = undefined, reason_len: u8 = 0 }` with `pub fn reasonText(self: *const State) []const u8`
- `pub const Options = struct { target: client.Target, session: []const u8, cols: u16, rows: u16, wake: *const fn (ctx: *anyopaque) void, wake_ctx: *anyopaque }`
- `pub const Pump = struct { mu: std.Thread.Mutex, replica: Replica, grid: *Grid, last_apply_us: u32, ... pub fn start(alloc, opts: Options) !*Pump; pub fn say(self: *Pump, s: Say) !void; pub fn state(self: *Pump) State; pub fn stop(self: *Pump) void; }`
- Reading the grid: lock `pump.mu`, read `pump.grid` and `pump.last_apply_us`, unlock. `stop` posts `.quit`, joins the thread and frees the pump; it is the ONLY way to end it.
- [ ] **Step 1: Write the failing tests (mailbox, replay failures, and pump lifecycle)**
Create `src/client/session_pump.zig` with the imports and, at the bottom:
```zig
test "the mailbox keeps order, copies input and rings once per post" {
const alloc = std.testing.allocator;
var mb = try Mailbox.init(alloc);
defer mb.deinit();
var typed = [_]u8{ 'h', 'i' };
try mb.post(.{ .input = &typed });
typed[0] = 'X'; // the mailbox copied, so this cannot reach the frame
try mb.post(.{ .resize = .{ .cols = 100, .rows = 40 } });
try mb.post(.detach);
var ring: [8]u8 = undefined;
const n = std.posix.read(mb.wake_r, &ring) catch 0;
try std.testing.expect(n >= 1);
var items: std.ArrayListUnmanaged(Say) = .empty;
defer {
for (items.items) |it| mb.free(it);
items.deinit(alloc);
}
try mb.drain(&items);
try std.testing.expectEqual(@as(usize, 3), items.items.len);
try std.testing.expectEqualStrings("hi", items.items[0].input);
try std.testing.expectEqual(@as(u16, 100), items.items[1].resize.cols);
try std.testing.expect(items.items[2] == .detach);
}
test "state is read as a copy under the lock" {
var p: Pump = undefined;
p.state_mu = .{};
p.state_val = .{};
p.setPhase(.refused, "no such session");
const s = p.state();
try std.testing.expectEqual(Phase.refused, s.phase);
try std.testing.expectEqualStrings("no such session", s.reasonText());
}
```
Add pump regression fixtures using a local socket pair and a scripted peer, with bounded waits and captured thread lifetimes:
- Send one snapshot, then remain silent: the wake must arrive before any second frame; input, resize and detach must still reach the peer, and `stop` must join. Repeat for the pipe transport. The fd/pipe `readFrame` blocks; `.incomplete` is QUIC-only.
- Feed more than one QUIC batch of complete frames: each batch wakes the painter and services pending mailbox work without waiting for the receive buffer to empty.
- A short snapshot preserves the existing grid and produces no terminal failure; an aborted snapshot publishes `.failed` with a reason and wakes the window. The window must print the reason and exit 1, never 0.
- Inject poll failure and attach-write failure during reconnect: each unexpected thread exit publishes `.failed` and wakes the window. Intentional detach/quit must not become a failure.
- Admit one attach, reconnect, then send `exit_status` before any replay on the new attach: classify it as `.refused`. Verify the resume sequence and epoch were retained in the new attach.
- [ ] **Step 2: Run to verify they fail**
Run: `deps/zig/zig build test 2>&1 | tail -5` — `client` is in `test_order`, so `make test` runs this file's tests.
Expected: compile error, `Mailbox` not found.
- [ ] **Step 3: Implement**
`src/client/session_pump.zig` above the tests:
```zig
//! One thread that owns a transport from dial to close: it attaches to ONE
//! session, reads frames, applies them to a Replica it owns under a mutex,
//! feeds every non-replay frame to the client core, and rings a wake
//! callback once per pass that changed anything. The window thread never
//! touches the transport: it posts to the mailbox and rings the doorbell.
//!
//! What it does NOT handle is the point: no agent channels, no births, no
//! end arms, no session lists. A viewer asks none of those questions.
//!
//! This is the third terminal-free attach loop, after webhub.pumpTile and
//! mux a's. pumpTile is the first candidate to move onto it; it is not
//! moved here.
const std = @import("std");
const proto = @import("term").protocol;
const term = @import("term");
const client = @import("client.zig");
const core_mod = @import("client_core.zig");
const Replica = term.replica.Replica;
const Grid = term.grid.Grid;
pub const Say = union(enum) {
input: []const u8,
resize: struct { cols: u16, rows: u16 },
detach,
quit,
};
pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed };
pub const State = struct {
phase: Phase = .dialing,
exit_code: u8 = 0,
/// Set by the pump, cleared by whoever shows it.
bell: bool = false,
reason: [128]u8 = undefined,
reason_len: u8 = 0,
pub fn reasonText(self: *const State) []const u8 {
return self.reason[0..self.reason_len];
}
};
pub const Mailbox = struct {
alloc: std.mem.Allocator,
mu: std.Thread.Mutex = .{},
items: std.ArrayListUnmanaged(Say) = .empty,
wake_r: std.posix.fd_t,
wake_w: std.posix.fd_t,
pub fn init(alloc: std.mem.Allocator) !Mailbox {
const p = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
return .{ .alloc = alloc, .wake_r = p[0], .wake_w = p[1] };
}
pub fn deinit(self: *Mailbox) void {
for (self.items.items) |it| self.free(it);
self.items.deinit(self.alloc);
std.posix.close(self.wake_r);
std.posix.close(self.wake_w);
}
pub fn free(self: *Mailbox, s: Say) void {
switch (s) {
.input => |b| self.alloc.free(b),
else => {},
}
}
/// Copies `input` bytes; rings the doorbell. A full pipe is fine: one
/// byte in it is one wake, and the pump drains everything per wake.
pub fn post(self: *Mailbox, s: Say) !void {
const owned: Say = switch (s) {
.input => |b| .{ .input = try self.alloc.dupe(u8, b) },
else => s,
};
{
self.mu.lock();
defer self.mu.unlock();
try self.items.append(self.alloc, owned);
}
_ = std.posix.write(self.wake_w, "w") catch {};
}
/// Moves every pending item into `into`; the caller frees them.
pub fn drain(self: *Mailbox, into: *std.ArrayListUnmanaged(Say)) !void {
self.mu.lock();
defer self.mu.unlock();
try into.appendSlice(self.alloc, self.items.items);
self.items.clearRetainingCapacity();
}
fn drainBell(self: *Mailbox) void {
var buf: [64]u8 = undefined;
while (true) {
const n = std.posix.read(self.wake_r, &buf) catch return;
if (n < buf.len) return;
}
}
};
pub const Options = struct {
target: client.Target,
session: []const u8,
cols: u16,
rows: u16,
wake: *const fn (ctx: *anyopaque) void,
wake_ctx: *anyopaque,
};
pub const Pump = struct {
alloc: std.mem.Allocator,
opts: Options,
mailbox: Mailbox,
/// Guards `grid`, `replica` and `last_apply_us`.
mu: std.Thread.Mutex = .{},
grid: *Grid,
replica: Replica,
last_apply_us: u32 = 0,
core: core_mod.ClientCore = .{},
state_mu: std.Thread.Mutex = .{},
state_val: State = .{},
/// The size the daemon should hear on the next attach.
cols: u16,
rows: u16,
thread: ?std.Thread = null,
/// Pump-thread only: whether `run`'s transport is open (see `reconnect`).
link_open: bool = false,
pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump {
const p = try alloc.create(Pump);
errdefer alloc.destroy(p);
const g = try Grid.init(alloc, @max(opts.cols, 1), @max(opts.rows, 1));
errdefer g.deinit();
p.* = .{
.alloc = alloc,
.opts = opts,
.mailbox = try Mailbox.init(alloc),
.grid = g,
.replica = Replica.init(alloc, g),
.cols = opts.cols,
.rows = opts.rows,
};
p.thread = try std.Thread.spawn(.{}, run, .{p});
return p;
}
pub fn say(self: *Pump, s: Say) !void {
try self.mailbox.post(s);
}
pub fn state(self: *Pump) State {
self.state_mu.lock();
defer self.state_mu.unlock();
return self.state_val;
}
pub fn stop(self: *Pump) void {
self.mailbox.post(.quit) catch {};
if (self.thread) |t| t.join();
self.mailbox.deinit();
self.grid.deinit();
self.alloc.destroy(self);
}
fn setPhase(self: *Pump, ph: Phase, reason: []const u8) void {
self.state_mu.lock();
defer self.state_mu.unlock();
self.state_val.phase = ph;
const n = @min(reason.len, self.state_val.reason.len);
@memcpy(self.state_val.reason[0..n], reason[0..n]);
self.state_val.reason_len = @intCast(n);
}
fn setExit(self: *Pump, code: u8) void {
self.state_mu.lock();
defer self.state_mu.unlock();
self.state_val.phase = .exited;
self.state_val.exit_code = code;
}
fn ringBell(self: *Pump) void {
self.state_mu.lock();
defer self.state_mu.unlock();
self.state_val.bell = true;
}
fn wake(self: *Pump) void {
self.opts.wake(self.opts.wake_ctx);
}
fn dial(self: *Pump) ?client.Transport {
var backoff: u64 = 0;
while (true) {
if (client.Transport.open(self.alloc, self.opts.target, null, -1, null)) |tr| {
return tr;
} else |err| {
var buf: [client.open_err_len]u8 = undefined;
const f = client.openFailure(&buf, self.opts.target, err, "");
self.setPhase(.reconnecting, f.message);
self.wake();
}
if (self.quitRequested()) return null;
backoff = client.nextBackoffMs(backoff);
std.Thread.sleep(backoff * std.time.ns_per_ms);
if (self.quitRequested()) return null;
}
}
/// True when a quit is waiting; leaves everything else in the mailbox.
fn quitRequested(self: *Pump) bool {
self.mailbox.mu.lock();
defer self.mailbox.mu.unlock();
for (self.mailbox.items.items) |it| if (it == .quit) return true;
return false;
}
fn sendAttach(self: *Pump, tr: *client.Transport, fresh: bool) !void {
const args = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.replica.attachArgs();
var buf: [proto.attach_max_len]u8 = undefined;
try tr.writeFrame(.attach, proto.encodeAttachNamed(
&buf,
self.cols,
self.rows,
args.have_seq,
args.have_epoch,
proto.wireName(self.opts.session),
));
tr.flushQuic();
}
/// Every pending mailbox item onto the wire. Returns false on quit or
/// detach: the loop ends either way.
fn deliver(self: *Pump, tr: *client.Transport) !bool {
self.mailbox.drainBell();
var items: std.ArrayListUnmanaged(Say) = .empty;
defer {
for (items.items) |it| self.mailbox.free(it);
items.deinit(self.alloc);
}
try self.mailbox.drain(&items);
for (items.items) |it| {
switch (it) {
.input => |b| tr.writeFrame(.input, b) catch return true, // the read side reports the loss
.resize => |r| {
self.cols = r.cols;
self.rows = r.rows;
tr.writeFrame(.resize, &proto.encodeSize(r.cols, r.rows)) catch return true;
},
.detach => {
tr.writeFrame(.detach, "") catch {};
tr.flushQuic();
return false;
},
.quit => return false,
}
}
tr.flushQuic();
return true;
}
/// One frame. Returns true when something the window paints changed.
fn onFrame(self: *Pump, tr: *client.Transport, frame: proto.Frame) !bool {
switch (frame.type) {
.snapshot, .delta => {
var t = try std.time.Timer.start();
self.mu.lock();
defer self.mu.unlock();
// The two snapshot failures are DIFFERENT, and telling them
// apart is the whole point (see `Replica.apply`'s doc comment
// and `interact.Core.frame`, which is the wall's copy of this
// rule). `error.BadPayload` left the grid untouched, so the
// frame is skipped and we paint what we already held.
// `error.SnapshotAborted` did not: the grid is blank while
// `last_seq` claims to be current, so no later delta can fix
// it and a resync's snapshot is what already failed. That one
// ENDS the session rather than paint a replica holding
// nothing.
const applied = self.replica.apply(frame.type, frame.payload) catch |err| switch (err) {
error.BadPayload => {
self.last_apply_us = @intCast(@min(t.read() / std.time.ns_per_us, std.math.maxInt(u32)));
return false;
},
error.SnapshotAborted => {
self.setPhase(.failed, "the daemon's snapshot could not be decoded");
return error.SessionOver;
},
else => .resync,
};
self.last_apply_us = @intCast(@min(t.read() / std.time.ns_per_us, std.math.maxInt(u32)));
switch (applied) {
.painted => {
if (self.state().phase != .attached) self.setPhase(.attached, "");
return true;
},
.resync => {
// The replica is suspect, not the transport: re-attach
// at (0,0), since a quoted seq invites an unfixable
// delta. The same rule as wall_pump's.
self.replica.state_since_attach = false;
try self.sendAttach(tr, true);
return false;
},
}
},
.exit_status => {
// Before any replay frame this is the refusal path; after,
// the session really ended.
if (self.replica.state_since_attach) {
self.setExit(if (frame.payload.len >= 1) frame.payload[0] else 1);
} else {
self.setPhase(.refused, "the daemon refused the attach");
}
return error.SessionOver;
},
.taken_over => {
// Unsent by this daemon (wire-compat): treated as an end, as
// the wall does.
self.setPhase(.taken, "");
return error.SessionOver;
},
else => {
switch (self.core.receive(frame.type, frame.payload)) {
.effect => |e| switch (e) {
.bell => {
self.ringBell();
return true;
},
.clipboard_set => return false, // v1 has no clipboard
},
else => return false,
}
},
}
}
fn run(self: *Pump) void {
var intentional_stop = false;
// No silent thread exits: the window observes state, not the thread.
defer {
const phase = self.state().phase;
const terminal = switch (phase) {
.exited, .refused, .taken, .failed => true,
else => false,
};
if (!intentional_stop and !self.quitRequested() and !terminal)
self.setPhase(.failed, "the session pump stopped unexpectedly");
self.wake();
}
var tr = self.dial() orelse return;
self.link_open = true;
// `reconnect` closes and reopens; a redial that never came back
// leaves `link_open` false so this does not close twice.
defer if (self.link_open) tr.close();
tr.adopt(self.alloc);
self.sendAttach(&tr, true) catch {
self.setPhase(.failed, "attach could not be sent");
self.wake();
return;
};
var buffered_quic = false;
while (true) {
var fds = [2]std.posix.pollfd{
.{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = self.mailbox.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
};
_ = std.posix.poll(&fds, if (buffered_quic) 0 else tr.timeoutMs(100)) catch {
self.setPhase(.failed, "transport poll failed");
return;
};
buffered_quic = false;
tr.service();
if (fds[1].revents != 0) {
const keep_running = self.deliver(&tr) catch {
self.setPhase(.failed, "mailbox could not be drained");
return;
};
if (!keep_running) {
intentional_stop = true;
return;
}
}
var changed = false;
if (fds[0].revents != 0 or tr.link == .quic) frames: {
// Socket/pipe reads block: consume ONE frame per poll.
// QUIC reads a userspace buffer, but must also yield fairly.
const budget: usize = if (tr.link == .quic) 64 else 1;
var consumed: usize = 0;
while (consumed < budget) : (consumed += 1) {
const incoming = tr.readFrame(self.alloc) catch {
if (!self.reconnect(&tr)) return;
break :frames;
};
switch (incoming) {
.incomplete => break :frames,
.closed => {
if (!self.reconnect(&tr)) return;
break :frames;
},
.frame => |frame| {
defer frame.deinit(self.alloc);
const c = self.onFrame(&tr, frame) catch |err| switch (err) {
error.SessionOver => {
self.wake();
return;
},
else => {
if (!self.reconnect(&tr)) return;
break :frames;
},
};
changed = changed or c;
},
}
}
// A full QUIC batch may leave buffered frames. Poll without
// sleeping next pass, still servicing the mailbox first.
buffered_quic = tr.link == .quic;
}
if (changed) self.wake();
}
}
/// The link went away: say so, redial with backoff, re-attach with what
/// the replica already holds. False when a quit arrived meanwhile.
fn reconnect(self: *Pump, tr: *client.Transport) bool {
tr.close();
self.link_open = false;
self.setPhase(.reconnecting, "");
self.wake();
tr.* = self.dial() orelse return false;
self.link_open = true;
tr.adopt(self.alloc);
self.mu.lock();
self.replica.state_since_attach = false;
self.mu.unlock();
self.sendAttach(tr, false) catch {
self.setPhase(.failed, "reconnect attach could not be sent");
return false;
};
return true;
}
};
```
If `client.openFailure` returns a struct whose text field is not named `message`, read the `OpenFailure` declaration in `src/client/client.zig` and use its field. If `Transport.close` followed by re-assignment is not how the wall reconnects, mirror the `redial` function in `src/tui/wall_pump.zig`.
- [ ] **Step 4: Wire the re-export and run the tests**
In `src/client/client.zig`, next to `pub const hosts = @import("hosts.zig");`, add `pub const session_pump = @import("session_pump.zig");`, and in the `test { std.testing.refAllDeclsRecursive(@This()); ... }` block add `_ = @import("session_pump.zig");` beside `_ = @import("hosts.zig");`.
Run: `make test 2>&1 | tail -5; echo rc=$?` (note: `make test | tail` reports tail's code — use `make test > /tmp/claude-1000/-home-xanderle-code-rad-mux/d23565e2-afbd-42d3-8c39-9d1b211180b5/scratchpad/t.log 2>&1; echo rc=$?; tail -5 the log`).
Expected: rc 0.
- [ ] **Step 5: Commit**
```sh
make check && git add src/client/session_pump.zig src/client/client.zig
git commit -m "feat: a terminal-free session pump under src/client for the native viewer"
```
---
### Task 8: `frame.zig` and the real `muxg.zig`
**Files:**
- Create: `src/gui/frame.zig`
- Modify: `src/gui/native.zig` (add `pub const frame`, `run`, `_ = frame;`), `src/cli/muxg.zig` (replace the probe)
**Interfaces:**
- Consumes: everything above; `client.session_pump.Pump`, `client.keymap`, `client.hosts.parse`, `client.Target.fromSpec`, `sockpath.defaultOrExplain`, `sockpath.answers`, `cliflags.parseStrict/exitFor/isHelp/isVersion/help`.
- Produces:
- `frame.Options = struct { target: client.Target, session: []const u8, font_px: u16 = 16, width: u32 = 960, height: u32 = 600, test_fifo: ?[]const u8 = null }`
- `frame.run(alloc, opts) !u8`
- `frame.keyEvent(sdl_key: u32, sdl_mod: u16) ?keymap.Event` — the pure key table.
- Test hook: when `test_fifo` is set (`MUXG_TEST_FIFO` in the entry), a thread reads lines from that FIFO and pushes SDL events: `text:<utf8>`, `key:<name>` (enter, tab, escape, backspace, up, down, left, right), `resize:<W>x<H>`, `quit`.
- `SIGUSR1` prints the frame table to stderr; so does exit.
- [ ] **Step 1: Write the failing key-table tests**
Create `src/gui/frame.zig` with the header, imports and, at the bottom:
```zig
test "named keys map, printable keys do not (they arrive as text)" {
try std.testing.expectEqual(keymap.Key.up, keyEvent(c.SDLK_UP, 0).?.key);
try std.testing.expectEqual(keymap.Key.enter, keyEvent(c.SDLK_RETURN, 0).?.key);
try std.testing.expectEqual(keymap.Key.f5, keyEvent(c.SDLK_F5, 0).?.key);
try std.testing.expect(keyEvent(c.SDLK_A, 0) == null);
try std.testing.expect(keyEvent(c.SDLK_SPACE, 0) == null);
}
test "ctrl and alt with a letter become a char event with mods" {
const ev = keyEvent(c.SDLK_C, c.SDL_KMOD_LCTRL).?;
try std.testing.expectEqual(keymap.Key.char, ev.key);
try std.testing.expectEqual(@as(u21, 'c'), ev.cp);
try std.testing.expect(ev.mods.ctrl and !ev.mods.alt);
const alt = keyEvent(c.SDLK_X, c.SDL_KMOD_RALT).?;
try std.testing.expect(alt.mods.alt);
// Shift alone on a letter is text, not a key event.
try std.testing.expect(keyEvent(c.SDLK_X, c.SDL_KMOD_LSHIFT) == null);
}
test "the window's drawable size floors to whole cells" {
try std.testing.expectEqual(CellsOf{ .cols = 120, .rows = 37 }, cellsOf(963, 601, 8, 16));
try std.testing.expectEqual(CellsOf{ .cols = 1, .rows = 1 }, cellsOf(3, 5, 8, 16));
}
test "a test-hook line parses" {
try std.testing.expectEqualStrings("hi there", parseHook("text:hi there").?.text);
try std.testing.expectEqual(keymap.Key.enter, parseHook("key:enter").?.key);
const r = parseHook("resize:640x480").?;
try std.testing.expectEqual(@as(u32, 640), r.resize.w);
try std.testing.expect(parseHook("quit").? == .quit);
try std.testing.expect(parseHook("bogus") == null);
}
```
- [ ] **Step 2: Run to verify they fail**
Add to `native.zig`: `pub const frame = @import("frame.zig");`, the `run` function from Task 1's listing, and `_ = frame;`. Run: `deps/zig/zig build native-test 2>&1 | tail -5`
Expected: compile error, `keyEvent` not found.
- [ ] **Step 3: Implement `frame.zig`**
Above the tests:
```zig
//! The window thread: SDL owns the window and the GL context; this file
//! owns the loop. It waits on SDL's event queue; a wake from the pump or a
//! resize locks the replica, rebuilds every instance from the whole grid,
//! unlocks, uploads, draws and swaps. Keys become keymap events, text
//! becomes input bytes, and both go to the pump's mailbox. The ONE file
//! under src/gui/ that names SDL (folder rule 9).
//!
//! Whole-grid rebuild every frame is deliberate for v1: a large window is
//! on the order of ten thousand cells, and the timing table is what will
//! say whether dirty rows ever matter.
const std = @import("std");
const client = @import("client");
const term = @import("term");
const keymap = client.keymap;
const session_pump = client.session_pump;
const font = @import("font.zig");
const atlas = @import("atlas.zig");
const quads = @import("quads.zig");
const gl = @import("gl.zig");
const bench = @import("bench.zig");
const c = @cImport({
@cInclude("SDL3/SDL.h");
});
pub const Options = struct {
target: client.Target,
session: []const u8,
font_px: u16 = 16,
width: u32 = 960,
height: u32 = 600,
/// The e2e leg's hook: a FIFO of `text:`/`key:`/`resize:`/`quit` lines.
test_fifo: ?[]const u8 = null,
};
pub const CellsOf = struct { cols: u16, rows: u16 };
pub fn cellsOf(px_w: u32, px_h: u32, cell_w: u16, cell_h: u16) CellsOf {
return .{
.cols = @intCast(@max(px_w / cell_w, 1)),
.rows = @intCast(@max(px_h / cell_h, 1)),
};
}
/// SDL keycode + mods → the keymap's event, or null for a key that types
/// (text input carries it) or means nothing to a session.
pub fn keyEvent(key: u32, mod: u16) ?keymap.Event {
const ctrl = mod & c.SDL_KMOD_CTRL != 0;
const alt = mod & c.SDL_KMOD_ALT != 0;
const shift = mod & c.SDL_KMOD_SHIFT != 0;
const mods: keymap.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift };
const named: ?keymap.Key = switch (key) {
c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter,
c.SDLK_TAB => .tab,
c.SDLK_BACKSPACE => .backspace,
c.SDLK_ESCAPE => .escape,
c.SDLK_UP => .up,
c.SDLK_DOWN => .down,
c.SDLK_LEFT => .left,
c.SDLK_RIGHT => .right,
c.SDLK_HOME => .home,
c.SDLK_END => .end,
c.SDLK_INSERT => .insert,
c.SDLK_DELETE => .delete,
c.SDLK_PAGEUP => .page_up,
c.SDLK_PAGEDOWN => .page_down,
c.SDLK_F1 => .f1,
c.SDLK_F2 => .f2,
c.SDLK_F3 => .f3,
c.SDLK_F4 => .f4,
c.SDLK_F5 => .f5,
c.SDLK_F6 => .f6,
c.SDLK_F7 => .f7,
c.SDLK_F8 => .f8,
c.SDLK_F9 => .f9,
c.SDLK_F10 => .f10,
c.SDLK_F11 => .f11,
c.SDLK_F12 => .f12,
else => null,
};
if (named) |k| return .{ .key = k, .mods = mods };
// A printable key with Ctrl or Alt held: SDL sends no text for it.
if ((ctrl or alt) and key >= 0x20 and key < 0x7f) {
return .{ .key = .char, .cp = @intCast(key), .mods = mods };
}
return null;
}
pub const Hook = union(enum) {
text: []const u8,
key: keymap.Key,
resize: struct { w: u32, h: u32 },
quit,
};
pub fn parseHook(line: []const u8) ?Hook {
if (std.mem.eql(u8, line, "quit")) return .quit;
if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] };
if (std.mem.startsWith(u8, line, "key:")) {
const name = line["key:".len..];
inline for (.{ "enter", "tab", "escape", "backspace", "up", "down", "left", "right" }) |n| {
if (std.mem.eql(u8, name, n)) return .{ .key = @field(keymap.Key, n) };
}
return null;
}
if (std.mem.startsWith(u8, line, "resize:")) {
const rest = line["resize:".len..];
const x = std.mem.indexOfScalar(u8, rest, 'x') orelse return null;
const w = std.fmt.parseInt(u32, rest[0..x], 10) catch return null;
const h = std.fmt.parseInt(u32, rest[x + 1 ..], 10) catch return null;
return .{ .resize = .{ .w = w, .h = h } };
}
return null;
}
// --- the loop ---
var usr1_seen = std.atomic.Value(bool).init(false);
fn onUsr1(_: c_int) callconv(.c) void {
usr1_seen.store(true, .release);
}
const Wake = struct {
var event_type: u32 = 0;
fn ring(_: *anyopaque) void {
var ev: c.SDL_Event = undefined;
ev.type = event_type;
_ = c.SDL_PushEvent(&ev);
}
};
/// A hook line becomes the same SDL event a person would have caused, so
/// the leg exercises the real key and text paths. A resize is pushed as a
/// window event of the given size; on a driver with no real window that is
/// the only way one arrives.
const HookThread = struct {
path: []const u8,
window_id: u32,
fn run(self: HookThread) void {
const f = std.fs.cwd().openFile(self.path, .{}) catch return;
defer f.close();
var buf: [4096]u8 = undefined;
var reader = f.reader(&buf);
while (reader.interface.takeDelimiterExclusive('\n')) |line| {
const hook = parseHook(line) orelse continue;
var ev: c.SDL_Event = undefined;
@memset(std.mem.asBytes(&ev), 0);
switch (hook) {
.text => |t| {
// SDL_EVENT_TEXT_INPUT carries a pointer; SDL owns it
// only for events it made, so hand it a stable copy.
const z = std.heap.page_allocator.dupeZ(u8, t) catch continue;
ev.text.type = c.SDL_EVENT_TEXT_INPUT;
ev.text.windowID = self.window_id;
ev.text.text = z.ptr;
},
.key => |k| {
ev.key.type = c.SDL_EVENT_KEY_DOWN;
ev.key.windowID = self.window_id;
ev.key.key = switch (k) {
.enter => c.SDLK_RETURN,
.tab => c.SDLK_TAB,
.escape => c.SDLK_ESCAPE,
.backspace => c.SDLK_BACKSPACE,
.up => c.SDLK_UP,
.down => c.SDLK_DOWN,
.left => c.SDLK_LEFT,
.right => c.SDLK_RIGHT,
else => continue,
};
},
.resize => |r| {
ev.window.type = c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED;
ev.window.windowID = self.window_id;
ev.window.data1 = @intCast(r.w);
ev.window.data2 = @intCast(r.h);
},
.quit => {
ev.type = c.SDL_EVENT_QUIT;
},
}
_ = c.SDL_PushEvent(&ev);
} else |_| {}
}
};
fn sdlFail(what: []const u8) u8 {
std.debug.print("muxg: {s}: {s}\n", .{ what, std.mem.span(c.SDL_GetError()) });
return 2;
}
const GlyphCache = struct {
alloc: std.mem.Allocator,
face: *font.Face,
atlas: *atlas.Atlas,
fn get(ctx: *anyopaque, cp: u21) ?atlas.Entry {
const self: *GlyphCache = @ptrCast(@alignCast(ctx));
if (self.atlas.get(cp)) |e| return e;
var g = self.face.render(self.alloc, cp) catch return null;
defer g.deinit(self.alloc);
return self.atlas.put(self.alloc, cp, g.w, g.h, g.left, g.top, g.pixels) catch null;
}
};
fn setTitle(win: *c.SDL_Window, session: []const u8, suffix: []const u8) void {
var buf: [128]u8 = undefined;
const t = std.fmt.bufPrintZ(&buf, "muxg {s}{s}", .{ session, suffix }) catch "muxg";
_ = c.SDL_SetWindowTitle(win, t.ptr);
}
pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
var ring: bench.Ring = .{};
defer {
var buf: [2048]u8 = undefined;
std.debug.print("{s}", .{ring.report(&buf)});
}
var sa: std.posix.Sigaction = .{
.handler = .{ .handler = onUsr1 },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.USR1, &sa, null);
var face = font.Face.open(opts.font_px) catch |err| {
std.debug.print("muxg: font: {s}\n", .{@errorName(err)});
return 2;
};
defer face.deinit();
if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init");
defer c.SDL_Quit();
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
const win = c.SDL_CreateWindow(
"muxg",
@intCast(opts.width),
@intCast(opts.height),
c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE,
) orelse return sdlFail("SDL_CreateWindow");
defer c.SDL_DestroyWindow(win);
const ctx = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext");
defer _ = c.SDL_GL_DestroyContext(ctx);
_ = c.SDL_GL_SetSwapInterval(1);
var renderer = gl.Renderer.init(@ptrCast(&c.SDL_GL_GetProcAddress)) catch |err| {
std.debug.print("muxg: gl: {s}\n", .{@errorName(err)});
return 2;
};
defer renderer.deinit();
_ = c.SDL_StartTextInput(win);
var glyph_atlas = try atlas.Atlas.init(alloc, 1024, 256);
defer glyph_atlas.deinit(alloc);
var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .atlas = &glyph_atlas };
var fb_w: c_int = 0;
var fb_h: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h);
var cells = cellsOf(@intCast(fb_w), @intCast(fb_h), face.cell_w, face.cell_h);
Wake.event_type = c.SDL_RegisterEvents(1);
const pump = try session_pump.Pump.start(alloc, .{
.target = opts.target,
.session = opts.session,
.cols = cells.cols,
.rows = cells.rows,
.wake = Wake.ring,
.wake_ctx = @ptrCast(@constCast(&Wake.event_type)),
});
defer pump.stop();
setTitle(win, opts.session, "");
if (opts.test_fifo) |path| {
const th = try std.Thread.spawn(.{}, HookThread.run, .{HookThread{ .path = path, .window_id = c.SDL_GetWindowID(win) }});
th.detach();
}
var instances: std.ArrayListUnmanaged(quads.Instance) = .empty;
defer instances.deinit(alloc);
var seq_buf: [keymap.max_seq_len]u8 = undefined;
var dirty = true;
var last_phase: session_pump.Phase = .dialing;
while (true) {
var ev: c.SDL_Event = undefined;
// 250 ms so a SIGUSR1 with no event traffic still prints.
if (c.SDL_WaitEventTimeout(&ev, 250)) {
switch (ev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => {
pump.say(.detach) catch {};
return 0;
},
c.SDL_EVENT_TEXT_INPUT => {
const text = std.mem.span(ev.text.text);
pump.say(.{ .input = text }) catch {};
},
c.SDL_EVENT_KEY_DOWN => {
if (keyEvent(ev.key.key, ev.key.mod)) |kev| {
const bytes = keymap.encode(kev, &seq_buf);
if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {};
}
},
c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => {
fb_w = ev.window.data1;
fb_h = ev.window.data2;
const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h);
if (now.cols != cells.cols or now.rows != cells.rows) {
cells = now;
pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {};
}
dirty = true;
},
c.SDL_EVENT_WINDOW_EXPOSED => dirty = true,
else => if (ev.type == Wake.event_type) {
dirty = true;
},
}
// Coalesce: everything else already queued is handled before a paint.
while (c.SDL_PollEvent(&ev)) {
switch (ev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => {
pump.say(.detach) catch {};
return 0;
},
c.SDL_EVENT_TEXT_INPUT => pump.say(.{ .input = std.mem.span(ev.text.text) }) catch {},
c.SDL_EVENT_KEY_DOWN => if (keyEvent(ev.key.key, ev.key.mod)) |kev| {
const bytes = keymap.encode(kev, &seq_buf);
if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {};
},
c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => {
fb_w = ev.window.data1;
fb_h = ev.window.data2;
const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h);
if (now.cols != cells.cols or now.rows != cells.rows) {
cells = now;
pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {};
}
dirty = true;
},
else => dirty = true,
}
}
}
if (usr1_seen.swap(false, .acq_rel)) {
var buf: [2048]u8 = undefined;
std.debug.print("{s}", .{ring.report(&buf)});
}
const st = pump.state();
if (st.phase != last_phase) {
last_phase = st.phase;
switch (st.phase) {
.attached => setTitle(win, opts.session, ""),
.reconnecting => setTitle(win, opts.session, " [reconnecting]"),
.exited => return st.exit_code,
.refused => {
std.debug.print("muxg: {s}\n", .{st.reasonText()});
return 1;
},
.taken => {
std.debug.print("muxg: the session was taken by another client\n", .{});
return 0;
},
.failed => {
std.debug.print("muxg: {s}\n", .{st.reasonText()});
return 1;
},
.dialing => {},
}
}
if (st.bell) setTitle(win, opts.session, " [bell]");
if (!dirty) continue;
dirty = false;
var timer = try std.time.Timer.start();
var frame_t: bench.Frame = .{};
instances.clearRetainingCapacity();
{
pump.mu.lock();
defer pump.mu.unlock();
frame_t.apply_us = pump.last_apply_us;
const g = pump.grid;
const qctx: quads.Ctx = .{
.cell_w = face.cell_w,
.cell_h = face.cell_h,
.ascent = face.ascent,
.atlas_w = @floatFromInt(glyph_atlas.width),
.atlas_h = @floatFromInt(glyph_atlas.height),
.glyphs = .{ .ctx = @ptrCast(&cache), .get = GlyphCache.get },
};
var y: u16 = 0;
while (y < g.rows) : (y += 1) {
try quads.rowInstances(&instances, alloc, g.row(y), g.cols, 0, y, qctx);
}
try instances.append(alloc, quads.cursorInstance(g.cursor.x, g.cursor.y, qctx));
}
frame_t.rebuild_us = bench.usSince(&timer);
// The atlas may have grown during the rebuild: the uv values above
// were computed against the size at rebuild time, so re-run the
// rebuild once when it did. Rare (first sight of a glyph) and cheap.
if (glyph_atlas.dirty) {
renderer.uploadAtlas(&glyph_atlas);
frame_t.atlas_us = bench.usSince(&timer);
}
renderer.uploadInstances(instances.items);
frame_t.upload_us = bench.usSince(&timer);
renderer.draw(instances.items.len, fb_w, fb_h, 0x101010ff);
_ = c.SDL_GL_SwapWindow(win);
frame_t.draw_us = bench.usSince(&timer);
ring.record(frame_t);
}
}
```
Before integrating this listing, apply Task 5b's complete-text/style callback and cache contracts. Resolve all glyph runs and atlas insertions first, then build quads against the final atlas dimensions and upload/draw. Never present stale normalized UVs and rely on a later loop pass to repair them. The event wait also uses Task 5b's blink deadline when blinking cells are visible.
- [ ] **Step 4: Replace the probe with the real entry**
`src/cli/muxg.zig`:
```zig
//! `muxg`: the native client's entry. Parses ONE target the way `mux`
//! does (HOST, --sock PATH, --via CMD, quic://HOST[:PORT]) plus --session
//! and --font-px, resolves it to a client.Target, and hands it to the
//! painter. A session viewer: no wall, no hosts file, no layout.
//!
//! No daemon is started here. The self-exec rule (CLAUDE.md) says an
//! auto-start may only run the image already running, and this image is
//! not the daemon's. A silent socket is a refusal with the command to run.
const std = @import("std");
const native = @import("native");
const client = @import("client");
const term = @import("term");
const cliflags = @import("cliflags");
const sockpath = @import("sockpath");
const proto = term.protocol;
const hosts = client.hosts;
const usage =
\\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
\\
\\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none means the local daemon
\\ --session the session name (default: the daemon's default session)
\\ --sock a local daemon's socket path
\\ --via a command whose stdio is the daemon
\\ --key the QUIC key file (or MUX_KEY_FILE)
\\ --font-px the face's pixel size (default 16)
\\ --help --version
\\
;
const Arguments = struct {
sock: ?[]const u8 = null,
via: ?[]const u8 = null,
key: ?[]const u8 = null,
session: ?proto.SessionName = null,
font_px: u16 = 16,
_target: ?[]const u8 = null,
_targets: u8 = 0,
pub fn positional(self: *Arguments, word: []const u8) bool {
self._target = word;
self._targets += 1;
return true;
}
};
comptime {
cliflags.assertDocumented(Arguments, usage, &.{});
}
pub fn main() !u8 {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak) std.debug.print("muxg: LEAK: allocations outlived deinit\n", .{});
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
var o: Arguments = .{};
cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", "0.0.1");
const named: u8 = @as(u8, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets;
if (named > 1) {
std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage});
return 2;
}
const session = if (o.session) |n| n.name else "";
const key = std.posix.getenv("MUX_KEY_FILE");
var target: client.Target = undefined;
if (o.via) |cmd| {
target = .{ .via = cmd };
} else if (o._target) |word| {
const spec = hosts.parse(word) catch |err| {
std.debug.print("muxg: bad target {s}: {s}\n", .{ word, @errorName(err) });
return 2;
};
target = client.Target.fromSpec(alloc, spec, o.key orelse key, client.quic_idle_ms_default, true) catch |err| switch (err) {
error.MissingKey => {
std.debug.print("muxg: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n", .{});
return 2;
},
else => |e| return e,
};
if (target == .hand) target.hand.narrate = true;
} else {
const path = if (o.sock) |s| try alloc.dupe(u8, s) else (try sockpath.defaultOrExplain(alloc, "muxg") orelse return 1);
defer alloc.free(path);
if (!sockpath.answers(path)) {
std.debug.print("muxg: no daemon at {s} (run: mux d start -d --sock {s})\n", .{ path, path });
return 2;
}
return native.run(alloc, .{
.target = .{ .sock = path },
.session = session,
.font_px = o.font_px,
.test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
});
}
return native.run(alloc, .{
.target = target,
.session = session,
.font_px = o.font_px,
.test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
});
}
```
If `cliflags.parseStrict` cannot parse a `u16` field, check how `ClientArguments.quic_idle_ms` uses `client.IdleMs` (a struct with a `ms` field and its own parse) and give `font_px` the same shape. If `Target.fromSpec` frees nothing on the `.sock` arm and leaks the dupe, mirror `mux_main`'s `defer` for that arm.
- [ ] **Step 5: Build, run the unit tests, and try it by hand**
Run:
```sh
deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?
make native; echo rc=$?
S=/tmp/claude-1000/-home-xanderle-code-rad-mux/d23565e2-afbd-42d3-8c39-9d1b211180b5/scratchpad
export XDG_STATE_HOME=$S/state XDG_RUNTIME_DIR=$S/run; mkdir -p $S/state $S/run
./zig-out/bin/mux d start -d --sock $S/run/g.sock; echo daemon rc=$?
./zig-out/bin/muxg --sock $S/run/g.sock; echo muxg rc=$?
./zig-out/bin/mux d stop --sock $S/run/g.sock
```
Expected: tests rc 0; a window with a shell prompt appears; typing `echo hi` Enter shows `hi`; resizing the window reflows the shell; closing the window prints the frame table on stderr and rc 0. Then `exit` in the shell on a second run ends the window with rc 0 and the shell's code.
- [ ] **Step 6: Commit**
```sh
make check && git add src/gui/frame.zig src/gui/native.zig src/cli/muxg.zig
git commit -m "feat: muxg, a window on one daemon session painted from the grid"
```
---
### Task 9: `test/native.sh` — the end-to-end leg
**Files:**
- Create: `test/native.sh` (mode 755)
**Interfaces:**
- Consumes: `test/e2e_lib.sh` (`start_daemon SOCK LOG LABEL`, `defer_kill`, `defer_rm`, `wait_grid SOCK NEEDLE LABEL`, `wait_pid_gone`, `ok`), `$MUX a status --sock`, `$MUX a run --sock --timeout`, the `MUXG_TEST_FIFO` hook, `SIGUSR1`.
- [ ] **Step 1: Write the leg**
`test/native.sh`:
```sh
#!/bin/sh
# The native client's end-to-end leg: a real daemon, a real muxg on SDL's
# offscreen driver, keys through the real event path, a bounded flood,
# and the frame table read while the flood ran. Opt-in (`make native-e2e`);
# not part of ci, because it needs SDL3 and a GL-capable offscreen driver.
#
# The one pin that matters is the waystty failure: frames were painted
# WHILE output flooded the session. A painter that is fast per frame and
# never asked to paint reports a fine p99 and zero frames; this leg reads
# the frame count twice during the flood and asserts it grew.
set -u
MUX="$1"
MUXG="$2"
[ -x "$MUX" ] && [ -x "$MUXG" ] || { echo "native FAIL: need mux and muxg (run: make native)"; exit 1; }
E2E_DIR=$(dirname "$0")
. "$E2E_DIR/e2e_lib.sh"
# A Debug muxg measures a Debug replica; the numbers would mean nothing.
# The budget below is stated for ReleaseSafe/ReleaseFast and the leg
# refuses to grade anything else. `zig build native -Doptimize=ReleaseSafe`.
case "${MUXG_OPT:-}" in
ReleaseSafe|ReleaseFast) ;;
*) echo "native FAIL: set MUXG_OPT=ReleaseSafe|ReleaseFast to match the build; a Debug number is not a number"; exit 1 ;;
esac
SOCK="${TMPDIR:-/tmp}/muxd-native-$$.sock"
LOG="${TMPDIR:-/tmp}/muxd-native-$$.log"
defer_rm "$LOG"
start_daemon "$SOCK" "$LOG" "native daemon"
FIFO="${TMPDIR:-/tmp}/muxg-hook-$$"
defer_rm "$FIFO"
mkfifo "$FIFO"
GLOG="${TMPDIR:-/tmp}/muxg-native-$$.log"
defer_rm "$GLOG"
SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \
"$MUXG" --sock "$SOCK" 2>"$GLOG" &
GPID=$!
defer_kill "$GPID"
# Hold the FIFO's write end open for the whole leg; each hook line is one echo.
exec 8>"$FIFO"
# 1. The window attached: the daemon counts a client.
# wait_until SECS LABEL PREDICATE — the predicate is eval'd per tick.
wait_until 10 "muxg attached" '[ "$(attaches_now "$SOCK")" -ge 1 ]'
ok "muxg attached to a real daemon on the offscreen driver"
# 2. Keys through the real event path reach the shell.
printf 'text:echo native-ok-%s\n' "$$" >&8
printf 'key:enter\n' >&8
wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid"
ok "text and Enter cross the keymap, the mailbox, the pump and the daemon"
# 3. A bounded producer with explicit readiness, progress and completion.
# Fixed /tmp template yields a shell-safe path for the session command.
FLOOD_DIR=$(mktemp -d /tmp/mux-native-flood.XXXXXXXX)
defer_rm "$FLOOD_DIR"
reports_now() { grep -c '^=== muxg frame timing (' "$GLOG" || true; }
frames_now() {
frame_reports_before=$(reports_now)
kill -USR1 "$GPID" || return 1
# Wait for a NEW, COMPLETE report, rather than rereading an old table.
# The total row is printed last, so its count acknowledges the report.
wait_until 5 "fresh frame report" '[ "$(grep -c "^total " "$GLOG" || true)" -gt "$frame_reports_before" ]' >&2
grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'
}
# At most 4096 x 256 KiB of source bytes; normal completion is requested
# after the two samples. The producer writes progress only after output.
"$MUX" a run --sock "$SOCK" --timeout 60000 "i=0; : > '$FLOOD_DIR/ready'; while [ \"\$i\" -lt 4096 ] && [ ! -e '$FLOOD_DIR/stop' ]; do head -c 262144 /dev/urandom | base64; i=\$((i + 1)); printf '%s\\n' \"\$i\" > '$FLOOD_DIR/progress.tmp'; mv '$FLOOD_DIR/progress.tmp' '$FLOOD_DIR/progress'; done; : > '$FLOOD_DIR/done'; echo flood-done" >/dev/null 2>&1 &
RUNPID=$!
defer_kill "$RUNPID"
wait_until 10 "flood producer ready" '[ -s "$FLOOD_DIR/progress" ]'
[ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished before sampling"; exit 1; }
before=$(frames_now)
progress_before=$(cat "$FLOOD_DIR/progress")
wait_until 5 "producer output advanced" '[ -s "$FLOOD_DIR/progress" ] && [ "$(cat "$FLOOD_DIR/progress")" != "$progress_before" ]'
mid=$(frames_now)
[ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished during sampling"; exit 1; }
[ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during active output (before=$before mid=$mid)"; exit 1; }
: > "$FLOOD_DIR/stop"
wait "$RUNPID" || { echo "native FAIL: producer command failed"; exit 1; }
wait_grid "$SOCK" "flood-done" "the flood ended"
after=$(frames_now)
ok "frames were painted while the session flooded: before=$before mid=$mid after=$after"
# 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only).
# Budget: 20 ms per stage on the total row, generous for a 960x600
# window of ~4,500 cells; the row to read is `total`.
p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}')
[ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; }
ok "window-side total p99 ${p99} us under 20000 us"
# 5. A resize through the hook is followed by the daemon: cols shrink from
# whatever the 960 px window gave (the face decides the number) to
# fewer at 640 px. Read before, then wait for after to differ.
cols_of() { "$MUX" a status --sock "$SOCK" --timeout 2000 | sed 's/.*"cols":\([0-9]*\).*/\1/'; }
cols_before=$(cols_of)
printf 'resize:640x400\n' >&8
wait_until 10 "the daemon's cols changed after the resize" '[ "$(cols_of)" != "$cols_before" ]'
cols_after=$(cols_of)
[ "$cols_after" -lt "$cols_before" ] || { echo "native FAIL: cols $cols_before -> $cols_after did not shrink with the 640 px window"; exit 1; }
ok "the daemon followed the window's resize (cols $cols_before -> $cols_after)"
# 6. Close the window: muxg exits and the session survives on the daemon.
# wait_pid_gone PID LABEL — it allows 2 s.
printf 'quit\n' >&8
wait_pid_gone "$GPID" "muxg exits on quit"
"$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; }
ok "closing the window detaches and leaves the session on its daemon"
echo "native OK ($OK_COUNT checkpoints)"
```
The helpers are spelled as `test/e2e_lib.sh` defines them (verified 2026-09-04): `wait_until SECS LABEL PREDICATE` at line 820, `attaches_now SOCK` at 864 (reads `mux d stats`), `wait_pid_gone PID LABEL` at 946 (2 s), `wait_grid SOCK NEEDLE LABEL [SESSION]` at 776, `start_daemon SOCK LOG LABEL` at 421. Re-grep the line numbers if the lib has moved; the argument shapes are the contract.
- [ ] **Step 2: Run it**
Run:
```sh
chmod +x test/native.sh
deps/zig/zig build native -Doptimize=ReleaseSafe 2>&1 | tail -3
MUXG_OPT=ReleaseSafe test/native.sh zig-out/bin/mux zig-out/bin/muxg; echo rc=$?
```
Expected: six `e2e OK:` lines, `native OK (6 checkpoints)`, rc 0. On the first run, expect to adjust: the `frames_now` grep pattern must match `bench.Ring.report`'s first line exactly (`=== muxg frame timing (N frames) ===`); the status JSON field order is `"cols":N,"rows":N`, which `printStatus` in `src/cli/muxa.zig` spells.
If step 1's `wait_until` fails and `$GLOG` says `SDL_GL_CreateContext`, the offscreen driver has no GL here: run with `MUXG_VIDEODRIVER=x11` under `xvfb-run -a` and record that in the spec's Testing section.
- [ ] **Step 3: Make `make native-e2e` pass `MUXG_OPT`**
In `Makefile`, change the recipe to:
```makefile
native-e2e: mac-sdk
$(ZIG) build native -Doptimize=ReleaseSafe
MUXG_OPT=ReleaseSafe $(ZIG) build native-e2e -Doptimize=ReleaseSafe
```
Run: `make native-e2e; echo rc=$?` — expected rc 0.
- [ ] **Step 4: Commit**
```sh
make check && git add test/native.sh Makefile
git commit -m "test: the native client's end-to-end leg pins painting under a flood"
```
---
### Task 10: Docs
**Files:**
- Modify: `README.md` (a new section after the browser hub's), `CLAUDE.md` (the layout table, the toolchain block, the folder-rule paragraph)
- [ ] **Step 1: README**
After the `mux web` section, add:
```markdown
## muxg: a native window on one session
`muxg [TARGET] [--session NAME]` opens a window on one daemon session and
paints it with OpenGL from the cells the daemon sends. It is a session
viewer: no panes, no picker, no hosts file. Type into it; resize it and
the session follows; close it and the session stays on its daemon. A
wall inside a window is `mux` run inside a `muxg` session.
It is opt-in: `make native` builds it against the system's SDL3, freetype
fontconfig and HarfBuzz, and `make native-e2e` runs its leg. `muxg` never starts a
daemon; a silent local socket is refused with the `mux d start` line to
run. `kill -USR1` on a running `muxg` prints its per-stage frame-timing
table to stderr, as does exit.
```
- [ ] **Step 2: CLAUDE.md**
- Toolchain block: add `make native native-e2e # opt-in: the muxg viewer against system SDL3/freetype/fontconfig/HarfBuzz; never in ci`.
- Layout table: add the row `| \`src/gui/\` | \`native\`(\`native.zig\`) — \`font\` \`atlas\` \`quads\` \`gl\` \`frame\` \`bench\` · the session VIEWER's painter; imports \`client\` and \`term\` only; no wall policy (rule 8), SDL confined to \`frame.zig\` (rule 9) |` and, under the `src/client/` row's child list, `session_pump`.
- The folder-rule paragraph: after rule 7, add: `Rule 8 keeps wall policy out of \`src/gui/\` (no \`wall_host\`, \`wall_layout\`, \`wall_picker\`, \`layoutfile\`, \`SessionPoll\`); rule 9 confines the needle \`sdl\` to \`src/gui/frame.zig\`. Rules 5, 6 and 7 list \`src/gui\` too.`
- The `ONE binary` paragraph: add one sentence: `\`muxg\` is a second, opt-in binary — the native viewer — because it links system libraries the static \`mux\` cannot.`
- [ ] **Step 3: Commit**
```sh
make check && git add README.md CLAUDE.md
git commit -m "docs: muxg, the native session viewer, in README and CLAUDE.md"
```
---
## Self-review
**Spec coverage.** Section 1 (build root, target, two rules): Task 1. Section 2 (pump, mailbox, doorbell, state, frame arms, redial, the named debt): Task 7. Section 3 (font, atlas, quads, gl, frame loop, bench table): Tasks 2–6 and 8. Section 4 (input, no chord, close = detach, bell in title, clipboard ignored, mouse dropped): Task 8 — mouse events fall to the `else => dirty = true` arm and send nothing. Section 5 (dial failure words, exit code, refused, taken, reconnecting title, missing lib, face/GL failure, pump death): Task 8's phase switch and `muxg.zig`; the no-auto-start bullet is Task 1 step 8's spec amendment. Section 6 (unit tests: quads off-origin, flooring, key table both ways, atlas packer; the leg's six steps; Debug refusal; offscreen spike): Tasks 3, 4, 8, 9 and Task 1 step 7. Deferred items: none implemented, none needed.
**Type consistency.** `atlas.Entry {x,y,w,h,left,top}` remains the bitmap rectangle. Task 5b replaces the earlier codepoint-only callback and cache with complete-text shaped runs and face-variant/glyph-ID bitmap keys; update the Task 8 scaffold to those contracts before integration. `quads.Instance` field order (`x y w h u0 v0 u1 v1 rgba kind`) matches Task 6's attribute offsets; decorations use solid instances. `bench.Frame` fields match the `frame_t` assignments in Task 8. `session_pump.Say` is posted by Task 8 as `.detach`, `.{ .input = ... }`, `.{ .resize = ... }`. `Pump.state()` returns a `State` with `phase`, `exit_code`, `bell`, `reasonText()`. `frame.Options.test_fifo` is read from `MUXG_TEST_FIFO` in `muxg.zig`.
**Known verification points for the implementer** (named at the step, not placeholders): the `OpenFailure` text field's name (Task 7), `Transport` reassignment on redial (Task 7), freetype translated field names (Task 5), `cliflags` parsing a `u16` (Task 8), the lib helper signatures (Task 9), and the `{s:<16}` column widths (Task 2). Each says what to read and what to do.