b8b679eb
docs: the native client implementation plan
a73x 2026-09-04 18:48
Commit message
docs/superpowers/plans/2026-09-04-native-client.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,2794 @@ | |||
| 1 | # Native Client (`muxg`) Implementation Plan | ||
| 2 | |||
| 3 | > **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. | ||
| 4 | |||
| 5 | **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. | ||
| 6 | |||
| 7 | **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. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), SDL3 3.4, OpenGL 3.3 core loaded through `SDL_GL_GetProcAddress`, freetype2, fontconfig — all system libraries through pkg-config, dynamically linked, opt-in under `make native`. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-04-native-client-design.md` | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - **Prerequisite:** the cells-on-the-wire branch must be on main first. Every type below (`term.grid.Grid`, `Row`, `Cell`, `proto.CellStyle`, `proto.Wide`, `Replica.apply` returning `.painted | .resync`, `Replica.attachArgs`) comes from it. Do not start Task 4 or later on a tree without `src/engine/grid.zig`. | ||
| 16 | - 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. | ||
| 17 | - `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. | ||
| 18 | - `src/gui/` imports `client` and `term` and nothing else of ours. `src/cli/muxg.zig` (the entry) may also import `cliflags` and `sockpath`. | ||
| 19 | - 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). | ||
| 20 | - 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). | ||
| 21 | - 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. | ||
| 22 | - Every module must `@import` every table import it declares (`checkGrantsUsed` fails the build otherwise). | ||
| 23 | - Every hand-run rig exports an isolated `XDG_STATE_HOME`; the e2e leg sources `test/e2e_lib.sh`, which does that. | ||
| 24 | - Never quote a speed number measured in a Debug build. The e2e leg refuses to grade one. | ||
| 25 | - Commit subjects are `type: what changed` with no scope. Every commit ends with: | ||
| 26 | ``` | ||
| 27 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> | ||
| 28 | Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW | ||
| 29 | ``` | ||
| 30 | - `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. | ||
| 31 | - Never `pkill`/`pgrep`/`killall` anything named `mux` or `muxg`. Kill only a pid you captured with `$!`. | ||
| 32 | |||
| 33 | --- | ||
| 34 | |||
| 35 | ## File map | ||
| 36 | |||
| 37 | | File | Responsibility | | ||
| 38 | |---|---| | ||
| 39 | | `src/gui/native.zig` | Row root: the two folder rules in its header, re-exports, `run` | | ||
| 40 | | `src/gui/bench.zig` | Per-frame stage timing ring and the stats table | | ||
| 41 | | `src/gui/atlas.zig` | R8 glyph atlas: shelf packer, codepoint → entry map, growth | | ||
| 42 | | `src/gui/font.zig` | fontconfig match + freetype rasterise; cell metrics | | ||
| 43 | | `src/gui/quads.zig` | `Row` + column offset → instanced quads; colours | | ||
| 44 | | `src/gui/gl.zig` | GL 3.3 core objects loaded by name; one instanced draw | | ||
| 45 | | `src/gui/frame.zig` | SDL window, GL context, event loop, paint, test hook, SIGUSR1 | | ||
| 46 | | `src/cli/muxg.zig` | argv → `client.Target`, then `native.run` | | ||
| 47 | | `src/client/session_pump.zig` | The transport-owning thread, mailbox, doorbell, state | | ||
| 48 | | `build.zig` | rows `native` and `muxg`, steps `native` `native-test` `native-e2e`, rules 8 and 9, `src/gui` in rules 5–7 | | ||
| 49 | | `Makefile` | `native`, `native-e2e` | | ||
| 50 | | `test/native.sh` | The end-to-end leg | | ||
| 51 | | `README.md`, `CLAUDE.md` | The viewer's one section; the table row and rules | | ||
| 52 | |||
| 53 | --- | ||
| 54 | |||
| 55 | ### Task 1: Build wiring and the GL probe (the offscreen spike) | ||
| 56 | |||
| 57 | **Files:** | ||
| 58 | - Create: `src/gui/native.zig`, `src/cli/muxg.zig` | ||
| 59 | - 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) | ||
| 60 | |||
| 61 | **Interfaces:** | ||
| 62 | - 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`. | ||
| 63 | |||
| 64 | - [ ] **Step 1: Write the row root with the two rules in its header** | ||
| 65 | |||
| 66 | `src/gui/native.zig`: | ||
| 67 | |||
| 68 | ```zig | ||
| 69 | //! The native client's painter: a window on ONE daemon session, painted | ||
| 70 | //! with OpenGL from the grid cells-on-the-wire delivers. A SESSION VIEWER, | ||
| 71 | //! not a wall. | ||
| 72 | //! | ||
| 73 | //! Two folder rules, enforced by build.zig (rules 8 and 9): | ||
| 74 | //! | ||
| 75 | //! - No wall policy enters src/gui/. No tiles, no layout file, no picker, | ||
| 76 | //! no host grade, no session list. The wall's policy lives under src/tui/ | ||
| 77 | //! against a cell painter, and the hub's copy of its grade is already an | ||
| 78 | //! open issue; a third copy here would be the same mistake with pixels. | ||
| 79 | //! Native panes wait on lifting that policy into a painter-free module. | ||
| 80 | //! - The windowing library is confined to frame.zig and the entry file. | ||
| 81 | //! Every other file here is plain Zig over term.grid, freetype and GL | ||
| 82 | //! function pointers, so the painter can be tested with no window and | ||
| 83 | //! moved to another windowing layer without touching it. | ||
| 84 | //! | ||
| 85 | //! Imports `client` and `term` and nothing else of ours. | ||
| 86 | const std = @import("std"); | ||
| 87 | const client = @import("client"); | ||
| 88 | const term = @import("term"); | ||
| 89 | |||
| 90 | pub const frame = @import("frame.zig"); | ||
| 91 | |||
| 92 | /// The one entry: dial, attach, paint until the window closes or the | ||
| 93 | /// session ends. Returns the process exit code, which is the shell's when | ||
| 94 | /// the session exited, because a script can read it. | ||
| 95 | pub fn run(alloc: std.mem.Allocator, opts: frame.Options) !u8 { | ||
| 96 | return frame.run(alloc, opts); | ||
| 97 | } | ||
| 98 | |||
| 99 | test { | ||
| 100 | _ = client; | ||
| 101 | _ = term; | ||
| 102 | _ = frame; | ||
| 103 | } | ||
| 104 | ``` | ||
| 105 | |||
| 106 | - [ ] **Step 2: Write the probe entry** | ||
| 107 | |||
| 108 | `src/cli/muxg.zig` (this version is the spike; Task 8 replaces it): | ||
| 109 | |||
| 110 | ```zig | ||
| 111 | //! `muxg`: the native client's entry. Parses the target, hands it to | ||
| 112 | //! src/gui/native.zig. Until Task 8 this file is the GL probe: it opens a | ||
| 113 | //! window on whatever video driver SDL picks and prints the GL version. | ||
| 114 | const std = @import("std"); | ||
| 115 | const native = @import("native"); | ||
| 116 | const client = @import("client"); | ||
| 117 | const term = @import("term"); | ||
| 118 | const cliflags = @import("cliflags"); | ||
| 119 | const sockpath = @import("sockpath"); | ||
| 120 | |||
| 121 | const c = @cImport({ | ||
| 122 | @cInclude("SDL3/SDL.h"); | ||
| 123 | }); | ||
| 124 | |||
| 125 | pub fn main() !u8 { | ||
| 126 | if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { | ||
| 127 | std.debug.print("muxg: SDL_Init: {s}\n", .{std.mem.span(c.SDL_GetError())}); | ||
| 128 | return 2; | ||
| 129 | } | ||
| 130 | defer c.SDL_Quit(); | ||
| 131 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); | ||
| 132 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3); | ||
| 133 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE); | ||
| 134 | const win = c.SDL_CreateWindow("muxg probe", 320, 200, c.SDL_WINDOW_OPENGL) orelse { | ||
| 135 | std.debug.print("muxg: SDL_CreateWindow: {s}\n", .{std.mem.span(c.SDL_GetError())}); | ||
| 136 | return 2; | ||
| 137 | }; | ||
| 138 | defer c.SDL_DestroyWindow(win); | ||
| 139 | const ctx = c.SDL_GL_CreateContext(win) orelse { | ||
| 140 | std.debug.print("muxg: SDL_GL_CreateContext: {s}\n", .{std.mem.span(c.SDL_GetError())}); | ||
| 141 | return 2; | ||
| 142 | }; | ||
| 143 | defer _ = c.SDL_GL_DestroyContext(ctx); | ||
| 144 | const get_string: ?*const fn (u32) callconv(.c) ?[*:0]const u8 = | ||
| 145 | @ptrCast(c.SDL_GL_GetProcAddress("glGetString")); | ||
| 146 | const f = get_string orelse { | ||
| 147 | std.debug.print("muxg: no glGetString\n", .{}); | ||
| 148 | return 2; | ||
| 149 | }; | ||
| 150 | const ver = f(0x1F02) orelse "?"; // GL_VERSION | ||
| 151 | std.debug.print("muxg probe: driver={s} GL_VERSION={s}\n", .{ | ||
| 152 | std.mem.span(c.SDL_GetCurrentVideoDriver()), | ||
| 153 | std.mem.span(ver), | ||
| 154 | }); | ||
| 155 | _ = native; | ||
| 156 | _ = client; | ||
| 157 | _ = term; | ||
| 158 | _ = cliflags; | ||
| 159 | _ = sockpath; | ||
| 160 | return 0; | ||
| 161 | } | ||
| 162 | ``` | ||
| 163 | |||
| 164 | - [ ] **Step 3: Add the two rows to the module table** | ||
| 165 | |||
| 166 | In `build.zig`, after the `mux` row of `mod_table`: | ||
| 167 | |||
| 168 | ```zig | ||
| 169 | // Opt-in rows: nothing in test_order and nothing the `mux` binary links | ||
| 170 | // names them, so `zig build`, `test` and `check` never compile a line of | ||
| 171 | // src/gui/. Only the `native*` steps below reference them. | ||
| 172 | .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "term", "client" } }, | ||
| 173 | .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath" } }, | ||
| 174 | ``` | ||
| 175 | |||
| 176 | - [ ] **Step 4: Add `src/gui` to rules 5, 6 and 7 and add rules 8 and 9** | ||
| 177 | |||
| 178 | 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: | ||
| 179 | |||
| 180 | ```zig | ||
| 181 | .{ | ||
| 182 | .rule = "8", | ||
| 183 | .folders = &.{"src/gui"}, | ||
| 184 | .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" }, | ||
| 185 | .why = "src/gui/ is a session viewer and carries no wall policy — " ++ | ||
| 186 | "tiles, the layout file, the picker and the host grade live under " ++ | ||
| 187 | "src/tui/, and a second copy of them is the hub's open issue again", | ||
| 188 | }, | ||
| 189 | .{ | ||
| 190 | .rule = "9", | ||
| 191 | .folders = &.{"src/gui"}, | ||
| 192 | .needles = &.{"sdl"}, | ||
| 193 | .except = &.{"src/gui/frame.zig"}, | ||
| 194 | .why = "the windowing library is confined to frame.zig, so every other " ++ | ||
| 195 | "painter file tests with no window and survives a change of layer", | ||
| 196 | }, | ||
| 197 | ``` | ||
| 198 | |||
| 199 | (Rule 9's folder is `src/gui` only; `src/cli/muxg.zig` is outside it and may spell SDL.) | ||
| 200 | |||
| 201 | - [ ] **Step 5: Add the `native` steps** | ||
| 202 | |||
| 203 | In `build()`, after the `agent_step` block: | ||
| 204 | |||
| 205 | ```zig | ||
| 206 | // The native client: opt-in, dynamically linked against the system's | ||
| 207 | // SDL3, freetype and fontconfig through pkg-config. GL is loaded by | ||
| 208 | // name through SDL, so no -lGL. Nothing on the default path depends on | ||
| 209 | // these, so a box without SDL3 builds and gates mux as before. | ||
| 210 | const native_mod = mods[comptime idxOf("native")]; | ||
| 211 | const muxg_mod = mods[comptime idxOf("muxg")]; | ||
| 212 | const muxg_exe = b.addExecutable(.{ .name = "muxg", .root_module = muxg_mod }); | ||
| 213 | linkerFor(muxg_exe); | ||
| 214 | muxg_exe.linkSystemLibrary2("sdl3", .{}); | ||
| 215 | muxg_exe.linkSystemLibrary2("freetype2", .{}); | ||
| 216 | muxg_exe.linkSystemLibrary2("fontconfig", .{}); | ||
| 217 | linkQuic(b, muxg_exe, quic); | ||
| 218 | const native_step = b.step("native", "Build the native client, muxg (opt-in; needs SDL3, freetype, fontconfig)"); | ||
| 219 | native_step.dependOn(&b.addInstallArtifact(muxg_exe, .{}).step); | ||
| 220 | |||
| 221 | // Unit tests open no window but link the libraries: frame.zig's key | ||
| 222 | // table is tested through the same compilation unit that calls SDL. | ||
| 223 | const native_tests = b.addTest(.{ .root_module = native_mod }); | ||
| 224 | linkerFor(native_tests); | ||
| 225 | native_tests.linkSystemLibrary2("sdl3", .{}); | ||
| 226 | native_tests.linkSystemLibrary2("freetype2", .{}); | ||
| 227 | native_tests.linkSystemLibrary2("fontconfig", .{}); | ||
| 228 | linkQuic(b, native_tests, quic); | ||
| 229 | const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)"); | ||
| 230 | native_test_step.dependOn(&b.addRunArtifact(native_tests).step); | ||
| 231 | |||
| 232 | const native_e2e = b.addSystemCommand(&.{"test/native.sh"}); | ||
| 233 | native_e2e.addArtifactArg(mux_exe); | ||
| 234 | native_e2e.addArtifactArg(muxg_exe); | ||
| 235 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | ||
| 236 | native_e2e_step.dependOn(&native_e2e.step); | ||
| 237 | ``` | ||
| 238 | |||
| 239 | `linkQuic` is needed because `client` links the QUIC transport; the `mux` binary already does the same. | ||
| 240 | |||
| 241 | - [ ] **Step 6: Add the Makefile targets** | ||
| 242 | |||
| 243 | After the `agent:` recipe in `Makefile`: | ||
| 244 | |||
| 245 | ```makefile | ||
| 246 | # The native client (docs/superpowers/specs/2026-09-04-native-client-design.md): | ||
| 247 | # opt-in, because it links the system's SDL3, freetype and fontconfig and | ||
| 248 | # a box without them must still build and gate mux. Not part of ci. | ||
| 249 | native: mac-sdk | ||
| 250 | $(ZIG) build native native-test | ||
| 251 | |||
| 252 | native-e2e: mac-sdk | ||
| 253 | $(ZIG) build native-e2e | ||
| 254 | ``` | ||
| 255 | |||
| 256 | - [ ] **Step 7: Build and run the probe (this is the spike)** | ||
| 257 | |||
| 258 | Run: | ||
| 259 | ```sh | ||
| 260 | make check; echo "check rc=$?" | ||
| 261 | make native; echo "native rc=$?" | ||
| 262 | SDL_VIDEODRIVER=offscreen ./zig-out/bin/muxg; echo "offscreen rc=$?" | ||
| 263 | ./zig-out/bin/muxg; echo "display rc=$?" | ||
| 264 | ``` | ||
| 265 | 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. | ||
| 266 | |||
| 267 | 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). | ||
| 268 | |||
| 269 | - [ ] **Step 8: Amend the spec's three stale lines** | ||
| 270 | |||
| 271 | In `docs/superpowers/specs/2026-09-04-native-client-design.md`: | ||
| 272 | - Section 1, the pkg-config sentence: replace `SDL3, libGL, freetype and fontconfig` with `SDL3, freetype and fontconfig (GL functions are loaded by name through SDL, so no libGL link)`. | ||
| 273 | - Section 6, `native-test builds src/gui/ without SDL and runs:` → `native-test links the same libraries, opens no window, and runs:`. | ||
| 274 | - 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.` | ||
| 275 | - Section 6, step 1 of the leg: record the offscreen or Xvfb outcome from step 7. | ||
| 276 | |||
| 277 | - [ ] **Step 9: Commit** | ||
| 278 | |||
| 279 | ```sh | ||
| 280 | 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 | ||
| 281 | git commit -m "build: opt-in native rows, steps and folder rules for the muxg viewer" | ||
| 282 | ``` | ||
| 283 | |||
| 284 | --- | ||
| 285 | |||
| 286 | ### Task 2: `bench.zig` — the frame-timing ring | ||
| 287 | |||
| 288 | **Files:** | ||
| 289 | - Create: `src/gui/bench.zig` | ||
| 290 | - Modify: `src/gui/native.zig` (add `pub const bench = @import("bench.zig");` and `_ = bench;` in the test block) | ||
| 291 | |||
| 292 | **Interfaces:** | ||
| 293 | - Produces: | ||
| 294 | - `pub const Frame = struct { apply_us: u32 = 0, rebuild_us: u32 = 0, atlas_us: u32 = 0, upload_us: u32 = 0, draw_us: u32 = 0 }` | ||
| 295 | - `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` | ||
| 296 | - `pub fn usSince(t: *std.time.Timer) u32` — microseconds since the timer's last lap, capped at `maxInt(u32)`. | ||
| 297 | |||
| 298 | - [ ] **Step 1: Write the failing tests** | ||
| 299 | |||
| 300 | 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): | ||
| 301 | |||
| 302 | ```zig | ||
| 303 | test "an empty ring reports zero frames and no stats rows" { | ||
| 304 | var r: Ring = .{}; | ||
| 305 | var buf: [2048]u8 = undefined; | ||
| 306 | const out = r.report(&buf); | ||
| 307 | try std.testing.expect(std.mem.indexOf(u8, out, "(0 frames)") != null); | ||
| 308 | } | ||
| 309 | |||
| 310 | /// The four numbers on the table row that starts with `name`, or null. | ||
| 311 | /// Tokenised rather than matched byte for byte, so the test pins the | ||
| 312 | /// stats and not the column widths. | ||
| 313 | fn statsRow(table: []const u8, name: []const u8) ?[4]u32 { | ||
| 314 | var lines = std.mem.splitScalar(u8, table, '\n'); | ||
| 315 | while (lines.next()) |line| { | ||
| 316 | var toks = std.mem.tokenizeScalar(u8, line, ' '); | ||
| 317 | const first = toks.next() orelse continue; | ||
| 318 | if (!std.mem.eql(u8, first, name)) continue; | ||
| 319 | var out: [4]u32 = undefined; | ||
| 320 | for (&out) |*slot| slot.* = std.fmt.parseInt(u32, toks.next() orelse return null, 10) catch return null; | ||
| 321 | return out; | ||
| 322 | } | ||
| 323 | return null; | ||
| 324 | } | ||
| 325 | |||
| 326 | test "min avg p99 max per stage and a total row" { | ||
| 327 | var r: Ring = .{}; | ||
| 328 | // 100 frames: rebuild 1..100 us, draw a flat 10 us. | ||
| 329 | var i: u32 = 1; | ||
| 330 | while (i <= 100) : (i += 1) r.record(.{ .rebuild_us = i, .draw_us = 10 }); | ||
| 331 | try std.testing.expectEqual(@as(u64, 100), r.count()); | ||
| 332 | var buf: [2048]u8 = undefined; | ||
| 333 | const out = r.report(&buf); | ||
| 334 | try std.testing.expect(std.mem.indexOf(u8, out, "(100 frames)") != null); | ||
| 335 | try std.testing.expectEqual([4]u32{ 1, 50, 99, 100 }, statsRow(out, "rebuild").?); | ||
| 336 | try std.testing.expectEqual([4]u32{ 10, 10, 10, 10 }, statsRow(out, "draw_swap").?); | ||
| 337 | // total per frame = rebuild + draw: min 11, avg 60, p99 109, max 110 | ||
| 338 | try std.testing.expectEqual([4]u32{ 11, 60, 109, 110 }, statsRow(out, "total").?); | ||
| 339 | } | ||
| 340 | |||
| 341 | test "the ring keeps the newest capacity frames" { | ||
| 342 | var r: Ring = .{}; | ||
| 343 | var i: u32 = 0; | ||
| 344 | while (i < Ring.capacity + 10) : (i += 1) r.record(.{ .apply_us = i }); | ||
| 345 | try std.testing.expectEqual(@as(u64, Ring.capacity + 10), r.count()); | ||
| 346 | var buf: [2048]u8 = undefined; | ||
| 347 | const out = r.report(&buf); | ||
| 348 | // The oldest ten (0..9) fell out: min is 10, max is 265. | ||
| 349 | const s = statsRow(out, "apply").?; | ||
| 350 | try std.testing.expectEqual(@as(u32, 10), s[0]); | ||
| 351 | try std.testing.expectEqual(@as(u32, Ring.capacity + 9), s[3]); | ||
| 352 | } | ||
| 353 | ``` | ||
| 354 | |||
| 355 | - [ ] **Step 2: Run to verify they fail** | ||
| 356 | |||
| 357 | Run: `deps/zig/zig build native-test 2>&1 | tail -5` | ||
| 358 | Expected: compile error, `Ring` not found. | ||
| 359 | |||
| 360 | - [ ] **Step 3: Implement** | ||
| 361 | |||
| 362 | Prepend to `src/gui/bench.zig` (above the tests): | ||
| 363 | |||
| 364 | ```zig | ||
| 365 | //! Per-frame stage timings in a ring, and the table that reports them. | ||
| 366 | //! Always compiled in: a timer read per stage is noise next to a draw, and | ||
| 367 | //! the table is how the waystty fault was found — a painter that is fast | ||
| 368 | //! per frame and never asked to paint. Idle passes are not recorded. | ||
| 369 | //! Pure: no clock of its own, no output; the caller times and prints. | ||
| 370 | const std = @import("std"); | ||
| 371 | |||
| 372 | pub const Frame = struct { | ||
| 373 | apply_us: u32 = 0, | ||
| 374 | rebuild_us: u32 = 0, | ||
| 375 | atlas_us: u32 = 0, | ||
| 376 | upload_us: u32 = 0, | ||
| 377 | draw_us: u32 = 0, | ||
| 378 | |||
| 379 | fn total(self: Frame) u32 { | ||
| 380 | return self.apply_us +| self.rebuild_us +| self.atlas_us +| self.upload_us +| self.draw_us; | ||
| 381 | } | ||
| 382 | }; | ||
| 383 | |||
| 384 | pub const Ring = struct { | ||
| 385 | pub const capacity = 256; | ||
| 386 | |||
| 387 | frames: [capacity]Frame = undefined, | ||
| 388 | next: usize = 0, | ||
| 389 | recorded: u64 = 0, | ||
| 390 | |||
| 391 | pub fn record(self: *Ring, f: Frame) void { | ||
| 392 | self.frames[self.next] = f; | ||
| 393 | self.next = (self.next + 1) % capacity; | ||
| 394 | self.recorded += 1; | ||
| 395 | } | ||
| 396 | |||
| 397 | pub fn count(self: *const Ring) u64 { | ||
| 398 | return self.recorded; | ||
| 399 | } | ||
| 400 | |||
| 401 | fn held(self: *const Ring) usize { | ||
| 402 | return @intCast(@min(self.recorded, capacity)); | ||
| 403 | } | ||
| 404 | |||
| 405 | const Stats = struct { min: u32, avg: u32, p99: u32, max: u32 }; | ||
| 406 | |||
| 407 | fn stats(self: *const Ring, comptime pick: fn (Frame) u32) Stats { | ||
| 408 | const n = self.held(); | ||
| 409 | var vals: [capacity]u32 = undefined; | ||
| 410 | var sum: u64 = 0; | ||
| 411 | for (self.frames[0..n], 0..) |f, i| { | ||
| 412 | vals[i] = pick(f); | ||
| 413 | sum += vals[i]; | ||
| 414 | } | ||
| 415 | std.mem.sort(u32, vals[0..n], {}, std.sort.asc(u32)); | ||
| 416 | return .{ | ||
| 417 | .min = vals[0], | ||
| 418 | .avg = @intCast(sum / n), | ||
| 419 | .p99 = vals[(n * 99) / 100 - @intFromBool((n * 99) % 100 == 0 and n * 99 / 100 > 0)], | ||
| 420 | .max = vals[n - 1], | ||
| 421 | }; | ||
| 422 | } | ||
| 423 | |||
| 424 | const rows = [_]struct { name: []const u8, pick: fn (Frame) u32 }{ | ||
| 425 | .{ .name = "apply", .pick = struct { | ||
| 426 | fn f(x: Frame) u32 { | ||
| 427 | return x.apply_us; | ||
| 428 | } | ||
| 429 | }.f }, | ||
| 430 | .{ .name = "rebuild", .pick = struct { | ||
| 431 | fn f(x: Frame) u32 { | ||
| 432 | return x.rebuild_us; | ||
| 433 | } | ||
| 434 | }.f }, | ||
| 435 | .{ .name = "atlas_upload", .pick = struct { | ||
| 436 | fn f(x: Frame) u32 { | ||
| 437 | return x.atlas_us; | ||
| 438 | } | ||
| 439 | }.f }, | ||
| 440 | .{ .name = "instance_upload", .pick = struct { | ||
| 441 | fn f(x: Frame) u32 { | ||
| 442 | return x.upload_us; | ||
| 443 | } | ||
| 444 | }.f }, | ||
| 445 | .{ .name = "draw_swap", .pick = struct { | ||
| 446 | fn f(x: Frame) u32 { | ||
| 447 | return x.draw_us; | ||
| 448 | } | ||
| 449 | }.f }, | ||
| 450 | .{ .name = "total", .pick = Frame.total }, | ||
| 451 | }; | ||
| 452 | |||
| 453 | /// The table, into `buf`. A buffer too small truncates the table | ||
| 454 | /// rather than failing: a report is diagnostics, never a gate. | ||
| 455 | pub fn report(self: *const Ring, buf: []u8) []const u8 { | ||
| 456 | var w = std.io.Writer.fixed(buf); | ||
| 457 | w.print("=== muxg frame timing ({d} frames) ===\n", .{self.recorded}) catch return w.buffered(); | ||
| 458 | w.print("{s:<16}{s:>6}{s:>7}{s:>7}{s:>7} (us)\n", .{ "stage", "min", "avg", "p99", "max" }) catch return w.buffered(); | ||
| 459 | if (self.held() == 0) return w.buffered(); | ||
| 460 | inline for (rows) |row| { | ||
| 461 | const s = self.stats(row.pick); | ||
| 462 | 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(); | ||
| 463 | } | ||
| 464 | return w.buffered(); | ||
| 465 | } | ||
| 466 | }; | ||
| 467 | |||
| 468 | /// Microseconds since the timer's last lap, capped so a stall cannot wrap. | ||
| 469 | pub fn usSince(t: *std.time.Timer) u32 { | ||
| 470 | const ns = t.lap(); | ||
| 471 | return @intCast(@min(ns / std.time.ns_per_us, std.math.maxInt(u32))); | ||
| 472 | } | ||
| 473 | ``` | ||
| 474 | |||
| 475 | 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. | ||
| 476 | |||
| 477 | - [ ] **Step 4: Wire the re-export and run the tests** | ||
| 478 | |||
| 479 | 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.) | ||
| 480 | |||
| 481 | Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?` | ||
| 482 | 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. | ||
| 483 | |||
| 484 | - [ ] **Step 5: Commit** | ||
| 485 | |||
| 486 | ```sh | ||
| 487 | make check && git add src/gui/bench.zig src/gui/native.zig | ||
| 488 | git commit -m "feat: the native client's per-frame stage timing ring and table" | ||
| 489 | ``` | ||
| 490 | |||
| 491 | --- | ||
| 492 | |||
| 493 | ### Task 3: `atlas.zig` — the glyph atlas | ||
| 494 | |||
| 495 | **Files:** | ||
| 496 | - Create: `src/gui/atlas.zig` | ||
| 497 | - Modify: `src/gui/native.zig` (re-export + test reach) | ||
| 498 | |||
| 499 | **Interfaces:** | ||
| 500 | - Produces: | ||
| 501 | - `pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }` | ||
| 502 | - `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; }` | ||
| 503 | - `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. | ||
| 504 | |||
| 505 | - [ ] **Step 1: Write the failing tests** | ||
| 506 | |||
| 507 | Create `src/gui/atlas.zig` with `const std = @import("std");` and: | ||
| 508 | |||
| 509 | ```zig | ||
| 510 | test "a second glyph lands beside the first on the same shelf" { | ||
| 511 | const alloc = std.testing.allocator; | ||
| 512 | var a = try Atlas.init(alloc, 64, 16); | ||
| 513 | defer a.deinit(alloc); | ||
| 514 | const px = [_]u8{0xff} ** (8 * 12); | ||
| 515 | const e1 = try a.put(alloc, 'A', 8, 12, 0, 12, &px); | ||
| 516 | const e2 = try a.put(alloc, 'B', 8, 12, 1, 11, &px); | ||
| 517 | try std.testing.expectEqual(@as(u16, 0), e1.x); | ||
| 518 | try std.testing.expectEqual(@as(u16, 8), e2.x); | ||
| 519 | try std.testing.expectEqual(e1.y, e2.y); | ||
| 520 | try std.testing.expectEqual(@as(i16, 1), e2.left); | ||
| 521 | try std.testing.expect(a.dirty); | ||
| 522 | try std.testing.expectEqual(e1, a.get('A').?); | ||
| 523 | try std.testing.expect(a.get('C') == null); | ||
| 524 | } | ||
| 525 | |||
| 526 | test "a row that overflows opens a new shelf below" { | ||
| 527 | const alloc = std.testing.allocator; | ||
| 528 | var a = try Atlas.init(alloc, 20, 64); | ||
| 529 | defer a.deinit(alloc); | ||
| 530 | const px = [_]u8{1} ** (8 * 10); | ||
| 531 | _ = try a.put(alloc, 'a', 8, 10, 0, 0, &px); | ||
| 532 | _ = try a.put(alloc, 'b', 8, 10, 0, 0, &px); | ||
| 533 | const e3 = try a.put(alloc, 'c', 8, 10, 0, 0, &px); | ||
| 534 | try std.testing.expectEqual(@as(u16, 0), e3.x); | ||
| 535 | try std.testing.expectEqual(@as(u16, 10), e3.y); | ||
| 536 | } | ||
| 537 | |||
| 538 | test "growth keeps every earlier entry's rectangle and pixels" { | ||
| 539 | const alloc = std.testing.allocator; | ||
| 540 | var a = try Atlas.init(alloc, 16, 6); | ||
| 541 | defer a.deinit(alloc); | ||
| 542 | const px1 = [_]u8{7} ** (4 * 4); | ||
| 543 | const e1 = try a.put(alloc, 'x', 4, 4, 0, 0, &px1); | ||
| 544 | const px2 = [_]u8{9} ** (4 * 8); | ||
| 545 | // 8 tall on a 6-high atlas: the shelf grows the atlas to 12 (doubling). | ||
| 546 | const e2 = try a.put(alloc, 'y', 4, 8, 0, 0, &px2); | ||
| 547 | try std.testing.expectEqual(@as(u16, 12), a.height); | ||
| 548 | try std.testing.expectEqual(@as(u16, 4), e2.x); // beside x, same shelf | ||
| 549 | try std.testing.expectEqual(e1, a.get('x').?); | ||
| 550 | try std.testing.expectEqual(@as(u8, 7), a.pixels[@as(usize, e1.y) * a.width + e1.x]); | ||
| 551 | try std.testing.expectEqual(@as(u8, 9), a.pixels[@as(usize, e2.y) * a.width + e2.x]); | ||
| 552 | } | ||
| 553 | |||
| 554 | test "a glyph wider than the atlas is refused, not wrapped" { | ||
| 555 | const alloc = std.testing.allocator; | ||
| 556 | var a = try Atlas.init(alloc, 8, 8); | ||
| 557 | defer a.deinit(alloc); | ||
| 558 | const px = [_]u8{1} ** (9 * 1); | ||
| 559 | try std.testing.expectError(error.GlyphTooWide, a.put(alloc, 'w', 9, 1, 0, 0, &px)); | ||
| 560 | } | ||
| 561 | ``` | ||
| 562 | |||
| 563 | - [ ] **Step 2: Run to verify they fail** | ||
| 564 | |||
| 565 | Run: `deps/zig/zig build native-test 2>&1 | tail -5` — after adding `pub const atlas = @import("atlas.zig");` and `_ = atlas;` to `native.zig`. | ||
| 566 | Expected: compile error, `Atlas` not found. | ||
| 567 | |||
| 568 | - [ ] **Step 3: Implement** | ||
| 569 | |||
| 570 | Prepend to `src/gui/atlas.zig`: | ||
| 571 | |||
| 572 | ```zig | ||
| 573 | //! One R8 texture's worth of glyph bitmaps, shelf-packed: glyphs go left | ||
| 574 | //! to right along a shelf as tall as its tallest glyph, and a glyph that | ||
| 575 | //! does not fit opens the next shelf below. The atlas never shrinks and | ||
| 576 | //! never moves an entry — growth doubles the height and copies, so every | ||
| 577 | //! rectangle a quad already holds stays true. `dirty` says the texture on | ||
| 578 | //! the GPU is behind; the uploader clears it. No GL here. | ||
| 579 | const std = @import("std"); | ||
| 580 | |||
| 581 | pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }; | ||
| 582 | |||
| 583 | pub const Atlas = struct { | ||
| 584 | width: u16, | ||
| 585 | height: u16, | ||
| 586 | pixels: []u8, | ||
| 587 | dirty: bool = false, | ||
| 588 | entries: std.AutoHashMapUnmanaged(u21, Entry) = .empty, | ||
| 589 | /// The open shelf: its top row, its height, and the next free x. | ||
| 590 | shelf_y: u16 = 0, | ||
| 591 | shelf_h: u16 = 0, | ||
| 592 | shelf_x: u16 = 0, | ||
| 593 | |||
| 594 | pub fn init(alloc: std.mem.Allocator, width: u16, height: u16) !Atlas { | ||
| 595 | const px = try alloc.alloc(u8, @as(usize, width) * height); | ||
| 596 | @memset(px, 0); | ||
| 597 | return .{ .width = width, .height = height, .pixels = px }; | ||
| 598 | } | ||
| 599 | |||
| 600 | pub fn deinit(self: *Atlas, alloc: std.mem.Allocator) void { | ||
| 601 | alloc.free(self.pixels); | ||
| 602 | self.entries.deinit(alloc); | ||
| 603 | } | ||
| 604 | |||
| 605 | pub fn get(self: *const Atlas, cp: u21) ?Entry { | ||
| 606 | return self.entries.get(cp); | ||
| 607 | } | ||
| 608 | |||
| 609 | pub fn put( | ||
| 610 | self: *Atlas, | ||
| 611 | alloc: std.mem.Allocator, | ||
| 612 | cp: u21, | ||
| 613 | w: u16, | ||
| 614 | h: u16, | ||
| 615 | left: i16, | ||
| 616 | top: i16, | ||
| 617 | bitmap: []const u8, | ||
| 618 | ) !Entry { | ||
| 619 | if (w > self.width) return error.GlyphTooWide; | ||
| 620 | std.debug.assert(bitmap.len >= @as(usize, w) * h); | ||
| 621 | // Wrap to a new shelf when this glyph does not fit beside the last. | ||
| 622 | if (self.shelf_x + w > self.width) { | ||
| 623 | self.shelf_y += self.shelf_h; | ||
| 624 | self.shelf_h = 0; | ||
| 625 | self.shelf_x = 0; | ||
| 626 | } | ||
| 627 | // Grow until the shelf's bottom (with this glyph on it) fits. | ||
| 628 | while (self.shelf_y + @max(self.shelf_h, h) > self.height) try self.grow(alloc); | ||
| 629 | const e: Entry = .{ .x = self.shelf_x, .y = self.shelf_y, .w = w, .h = h, .left = left, .top = top }; | ||
| 630 | var row: usize = 0; | ||
| 631 | while (row < h) : (row += 1) { | ||
| 632 | const dst = (@as(usize, e.y) + row) * self.width + e.x; | ||
| 633 | @memcpy(self.pixels[dst .. dst + w], bitmap[row * w .. row * w + w]); | ||
| 634 | } | ||
| 635 | self.shelf_x += w; | ||
| 636 | self.shelf_h = @max(self.shelf_h, h); | ||
| 637 | self.dirty = true; | ||
| 638 | try self.entries.put(alloc, cp, e); | ||
| 639 | return e; | ||
| 640 | } | ||
| 641 | |||
| 642 | fn grow(self: *Atlas, alloc: std.mem.Allocator) !void { | ||
| 643 | const new_h: u16 = std.math.mul(u16, self.height, 2) catch return error.AtlasFull; | ||
| 644 | const px = try alloc.alloc(u8, @as(usize, self.width) * new_h); | ||
| 645 | @memset(px, 0); | ||
| 646 | @memcpy(px[0..self.pixels.len], self.pixels); | ||
| 647 | alloc.free(self.pixels); | ||
| 648 | self.pixels = px; | ||
| 649 | self.height = new_h; | ||
| 650 | self.dirty = true; | ||
| 651 | } | ||
| 652 | }; | ||
| 653 | ``` | ||
| 654 | |||
| 655 | - [ ] **Step 4: Run the tests** | ||
| 656 | |||
| 657 | Run: `deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$?` | ||
| 658 | Expected: rc 0. | ||
| 659 | |||
| 660 | - [ ] **Step 5: Commit** | ||
| 661 | |||
| 662 | ```sh | ||
| 663 | make check && git add src/gui/atlas.zig src/gui/native.zig | ||
| 664 | git commit -m "feat: the native client's shelf-packed glyph atlas" | ||
| 665 | ``` | ||
| 666 | |||
| 667 | --- | ||
| 668 | |||
| 669 | ### Task 4: `quads.zig` — cells to instanced quads | ||
| 670 | |||
| 671 | **Files:** | ||
| 672 | - Create: `src/gui/quads.zig` | ||
| 673 | - Modify: `src/gui/native.zig` (re-export + test reach) | ||
| 674 | |||
| 675 | **Interfaces:** | ||
| 676 | - Consumes: `term.grid.Row`, `term.grid.Cell`, `term.protocol.CellStyle`, `term.protocol.Wide`, `term.protocol.color_none/colorPalette/colorRgb`; `atlas.Entry`. | ||
| 677 | - Produces: | ||
| 678 | - `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. | ||
| 679 | - `pub const Glyphs = struct { ctx: *anyopaque, get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry }` | ||
| 680 | - `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 }` | ||
| 681 | - `pub fn rowInstances(out: *std.ArrayListUnmanaged(Instance), alloc, row: *const Row, cols: u16, col_off: u16, y: u16, ctx: Ctx) !void` | ||
| 682 | - `pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance` | ||
| 683 | - `pub fn rgbaOf(color: u32, default: u32) u32` — the wire colour (none / palette / rgb) to packed RGBA. | ||
| 684 | |||
| 685 | - [ ] **Step 1: Write the failing tests** | ||
| 686 | |||
| 687 | Create `src/gui/quads.zig` with the imports and tests: | ||
| 688 | |||
| 689 | ```zig | ||
| 690 | const std = @import("std"); | ||
| 691 | const term = @import("term"); | ||
| 692 | const grid = term.grid; | ||
| 693 | const proto = term.protocol; | ||
| 694 | const atlas = @import("atlas.zig"); | ||
| 695 | |||
| 696 | // --- tests --- | ||
| 697 | |||
| 698 | const StubGlyphs = struct { | ||
| 699 | fn get(_: *anyopaque, cp: u21) ?atlas.Entry { | ||
| 700 | // Every codepoint has a 6x10 glyph at (cp % 4) * 8 on shelf 0. | ||
| 701 | return .{ .x = @intCast((cp % 4) * 8), .y = 0, .w = 6, .h = 10, .left = 1, .top = 9 }; | ||
| 702 | } | ||
| 703 | }; | ||
| 704 | |||
| 705 | var stub_ctx_byte: u8 = 0; | ||
| 706 | |||
| 707 | fn testCtx() Ctx { | ||
| 708 | return .{ | ||
| 709 | .cell_w = 8, | ||
| 710 | .cell_h = 16, | ||
| 711 | .ascent = 12, | ||
| 712 | .x0 = 0, | ||
| 713 | .y0 = 0, | ||
| 714 | .atlas_w = 64, | ||
| 715 | .atlas_h = 64, | ||
| 716 | .glyphs = .{ .ctx = @ptrCast(&stub_ctx_byte), .get = StubGlyphs.get }, | ||
| 717 | }; | ||
| 718 | } | ||
| 719 | |||
| 720 | /// A row built by hand: `spec` is one entry per cell, text "" for a blank. | ||
| 721 | const CellSpec = struct { text: []const u8 = "", style: proto.CellStyle = .{}, wide: proto.Wide = .narrow }; | ||
| 722 | |||
| 723 | fn rowOf(alloc: std.mem.Allocator, spec: []const CellSpec) !grid.Row { | ||
| 724 | var r: grid.Row = .{ .cells = try alloc.alloc(grid.Cell, spec.len), .text = .empty }; | ||
| 725 | for (spec, 0..) |s, i| { | ||
| 726 | const off: u32 = @intCast(r.text.items.len); | ||
| 727 | try r.text.appendSlice(alloc, s.text); | ||
| 728 | r.cells[i] = .{ .style = s.style, .wide = s.wide, .text_off = off, .text_len = @intCast(s.text.len) }; | ||
| 729 | } | ||
| 730 | return r; | ||
| 731 | } | ||
| 732 | |||
| 733 | fn freeRow(alloc: std.mem.Allocator, r: *grid.Row) void { | ||
| 734 | alloc.free(r.cells); | ||
| 735 | r.text.deinit(alloc); | ||
| 736 | } | ||
| 737 | |||
| 738 | test "a blank cell emits nothing, text one glyph quad, a styled blank one background quad" { | ||
| 739 | const alloc = std.testing.allocator; | ||
| 740 | var row = try rowOf(alloc, &.{ | ||
| 741 | .{}, | ||
| 742 | .{ .text = "A" }, | ||
| 743 | .{ .style = .{ .bg = proto.colorRgb(10, 20, 30) } }, | ||
| 744 | }); | ||
| 745 | defer freeRow(alloc, &row); | ||
| 746 | var out: std.ArrayListUnmanaged(Instance) = .empty; | ||
| 747 | defer out.deinit(alloc); | ||
| 748 | // Column offset 5, row 2: the fixture is off-origin on purpose. | ||
| 749 | try rowInstances(&out, alloc, &row, 3, 5, 2, testCtx()); | ||
| 750 | try std.testing.expectEqual(@as(usize, 2), out.items.len); | ||
| 751 | const g = out.items[0]; | ||
| 752 | try std.testing.expectEqual(@as(u32, 1), g.kind); | ||
| 753 | // cell (5+1, 2) at 8x16: x = 48 + left 1 = 49; y = 32 + (ascent 12 - top 9) = 35 | ||
| 754 | try std.testing.expectEqual(@as(f32, 49), g.x); | ||
| 755 | try std.testing.expectEqual(@as(f32, 35), g.y); | ||
| 756 | try std.testing.expectEqual(@as(f32, 6), g.w); | ||
| 757 | try std.testing.expectEqual(@as(f32, 10), g.h); | ||
| 758 | // 'A' = 65, 65 % 4 = 1 → atlas x 8: u0 = 8/64 | ||
| 759 | try std.testing.expectApproxEqAbs(@as(f32, 0.125), g.u0, 1e-6); | ||
| 760 | try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), g.rgba); | ||
| 761 | const b = out.items[1]; | ||
| 762 | try std.testing.expectEqual(@as(u32, 0), b.kind); | ||
| 763 | try std.testing.expectEqual(@as(f32, 56), b.x); | ||
| 764 | try std.testing.expectEqual(@as(f32, 32), b.y); | ||
| 765 | try std.testing.expectEqual(@as(f32, 8), b.w); | ||
| 766 | try std.testing.expectEqual(@as(f32, 16), b.h); | ||
| 767 | try std.testing.expectEqual(@as(u32, 0x0a141eff), b.rgba); | ||
| 768 | } | ||
| 769 | |||
| 770 | test "a wide cell emits one two-column glyph quad and its tail emits nothing" { | ||
| 771 | const alloc = std.testing.allocator; | ||
| 772 | var row = try rowOf(alloc, &.{ | ||
| 773 | .{ .text = "漢", .wide = .wide }, | ||
| 774 | .{ .wide = .spacer_tail }, | ||
| 775 | .{ .text = "x" }, | ||
| 776 | }); | ||
| 777 | defer freeRow(alloc, &row); | ||
| 778 | var out: std.ArrayListUnmanaged(Instance) = .empty; | ||
| 779 | defer out.deinit(alloc); | ||
| 780 | try rowInstances(&out, alloc, &row, 3, 0, 0, testCtx()); | ||
| 781 | try std.testing.expectEqual(@as(usize, 2), out.items.len); | ||
| 782 | try std.testing.expectEqual(@as(f32, 1), out.items[0].x); | ||
| 783 | try std.testing.expectEqual(@as(f32, 16 + 1), out.items[1].x); | ||
| 784 | } | ||
| 785 | |||
| 786 | test "background quads come before glyph quads so a glyph is never covered" { | ||
| 787 | const alloc = std.testing.allocator; | ||
| 788 | var row = try rowOf(alloc, &.{ | ||
| 789 | .{ .text = "q", .style = .{ .bg = proto.colorPalette(1) } }, | ||
| 790 | }); | ||
| 791 | defer freeRow(alloc, &row); | ||
| 792 | var out: std.ArrayListUnmanaged(Instance) = .empty; | ||
| 793 | defer out.deinit(alloc); | ||
| 794 | try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx()); | ||
| 795 | try std.testing.expectEqual(@as(usize, 2), out.items.len); | ||
| 796 | try std.testing.expectEqual(@as(u32, 0), out.items[0].kind); | ||
| 797 | try std.testing.expectEqual(@as(u32, 1), out.items[1].kind); | ||
| 798 | try std.testing.expectEqual(@as(u32, 0xcc0000ff), out.items[0].rgba); // palette 1 = red | ||
| 799 | } | ||
| 800 | |||
| 801 | test "inverse swaps fg and bg" { | ||
| 802 | const alloc = std.testing.allocator; | ||
| 803 | var row = try rowOf(alloc, &.{ | ||
| 804 | .{ .text = "i", .style = .{ .flags = 1 << 4 } }, | ||
| 805 | }); | ||
| 806 | defer freeRow(alloc, &row); | ||
| 807 | var out: std.ArrayListUnmanaged(Instance) = .empty; | ||
| 808 | defer out.deinit(alloc); | ||
| 809 | try rowInstances(&out, alloc, &row, 1, 0, 0, testCtx()); | ||
| 810 | try std.testing.expectEqual(@as(usize, 2), out.items.len); | ||
| 811 | try std.testing.expectEqual(@as(u32, 0xd0d0d0ff), out.items[0].rgba); // bg painted in default fg | ||
| 812 | try std.testing.expectEqual(@as(u32, 0x101010ff), out.items[1].rgba); // glyph in default bg | ||
| 813 | } | ||
| 814 | |||
| 815 | test "the cursor quad covers its cell" { | ||
| 816 | const cur = cursorInstance(3, 1, testCtx()); | ||
| 817 | try std.testing.expectEqual(@as(f32, 24), cur.x); | ||
| 818 | try std.testing.expectEqual(@as(f32, 16), cur.y); | ||
| 819 | try std.testing.expectEqual(@as(f32, 8), cur.w); | ||
| 820 | try std.testing.expectEqual(@as(f32, 16), cur.h); | ||
| 821 | try std.testing.expectEqual(@as(u32, 0), cur.kind); | ||
| 822 | } | ||
| 823 | |||
| 824 | test "wire colours: none is the default, palette indexes the table, rgb passes through" { | ||
| 825 | try std.testing.expectEqual(@as(u32, 0xabcdefff), rgbaOf(proto.color_none, 0xabcdefff)); | ||
| 826 | try std.testing.expectEqual(@as(u32, 0x000000ff), rgbaOf(proto.colorPalette(0), 0xabcdefff)); | ||
| 827 | try std.testing.expectEqual(@as(u32, 0x0000eeff), rgbaOf(proto.colorPalette(4), 0)); | ||
| 828 | try std.testing.expectEqual(@as(u32, 0x5f0000ff), rgbaOf(proto.colorPalette(52), 0)); // cube (1,0,0) | ||
| 829 | try std.testing.expectEqual(@as(u32, 0x080808ff), rgbaOf(proto.colorPalette(232), 0)); // first grey | ||
| 830 | try std.testing.expectEqual(@as(u32, 0x112233ff), rgbaOf(proto.colorRgb(0x11, 0x22, 0x33), 0)); | ||
| 831 | } | ||
| 832 | ``` | ||
| 833 | |||
| 834 | - [ ] **Step 2: Run to verify they fail** | ||
| 835 | |||
| 836 | Add `pub const quads = @import("quads.zig");` and `_ = quads;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5` | ||
| 837 | Expected: compile error, `Instance`/`rowInstances` not found. | ||
| 838 | |||
| 839 | - [ ] **Step 3: Implement** | ||
| 840 | |||
| 841 | Insert between the imports and `// --- tests ---` in `src/gui/quads.zig`: | ||
| 842 | |||
| 843 | ```zig | ||
| 844 | //! A grid row becomes instanced quads: zero, one or two per cell — a | ||
| 845 | //! background quad when the cell's background is not the default, a glyph | ||
| 846 | //! quad when the cell has text. Backgrounds come first in the output so a | ||
| 847 | //! glyph is never covered by its own cell's background. A wide cell's | ||
| 848 | //! glyph spans two columns and its trailing half emits nothing. The port | ||
| 849 | //! of waystty's cell_instance.zig, rewritten over term.grid; the atlas | ||
| 850 | //! lookup is a callback so this file tests with no font and no GL. | ||
| 851 | |||
| 852 | pub const Instance = extern struct { | ||
| 853 | x: f32, | ||
| 854 | y: f32, | ||
| 855 | w: f32, | ||
| 856 | h: f32, | ||
| 857 | u0: f32, | ||
| 858 | v0: f32, | ||
| 859 | u1: f32, | ||
| 860 | v1: f32, | ||
| 861 | rgba: u32, | ||
| 862 | kind: u32, | ||
| 863 | |||
| 864 | pub const solid: u32 = 0; | ||
| 865 | pub const glyph: u32 = 1; | ||
| 866 | }; | ||
| 867 | |||
| 868 | pub const Glyphs = struct { | ||
| 869 | ctx: *anyopaque, | ||
| 870 | get: *const fn (ctx: *anyopaque, cp: u21) ?atlas.Entry, | ||
| 871 | }; | ||
| 872 | |||
| 873 | pub const Ctx = struct { | ||
| 874 | cell_w: u16, | ||
| 875 | cell_h: u16, | ||
| 876 | /// Baseline from the cell's top, in pixels: a glyph's `top` bearing is | ||
| 877 | /// measured from it. | ||
| 878 | ascent: u16, | ||
| 879 | x0: f32 = 0, | ||
| 880 | y0: f32 = 0, | ||
| 881 | atlas_w: f32, | ||
| 882 | atlas_h: f32, | ||
| 883 | glyphs: Glyphs, | ||
| 884 | default_fg: u32 = 0xd0d0d0ff, | ||
| 885 | default_bg: u32 = 0x101010ff, | ||
| 886 | }; | ||
| 887 | |||
| 888 | /// ghostty Style.Flags bit 4 (see proto.CellStyle). | ||
| 889 | const flag_inverse: u16 = 1 << 4; | ||
| 890 | /// ghostty Style.Flags bit 5. | ||
| 891 | const flag_invisible: u16 = 1 << 5; | ||
| 892 | |||
| 893 | const ansi16 = [16]u32{ | ||
| 894 | 0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff, | ||
| 895 | 0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff, | ||
| 896 | }; | ||
| 897 | |||
| 898 | fn packRgb(r: u8, g: u8, b: u8) u32 { | ||
| 899 | return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff; | ||
| 900 | } | ||
| 901 | |||
| 902 | fn paletteRgba(i: u8) u32 { | ||
| 903 | if (i < 16) return ansi16[i]; | ||
| 904 | if (i < 232) { | ||
| 905 | const n = i - 16; | ||
| 906 | const steps = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff }; | ||
| 907 | return packRgb(steps[n / 36], steps[(n / 6) % 6], steps[n % 6]); | ||
| 908 | } | ||
| 909 | const g: u8 = 8 + 10 * (i - 232); | ||
| 910 | return packRgb(g, g, g); | ||
| 911 | } | ||
| 912 | |||
| 913 | /// A wire colour (proto.color_none, colorPalette, colorRgb) to packed RGBA. | ||
| 914 | pub fn rgbaOf(color: u32, default: u32) u32 { | ||
| 915 | return switch (color >> 24) { | ||
| 916 | 1 => paletteRgba(@intCast(color & 0xff)), | ||
| 917 | 2 => (color << 8) | 0xff, | ||
| 918 | else => default, | ||
| 919 | }; | ||
| 920 | } | ||
| 921 | |||
| 922 | fn firstCodepoint(text: []const u8) ?u21 { | ||
| 923 | if (text.len == 0) return null; | ||
| 924 | const n = std.unicode.utf8ByteSequenceLength(text[0]) catch return null; | ||
| 925 | if (text.len < n) return null; | ||
| 926 | return std.unicode.utf8Decode(text[0..n]) catch null; | ||
| 927 | } | ||
| 928 | |||
| 929 | pub fn rowInstances( | ||
| 930 | out: *std.ArrayListUnmanaged(Instance), | ||
| 931 | alloc: std.mem.Allocator, | ||
| 932 | row: *const grid.Row, | ||
| 933 | cols: u16, | ||
| 934 | col_off: u16, | ||
| 935 | y: u16, | ||
| 936 | ctx: Ctx, | ||
| 937 | ) !void { | ||
| 938 | const n: usize = @min(cols, row.cells.len); | ||
| 939 | const cell_h: f32 = @floatFromInt(ctx.cell_h); | ||
| 940 | const cell_w: f32 = @floatFromInt(ctx.cell_w); | ||
| 941 | const top = ctx.y0 + @as(f32, @floatFromInt(y)) * cell_h; | ||
| 942 | |||
| 943 | // Backgrounds first, then glyphs: two passes over the row, so the | ||
| 944 | // ordering rule holds without a sort. | ||
| 945 | var x: usize = 0; | ||
| 946 | while (x < n) : (x += 1) { | ||
| 947 | const c = row.cells[x]; | ||
| 948 | if (c.wide == .spacer_tail) continue; | ||
| 949 | const inverse = c.style.flags & flag_inverse != 0; | ||
| 950 | const bg = if (inverse) rgbaOf(c.style.fg, ctx.default_fg) else rgbaOf(c.style.bg, ctx.default_bg); | ||
| 951 | if (!inverse and c.style.bg == proto.color_none) continue; | ||
| 952 | const span: f32 = if (c.wide == .wide) 2 else 1; | ||
| 953 | try out.append(alloc, .{ | ||
| 954 | .x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w, | ||
| 955 | .y = top, | ||
| 956 | .w = cell_w * span, | ||
| 957 | .h = cell_h, | ||
| 958 | .u0 = 0, | ||
| 959 | .v0 = 0, | ||
| 960 | .u1 = 0, | ||
| 961 | .v1 = 0, | ||
| 962 | .rgba = bg, | ||
| 963 | .kind = Instance.solid, | ||
| 964 | }); | ||
| 965 | } | ||
| 966 | x = 0; | ||
| 967 | while (x < n) : (x += 1) { | ||
| 968 | const c = row.cells[x]; | ||
| 969 | if (c.wide == .spacer_tail or c.text_len == 0) continue; | ||
| 970 | if (c.style.flags & flag_invisible != 0) continue; | ||
| 971 | const cp = firstCodepoint(row.textOf(c)) orelse continue; | ||
| 972 | const e = ctx.glyphs.get(ctx.glyphs.ctx, cp) orelse continue; | ||
| 973 | const inverse = c.style.flags & flag_inverse != 0; | ||
| 974 | const fg = if (inverse) rgbaOf(c.style.bg, ctx.default_bg) else rgbaOf(c.style.fg, ctx.default_fg); | ||
| 975 | const cell_x = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cell_w; | ||
| 976 | try out.append(alloc, .{ | ||
| 977 | .x = cell_x + @as(f32, @floatFromInt(e.left)), | ||
| 978 | .y = top + @as(f32, @floatFromInt(@as(i32, ctx.ascent) - e.top)), | ||
| 979 | .w = @floatFromInt(e.w), | ||
| 980 | .h = @floatFromInt(e.h), | ||
| 981 | .u0 = @as(f32, @floatFromInt(e.x)) / ctx.atlas_w, | ||
| 982 | .v0 = @as(f32, @floatFromInt(e.y)) / ctx.atlas_h, | ||
| 983 | .u1 = @as(f32, @floatFromInt(e.x + e.w)) / ctx.atlas_w, | ||
| 984 | .v1 = @as(f32, @floatFromInt(e.y + e.h)) / ctx.atlas_h, | ||
| 985 | .rgba = fg, | ||
| 986 | .kind = Instance.glyph, | ||
| 987 | }); | ||
| 988 | } | ||
| 989 | } | ||
| 990 | |||
| 991 | pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance { | ||
| 992 | return .{ | ||
| 993 | .x = ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)), | ||
| 994 | .y = ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)), | ||
| 995 | .w = @floatFromInt(ctx.cell_w), | ||
| 996 | .h = @floatFromInt(ctx.cell_h), | ||
| 997 | .u0 = 0, | ||
| 998 | .v0 = 0, | ||
| 999 | .u1 = 0, | ||
| 1000 | .v1 = 0, | ||
| 1001 | .rgba = ctx.default_fg, | ||
| 1002 | .kind = Instance.solid, | ||
| 1003 | }; | ||
| 1004 | } | ||
| 1005 | ``` | ||
| 1006 | |||
| 1007 | - [ ] **Step 4: Run the tests** | ||
| 1008 | |||
| 1009 | Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?` | ||
| 1010 | Expected: rc 0. If `grid.Row.text` is not an `ArrayListUnmanaged(u8)` on the landed branch, adapt `rowOf` to the landed type — the production code reads it only through `row.textOf`. | ||
| 1011 | |||
| 1012 | - [ ] **Step 5: Commit** | ||
| 1013 | |||
| 1014 | ```sh | ||
| 1015 | make check && git add src/gui/quads.zig src/gui/native.zig | ||
| 1016 | git commit -m "feat: grid rows become instanced quads for the native client" | ||
| 1017 | ``` | ||
| 1018 | |||
| 1019 | --- | ||
| 1020 | |||
| 1021 | ### Task 5: `font.zig` — fontconfig and freetype | ||
| 1022 | |||
| 1023 | **Files:** | ||
| 1024 | - Create: `src/gui/font.zig` | ||
| 1025 | - Modify: `src/gui/native.zig` (re-export + test reach) | ||
| 1026 | |||
| 1027 | **Interfaces:** | ||
| 1028 | - Produces: | ||
| 1029 | - `pub const Glyph = struct { w: u16, h: u16, left: i16, top: i16, pixels: []u8, pub fn deinit(self: *Glyph, alloc) void }` | ||
| 1030 | - `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; }` | ||
| 1031 | - `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). | ||
| 1032 | |||
| 1033 | - [ ] **Step 1: Write the failing tests** | ||
| 1034 | |||
| 1035 | Create `src/gui/font.zig` with: | ||
| 1036 | |||
| 1037 | ```zig | ||
| 1038 | const std = @import("std"); | ||
| 1039 | |||
| 1040 | const c = @cImport({ | ||
| 1041 | @cInclude("fontconfig/fontconfig.h"); | ||
| 1042 | @cInclude("ft2build.h"); | ||
| 1043 | @cInclude("freetype/freetype.h"); | ||
| 1044 | }); | ||
| 1045 | |||
| 1046 | // --- tests --- | ||
| 1047 | |||
| 1048 | test "the system monospace face measures a cell and renders a glyph" { | ||
| 1049 | var face = try Face.open(16); | ||
| 1050 | defer face.deinit(); | ||
| 1051 | try std.testing.expect(face.cell_w > 0 and face.cell_w < 64); | ||
| 1052 | try std.testing.expect(face.cell_h >= 16 and face.cell_h < 64); | ||
| 1053 | try std.testing.expect(face.ascent > 0 and face.ascent <= face.cell_h); | ||
| 1054 | var g = try face.render(std.testing.allocator, 'A'); | ||
| 1055 | defer g.deinit(std.testing.allocator); | ||
| 1056 | try std.testing.expect(g.w > 0 and g.h > 0); | ||
| 1057 | try std.testing.expectEqual(@as(usize, g.w) * g.h, g.pixels.len); | ||
| 1058 | var ink: usize = 0; | ||
| 1059 | for (g.pixels) |p| ink += @intFromBool(p > 0); | ||
| 1060 | try std.testing.expect(ink > 0); | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | test "a space renders empty, a missing codepoint renders the box, not an error" { | ||
| 1064 | var face = try Face.open(16); | ||
| 1065 | defer face.deinit(); | ||
| 1066 | var sp = try face.render(std.testing.allocator, ' '); | ||
| 1067 | defer sp.deinit(std.testing.allocator); | ||
| 1068 | var ink: usize = 0; | ||
| 1069 | for (sp.pixels) |p| ink += @intFromBool(p > 0); | ||
| 1070 | try std.testing.expectEqual(@as(usize, 0), ink); | ||
| 1071 | var box = try face.render(std.testing.allocator, 0x10FFFD); | ||
| 1072 | defer box.deinit(std.testing.allocator); | ||
| 1073 | _ = box.w; | ||
| 1074 | } | ||
| 1075 | ``` | ||
| 1076 | |||
| 1077 | - [ ] **Step 2: Run to verify they fail** | ||
| 1078 | |||
| 1079 | Add `pub const font = @import("font.zig");` and `_ = font;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5` | ||
| 1080 | Expected: compile error, `Face` not found. | ||
| 1081 | |||
| 1082 | - [ ] **Step 3: Implement** | ||
| 1083 | |||
| 1084 | Insert between the imports and `// --- tests ---`: | ||
| 1085 | |||
| 1086 | ```zig | ||
| 1087 | //! The face and its glyphs: fontconfig chooses the system monospace face, | ||
| 1088 | //! freetype rasterises one codepoint at a time to 8-bit coverage. The cell | ||
| 1089 | //! is the face's advance by its line height, in whole pixels. One face, | ||
| 1090 | //! no fallback chain, no colour emoji (deferred): a codepoint the face | ||
| 1091 | //! lacks renders as its missing-glyph box, which is honest and visible. | ||
| 1092 | |||
| 1093 | pub const Glyph = struct { | ||
| 1094 | w: u16, | ||
| 1095 | h: u16, | ||
| 1096 | left: i16, | ||
| 1097 | top: i16, | ||
| 1098 | pixels: []u8, | ||
| 1099 | |||
| 1100 | pub fn deinit(self: *Glyph, alloc: std.mem.Allocator) void { | ||
| 1101 | alloc.free(self.pixels); | ||
| 1102 | } | ||
| 1103 | }; | ||
| 1104 | |||
| 1105 | pub const Face = struct { | ||
| 1106 | lib: c.FT_Library, | ||
| 1107 | face: c.FT_Face, | ||
| 1108 | cell_w: u16, | ||
| 1109 | cell_h: u16, | ||
| 1110 | ascent: u16, | ||
| 1111 | |||
| 1112 | pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, GlyphLoad, OutOfMemory }; | ||
| 1113 | |||
| 1114 | /// The path and index fontconfig answers for `monospace`, into `buf`. | ||
| 1115 | fn matchMonospace(buf: *[std.fs.max_path_bytes]u8, index_out: *c_int) Error![]const u8 { | ||
| 1116 | if (c.FcInit() == c.FcFalse) return error.NoFontconfig; | ||
| 1117 | const pat = c.FcNameParse("monospace") orelse return error.NoMonospaceFace; | ||
| 1118 | defer c.FcPatternDestroy(pat); | ||
| 1119 | _ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern); | ||
| 1120 | c.FcDefaultSubstitute(pat); | ||
| 1121 | var result: c.FcResult = undefined; | ||
| 1122 | const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace; | ||
| 1123 | defer c.FcPatternDestroy(found); | ||
| 1124 | var file: [*c]c.FcChar8 = null; | ||
| 1125 | if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace; | ||
| 1126 | var index: c_int = 0; | ||
| 1127 | if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, &index) != c.FcResultMatch) index = 0; | ||
| 1128 | index_out.* = index; | ||
| 1129 | const path = std.mem.span(@as([*:0]const u8, @ptrCast(file))); | ||
| 1130 | if (path.len >= buf.len) return error.NoMonospaceFace; | ||
| 1131 | @memcpy(buf[0..path.len], path); | ||
| 1132 | buf[path.len] = 0; | ||
| 1133 | return buf[0..path.len]; | ||
| 1134 | } | ||
| 1135 | |||
| 1136 | pub fn open(px: u16) Error!Face { | ||
| 1137 | var lib: c.FT_Library = null; | ||
| 1138 | if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit; | ||
| 1139 | errdefer _ = c.FT_Done_FreeType(lib); | ||
| 1140 | var path_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1141 | var index: c_int = 0; | ||
| 1142 | const path = try matchMonospace(&path_buf, &index); | ||
| 1143 | var face: c.FT_Face = null; | ||
| 1144 | if (c.FT_New_Face(lib, @ptrCast(path.ptr), index, &face) != 0) return error.FaceLoad; | ||
| 1145 | errdefer _ = c.FT_Done_Face(face); | ||
| 1146 | if (c.FT_Set_Pixel_Sizes(face, 0, px) != 0) return error.SizeSet; | ||
| 1147 | const m = face.*.size.*.metrics; | ||
| 1148 | // 26.6 fixed point; round up so no glyph is clipped at the cell edge. | ||
| 1149 | const adv: i64 = @intCast((m.max_advance + 63) >> 6); | ||
| 1150 | const height: i64 = @intCast((m.height + 63) >> 6); | ||
| 1151 | const asc: i64 = @intCast((m.ascender + 63) >> 6); | ||
| 1152 | return .{ | ||
| 1153 | .lib = lib, | ||
| 1154 | .face = face, | ||
| 1155 | .cell_w = @intCast(@max(adv, 1)), | ||
| 1156 | .cell_h = @intCast(@max(height, 1)), | ||
| 1157 | .ascent = @intCast(std.math.clamp(asc, 1, @max(height, 1))), | ||
| 1158 | }; | ||
| 1159 | } | ||
| 1160 | |||
| 1161 | pub fn deinit(self: *Face) void { | ||
| 1162 | _ = c.FT_Done_Face(self.face); | ||
| 1163 | _ = c.FT_Done_FreeType(self.lib); | ||
| 1164 | } | ||
| 1165 | |||
| 1166 | pub fn render(self: *Face, alloc: std.mem.Allocator, cp: u21) Error!Glyph { | ||
| 1167 | var idx = c.FT_Get_Char_Index(self.face, cp); | ||
| 1168 | if (idx == 0) idx = 0; // glyph 0 IS the missing-glyph box | ||
| 1169 | if (c.FT_Load_Glyph(self.face, idx, c.FT_LOAD_RENDER) != 0) return error.GlyphLoad; | ||
| 1170 | const slot = self.face.*.glyph; | ||
| 1171 | const bm = slot.*.bitmap; | ||
| 1172 | const w: u16 = @intCast(bm.width); | ||
| 1173 | const h: u16 = @intCast(bm.rows); | ||
| 1174 | const pixels = try alloc.alloc(u8, @as(usize, w) * h); | ||
| 1175 | var row: usize = 0; | ||
| 1176 | while (row < h) : (row += 1) { | ||
| 1177 | const src: [*]const u8 = @ptrCast(bm.buffer + @as(usize, @intCast(bm.pitch)) * row); | ||
| 1178 | @memcpy(pixels[row * w .. row * w + w], src[0..w]); | ||
| 1179 | } | ||
| 1180 | return .{ | ||
| 1181 | .w = w, | ||
| 1182 | .h = h, | ||
| 1183 | .left = @intCast(slot.*.bitmap_left), | ||
| 1184 | .top = @intCast(slot.*.bitmap_top), | ||
| 1185 | .pixels = pixels, | ||
| 1186 | }; | ||
| 1187 | } | ||
| 1188 | }; | ||
| 1189 | ``` | ||
| 1190 | |||
| 1191 | `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. | ||
| 1192 | |||
| 1193 | - [ ] **Step 4: Run the tests** | ||
| 1194 | |||
| 1195 | Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?` | ||
| 1196 | 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`. | ||
| 1197 | |||
| 1198 | - [ ] **Step 5: Commit** | ||
| 1199 | |||
| 1200 | ```sh | ||
| 1201 | make check && git add src/gui/font.zig src/gui/native.zig | ||
| 1202 | git commit -m "feat: the native client's face and glyph rasteriser" | ||
| 1203 | ``` | ||
| 1204 | |||
| 1205 | --- | ||
| 1206 | |||
| 1207 | ### Task 6: `gl.zig` — the GL 3.3 renderer | ||
| 1208 | |||
| 1209 | **Files:** | ||
| 1210 | - Create: `src/gui/gl.zig` | ||
| 1211 | - Modify: `src/gui/native.zig` (re-export + test reach) | ||
| 1212 | |||
| 1213 | **Interfaces:** | ||
| 1214 | - Consumes: `quads.Instance`, `atlas.Atlas`. | ||
| 1215 | - Produces: | ||
| 1216 | - `pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque` | ||
| 1217 | - `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; }` | ||
| 1218 | - `uploadAtlas` re-uploads the whole texture when `a.dirty` and clears it. `draw` clears to `clear_rgba`, sets the viewport, draws `count` instances. | ||
| 1219 | |||
| 1220 | - [ ] **Step 1: Write the one test that runs without a context** | ||
| 1221 | |||
| 1222 | Create `src/gui/gl.zig` with the imports and, at the bottom: | ||
| 1223 | |||
| 1224 | ```zig | ||
| 1225 | test "the loader names every function it needs and refuses a missing one" { | ||
| 1226 | const Stub = struct { | ||
| 1227 | var missing: []const u8 = ""; | ||
| 1228 | fn get(name: [*:0]const u8) callconv(.c) ?*anyopaque { | ||
| 1229 | if (std.mem.eql(u8, std.mem.span(name), missing)) return null; | ||
| 1230 | return @ptrCast(@constCast(&get)); | ||
| 1231 | } | ||
| 1232 | }; | ||
| 1233 | Stub.missing = "glDrawArraysInstanced"; | ||
| 1234 | try std.testing.expectError(error.MissingGlFunction, Fns.load(Stub.get)); | ||
| 1235 | Stub.missing = ""; | ||
| 1236 | _ = try Fns.load(Stub.get); | ||
| 1237 | } | ||
| 1238 | ``` | ||
| 1239 | |||
| 1240 | - [ ] **Step 2: Run to verify it fails** | ||
| 1241 | |||
| 1242 | Add `pub const gl = @import("gl.zig");` and `_ = gl;` to `native.zig`. Run: `deps/zig/zig build native-test 2>&1 | tail -5` | ||
| 1243 | Expected: compile error, `Fns` not found. | ||
| 1244 | |||
| 1245 | - [ ] **Step 3: Implement** | ||
| 1246 | |||
| 1247 | `src/gui/gl.zig` above the test: | ||
| 1248 | |||
| 1249 | ```zig | ||
| 1250 | //! OpenGL 3.3 core, loaded by name: one program, one vertex array, one | ||
| 1251 | //! instance buffer, one R8 atlas texture, one instanced draw. Every GL | ||
| 1252 | //! function is a pointer fetched through the loader the window layer hands | ||
| 1253 | //! in, so this file names no windowing library and links no libGL. The | ||
| 1254 | //! quad is built in the vertex shader from gl_VertexID; the instance | ||
| 1255 | //! carries its rectangle, its atlas rectangle, its colour and its kind. | ||
| 1256 | const std = @import("std"); | ||
| 1257 | const quads = @import("quads.zig"); | ||
| 1258 | const atlas = @import("atlas.zig"); | ||
| 1259 | |||
| 1260 | const c = @cImport({ | ||
| 1261 | @cInclude("GL/glcorearb.h"); | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | pub const GetProc = *const fn (name: [*:0]const u8) callconv(.c) ?*anyopaque; | ||
| 1265 | |||
| 1266 | /// The function table. Field names are the GL names, so `load` can fetch | ||
| 1267 | /// each by reflection and a missing one is an error by name. | ||
| 1268 | pub const Fns = struct { | ||
| 1269 | glGetString: c.PFNGLGETSTRINGPROC, | ||
| 1270 | glCreateShader: c.PFNGLCREATESHADERPROC, | ||
| 1271 | glShaderSource: c.PFNGLSHADERSOURCEPROC, | ||
| 1272 | glCompileShader: c.PFNGLCOMPILESHADERPROC, | ||
| 1273 | glGetShaderiv: c.PFNGLGETSHADERIVPROC, | ||
| 1274 | glGetShaderInfoLog: c.PFNGLGETSHADERINFOLOGPROC, | ||
| 1275 | glCreateProgram: c.PFNGLCREATEPROGRAMPROC, | ||
| 1276 | glAttachShader: c.PFNGLATTACHSHADERPROC, | ||
| 1277 | glLinkProgram: c.PFNGLLINKPROGRAMPROC, | ||
| 1278 | glGetProgramiv: c.PFNGLGETPROGRAMIVPROC, | ||
| 1279 | glGetProgramInfoLog: c.PFNGLGETPROGRAMINFOLOGPROC, | ||
| 1280 | glUseProgram: c.PFNGLUSEPROGRAMPROC, | ||
| 1281 | glDeleteShader: c.PFNGLDELETESHADERPROC, | ||
| 1282 | glDeleteProgram: c.PFNGLDELETEPROGRAMPROC, | ||
| 1283 | glGetUniformLocation: c.PFNGLGETUNIFORMLOCATIONPROC, | ||
| 1284 | glUniform2f: c.PFNGLUNIFORM2FPROC, | ||
| 1285 | glUniform1i: c.PFNGLUNIFORM1IPROC, | ||
| 1286 | glGenVertexArrays: c.PFNGLGENVERTEXARRAYSPROC, | ||
| 1287 | glBindVertexArray: c.PFNGLBINDVERTEXARRAYPROC, | ||
| 1288 | glDeleteVertexArrays: c.PFNGLDELETEVERTEXARRAYSPROC, | ||
| 1289 | glGenBuffers: c.PFNGLGENBUFFERSPROC, | ||
| 1290 | glBindBuffer: c.PFNGLBINDBUFFERPROC, | ||
| 1291 | glBufferData: c.PFNGLBUFFERDATAPROC, | ||
| 1292 | glDeleteBuffers: c.PFNGLDELETEBUFFERSPROC, | ||
| 1293 | glEnableVertexAttribArray: c.PFNGLENABLEVERTEXATTRIBARRAYPROC, | ||
| 1294 | glVertexAttribPointer: c.PFNGLVERTEXATTRIBPOINTERPROC, | ||
| 1295 | glVertexAttribIPointer: c.PFNGLVERTEXATTRIBIPOINTERPROC, | ||
| 1296 | glVertexAttribDivisor: c.PFNGLVERTEXATTRIBDIVISORPROC, | ||
| 1297 | glGenTextures: c.PFNGLGENTEXTURESPROC, | ||
| 1298 | glBindTexture: c.PFNGLBINDTEXTUREPROC, | ||
| 1299 | glDeleteTextures: c.PFNGLDELETETEXTURESPROC, | ||
| 1300 | glTexImage2D: c.PFNGLTEXIMAGE2DPROC, | ||
| 1301 | glTexParameteri: c.PFNGLTEXPARAMETERIPROC, | ||
| 1302 | glPixelStorei: c.PFNGLPIXELSTOREIPROC, | ||
| 1303 | glActiveTexture: c.PFNGLACTIVETEXTUREPROC, | ||
| 1304 | glViewport: c.PFNGLVIEWPORTPROC, | ||
| 1305 | glClearColor: c.PFNGLCLEARCOLORPROC, | ||
| 1306 | glClear: c.PFNGLCLEARPROC, | ||
| 1307 | glEnable: c.PFNGLENABLEPROC, | ||
| 1308 | glBlendFunc: c.PFNGLBLENDFUNCPROC, | ||
| 1309 | glDrawArraysInstanced: c.PFNGLDRAWARRAYSINSTANCEDPROC, | ||
| 1310 | |||
| 1311 | pub fn load(get: GetProc) error{MissingGlFunction}!Fns { | ||
| 1312 | var f: Fns = undefined; | ||
| 1313 | inline for (std.meta.fields(Fns)) |field| { | ||
| 1314 | const p = get(field.name ++ "") orelse return error.MissingGlFunction; | ||
| 1315 | @field(f, field.name) = @ptrCast(p); | ||
| 1316 | } | ||
| 1317 | return f; | ||
| 1318 | } | ||
| 1319 | }; | ||
| 1320 | |||
| 1321 | const vertex_src: [*:0]const u8 = | ||
| 1322 | \\#version 330 core | ||
| 1323 | \\layout(location = 0) in vec4 rect; // x y w h in pixels | ||
| 1324 | \\layout(location = 1) in vec4 uv; // u0 v0 u1 v1 | ||
| 1325 | \\layout(location = 2) in uint rgba; | ||
| 1326 | \\layout(location = 3) in uint kind; | ||
| 1327 | \\uniform vec2 viewport; | ||
| 1328 | \\out vec2 v_uv; | ||
| 1329 | \\flat out vec4 v_color; | ||
| 1330 | \\flat out uint v_kind; | ||
| 1331 | \\void main() { | ||
| 1332 | \\ vec2 corner = vec2(gl_VertexID & 1, (gl_VertexID >> 1) & 1); | ||
| 1333 | \\ vec2 px = rect.xy + corner * rect.zw; | ||
| 1334 | \\ vec2 ndc = vec2(px.x / viewport.x * 2.0 - 1.0, 1.0 - px.y / viewport.y * 2.0); | ||
| 1335 | \\ gl_Position = vec4(ndc, 0.0, 1.0); | ||
| 1336 | \\ v_uv = mix(uv.xy, uv.zw, corner); | ||
| 1337 | \\ v_color = vec4(float((rgba >> 24u) & 255u), float((rgba >> 16u) & 255u), | ||
| 1338 | \\ float((rgba >> 8u) & 255u), float(rgba & 255u)) / 255.0; | ||
| 1339 | \\ v_kind = kind; | ||
| 1340 | \\} | ||
| 1341 | ; | ||
| 1342 | |||
| 1343 | const fragment_src: [*:0]const u8 = | ||
| 1344 | \\#version 330 core | ||
| 1345 | \\in vec2 v_uv; | ||
| 1346 | \\flat in vec4 v_color; | ||
| 1347 | \\flat in uint v_kind; | ||
| 1348 | \\uniform sampler2D atlas; | ||
| 1349 | \\out vec4 frag; | ||
| 1350 | \\void main() { | ||
| 1351 | \\ float a = (v_kind == 1u) ? texture(atlas, v_uv).r : 1.0; | ||
| 1352 | \\ frag = vec4(v_color.rgb, v_color.a * a); | ||
| 1353 | \\} | ||
| 1354 | ; | ||
| 1355 | |||
| 1356 | pub const Renderer = struct { | ||
| 1357 | f: Fns, | ||
| 1358 | program: c.GLuint, | ||
| 1359 | vao: c.GLuint, | ||
| 1360 | vbo: c.GLuint, | ||
| 1361 | tex: c.GLuint, | ||
| 1362 | u_viewport: c.GLint, | ||
| 1363 | tex_w: u16 = 0, | ||
| 1364 | tex_h: u16 = 0, | ||
| 1365 | |||
| 1366 | pub const Error = error{ MissingGlFunction, ShaderCompile, ProgramLink }; | ||
| 1367 | |||
| 1368 | fn compile(f: *const Fns, kind: c.GLenum, src: [*:0]const u8) Error!c.GLuint { | ||
| 1369 | const sh = f.glCreateShader.?(kind); | ||
| 1370 | f.glShaderSource.?(sh, 1, @ptrCast(&src), null); | ||
| 1371 | f.glCompileShader.?(sh); | ||
| 1372 | var ok: c.GLint = 0; | ||
| 1373 | f.glGetShaderiv.?(sh, c.GL_COMPILE_STATUS, &ok); | ||
| 1374 | if (ok == 0) { | ||
| 1375 | var log: [1024]u8 = undefined; | ||
| 1376 | var n: c.GLsizei = 0; | ||
| 1377 | f.glGetShaderInfoLog.?(sh, log.len, &n, &log); | ||
| 1378 | std.debug.print("muxg: shader: {s}\n", .{log[0..@intCast(n)]}); | ||
| 1379 | return error.ShaderCompile; | ||
| 1380 | } | ||
| 1381 | return sh; | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | pub fn init(get: GetProc) Error!Renderer { | ||
| 1385 | const f = try Fns.load(get); | ||
| 1386 | const vs = try compile(&f, c.GL_VERTEX_SHADER, vertex_src); | ||
| 1387 | defer f.glDeleteShader.?(vs); | ||
| 1388 | const fs = try compile(&f, c.GL_FRAGMENT_SHADER, fragment_src); | ||
| 1389 | defer f.glDeleteShader.?(fs); | ||
| 1390 | const prog = f.glCreateProgram.?(); | ||
| 1391 | f.glAttachShader.?(prog, vs); | ||
| 1392 | f.glAttachShader.?(prog, fs); | ||
| 1393 | f.glLinkProgram.?(prog); | ||
| 1394 | var ok: c.GLint = 0; | ||
| 1395 | f.glGetProgramiv.?(prog, c.GL_LINK_STATUS, &ok); | ||
| 1396 | if (ok == 0) { | ||
| 1397 | var log: [1024]u8 = undefined; | ||
| 1398 | var n: c.GLsizei = 0; | ||
| 1399 | f.glGetProgramInfoLog.?(prog, log.len, &n, &log); | ||
| 1400 | std.debug.print("muxg: program: {s}\n", .{log[0..@intCast(n)]}); | ||
| 1401 | return error.ProgramLink; | ||
| 1402 | } | ||
| 1403 | |||
| 1404 | var vao: c.GLuint = 0; | ||
| 1405 | f.glGenVertexArrays.?(1, &vao); | ||
| 1406 | f.glBindVertexArray.?(vao); | ||
| 1407 | var vbo: c.GLuint = 0; | ||
| 1408 | f.glGenBuffers.?(1, &vbo); | ||
| 1409 | f.glBindBuffer.?(c.GL_ARRAY_BUFFER, vbo); | ||
| 1410 | const stride: c.GLsizei = @sizeOf(quads.Instance); | ||
| 1411 | f.glEnableVertexAttribArray.?(0); | ||
| 1412 | f.glVertexAttribPointer.?(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "x"))); | ||
| 1413 | f.glVertexAttribDivisor.?(0, 1); | ||
| 1414 | f.glEnableVertexAttribArray.?(1); | ||
| 1415 | f.glVertexAttribPointer.?(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(quads.Instance, "u0"))); | ||
| 1416 | f.glVertexAttribDivisor.?(1, 1); | ||
| 1417 | f.glEnableVertexAttribArray.?(2); | ||
| 1418 | f.glVertexAttribIPointer.?(2, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "rgba"))); | ||
| 1419 | f.glVertexAttribDivisor.?(2, 1); | ||
| 1420 | f.glEnableVertexAttribArray.?(3); | ||
| 1421 | f.glVertexAttribIPointer.?(3, 1, c.GL_UNSIGNED_INT, stride, @ptrFromInt(@offsetOf(quads.Instance, "kind"))); | ||
| 1422 | f.glVertexAttribDivisor.?(3, 1); | ||
| 1423 | |||
| 1424 | var tex: c.GLuint = 0; | ||
| 1425 | f.glGenTextures.?(1, &tex); | ||
| 1426 | f.glBindTexture.?(c.GL_TEXTURE_2D, tex); | ||
| 1427 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_NEAREST); | ||
| 1428 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_NEAREST); | ||
| 1429 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); | ||
| 1430 | f.glTexParameteri.?(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); | ||
| 1431 | f.glPixelStorei.?(c.GL_UNPACK_ALIGNMENT, 1); | ||
| 1432 | |||
| 1433 | f.glUseProgram.?(prog); | ||
| 1434 | f.glUniform1i.?(f.glGetUniformLocation.?(prog, "atlas"), 0); | ||
| 1435 | f.glEnable.?(c.GL_BLEND); | ||
| 1436 | f.glBlendFunc.?(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA); | ||
| 1437 | |||
| 1438 | return .{ | ||
| 1439 | .f = f, | ||
| 1440 | .program = prog, | ||
| 1441 | .vao = vao, | ||
| 1442 | .vbo = vbo, | ||
| 1443 | .tex = tex, | ||
| 1444 | .u_viewport = f.glGetUniformLocation.?(prog, "viewport"), | ||
| 1445 | }; | ||
| 1446 | } | ||
| 1447 | |||
| 1448 | pub fn deinit(self: *Renderer) void { | ||
| 1449 | self.f.glDeleteTextures.?(1, &self.tex); | ||
| 1450 | self.f.glDeleteBuffers.?(1, &self.vbo); | ||
| 1451 | self.f.glDeleteVertexArrays.?(1, &self.vao); | ||
| 1452 | self.f.glDeleteProgram.?(self.program); | ||
| 1453 | } | ||
| 1454 | |||
| 1455 | /// The whole texture, only when the atlas says it changed. A whole | ||
| 1456 | /// re-upload on a new glyph is deliberate for v1: the timing table's | ||
| 1457 | /// atlas_upload row says whether an incremental path is ever needed. | ||
| 1458 | pub fn uploadAtlas(self: *Renderer, a: *atlas.Atlas) void { | ||
| 1459 | if (!a.dirty) return; | ||
| 1460 | self.f.glActiveTexture.?(c.GL_TEXTURE0); | ||
| 1461 | self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex); | ||
| 1462 | 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); | ||
| 1463 | self.tex_w = a.width; | ||
| 1464 | self.tex_h = a.height; | ||
| 1465 | a.dirty = false; | ||
| 1466 | } | ||
| 1467 | |||
| 1468 | pub fn uploadInstances(self: *Renderer, items: []const quads.Instance) void { | ||
| 1469 | self.f.glBindBuffer.?(c.GL_ARRAY_BUFFER, self.vbo); | ||
| 1470 | self.f.glBufferData.?(c.GL_ARRAY_BUFFER, @intCast(items.len * @sizeOf(quads.Instance)), items.ptr, c.GL_STREAM_DRAW); | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | pub fn draw(self: *Renderer, count: usize, fb_w: i32, fb_h: i32, clear_rgba: u32) void { | ||
| 1474 | const r: f32 = @as(f32, @floatFromInt((clear_rgba >> 24) & 0xff)) / 255.0; | ||
| 1475 | const g: f32 = @as(f32, @floatFromInt((clear_rgba >> 16) & 0xff)) / 255.0; | ||
| 1476 | const b: f32 = @as(f32, @floatFromInt((clear_rgba >> 8) & 0xff)) / 255.0; | ||
| 1477 | self.f.glViewport.?(0, 0, fb_w, fb_h); | ||
| 1478 | self.f.glClearColor.?(r, g, b, 1.0); | ||
| 1479 | self.f.glClear.?(c.GL_COLOR_BUFFER_BIT); | ||
| 1480 | self.f.glUseProgram.?(self.program); | ||
| 1481 | self.f.glUniform2f.?(self.u_viewport, @floatFromInt(fb_w), @floatFromInt(fb_h)); | ||
| 1482 | self.f.glBindVertexArray.?(self.vao); | ||
| 1483 | self.f.glActiveTexture.?(c.GL_TEXTURE0); | ||
| 1484 | self.f.glBindTexture.?(c.GL_TEXTURE_2D, self.tex); | ||
| 1485 | self.f.glDrawArraysInstanced.?(c.GL_TRIANGLE_STRIP, 0, 4, @intCast(count)); | ||
| 1486 | } | ||
| 1487 | }; | ||
| 1488 | ``` | ||
| 1489 | |||
| 1490 | - [ ] **Step 4: Run the tests** | ||
| 1491 | |||
| 1492 | Run: `deps/zig/zig build native-test 2>&1 | tail -8; echo rc=$?` | ||
| 1493 | 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. | ||
| 1494 | |||
| 1495 | - [ ] **Step 5: Commit** | ||
| 1496 | |||
| 1497 | ```sh | ||
| 1498 | make check && git add src/gui/gl.zig src/gui/native.zig | ||
| 1499 | git commit -m "feat: the native client's GL 3.3 instanced renderer" | ||
| 1500 | ``` | ||
| 1501 | |||
| 1502 | --- | ||
| 1503 | |||
| 1504 | ### Task 7: `session_pump.zig` — the transport-owning thread | ||
| 1505 | |||
| 1506 | **Files:** | ||
| 1507 | - Create: `src/client/session_pump.zig` | ||
| 1508 | - 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");`) | ||
| 1509 | |||
| 1510 | **Interfaces:** | ||
| 1511 | - 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`. | ||
| 1512 | - Produces: | ||
| 1513 | - `pub const Say = union(enum) { input: []const u8, resize: struct { cols: u16, rows: u16 }, detach, quit }` — `input` bytes are COPIED by `say`. | ||
| 1514 | - `pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed }` | ||
| 1515 | - `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` | ||
| 1516 | - `pub const Options = struct { target: client.Target, session: []const u8, cols: u16, rows: u16, wake: *const fn (ctx: *anyopaque) void, wake_ctx: *anyopaque }` | ||
| 1517 | - `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; }` | ||
| 1518 | - 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. | ||
| 1519 | |||
| 1520 | - [ ] **Step 1: Write the failing tests (mailbox and doorbell only; the thread is graded end to end)** | ||
| 1521 | |||
| 1522 | Create `src/client/session_pump.zig` with the imports and, at the bottom: | ||
| 1523 | |||
| 1524 | ```zig | ||
| 1525 | test "the mailbox keeps order, copies input and rings once per post" { | ||
| 1526 | const alloc = std.testing.allocator; | ||
| 1527 | var mb = try Mailbox.init(alloc); | ||
| 1528 | defer mb.deinit(); | ||
| 1529 | var typed = [_]u8{ 'h', 'i' }; | ||
| 1530 | try mb.post(.{ .input = &typed }); | ||
| 1531 | typed[0] = 'X'; // the mailbox copied, so this cannot reach the frame | ||
| 1532 | try mb.post(.{ .resize = .{ .cols = 100, .rows = 40 } }); | ||
| 1533 | try mb.post(.detach); | ||
| 1534 | var ring: [8]u8 = undefined; | ||
| 1535 | const n = std.posix.read(mb.wake_r, &ring) catch 0; | ||
| 1536 | try std.testing.expect(n >= 1); | ||
| 1537 | var items: std.ArrayListUnmanaged(Say) = .empty; | ||
| 1538 | defer { | ||
| 1539 | for (items.items) |it| mb.free(it); | ||
| 1540 | items.deinit(alloc); | ||
| 1541 | } | ||
| 1542 | try mb.drain(&items); | ||
| 1543 | try std.testing.expectEqual(@as(usize, 3), items.items.len); | ||
| 1544 | try std.testing.expectEqualStrings("hi", items.items[0].input); | ||
| 1545 | try std.testing.expectEqual(@as(u16, 100), items.items[1].resize.cols); | ||
| 1546 | try std.testing.expect(items.items[2] == .detach); | ||
| 1547 | } | ||
| 1548 | |||
| 1549 | test "state is read as a copy under the lock" { | ||
| 1550 | var p: Pump = undefined; | ||
| 1551 | p.state_mu = .{}; | ||
| 1552 | p.state_val = .{}; | ||
| 1553 | p.setPhase(.refused, "no such session"); | ||
| 1554 | const s = p.state(); | ||
| 1555 | try std.testing.expectEqual(Phase.refused, s.phase); | ||
| 1556 | try std.testing.expectEqualStrings("no such session", s.reasonText()); | ||
| 1557 | } | ||
| 1558 | ``` | ||
| 1559 | |||
| 1560 | - [ ] **Step 2: Run to verify they fail** | ||
| 1561 | |||
| 1562 | Run: `deps/zig/zig build test 2>&1 | tail -5` — `client` is in `test_order`, so `make test` runs this file's tests. | ||
| 1563 | Expected: compile error, `Mailbox` not found. | ||
| 1564 | |||
| 1565 | - [ ] **Step 3: Implement** | ||
| 1566 | |||
| 1567 | `src/client/session_pump.zig` above the tests: | ||
| 1568 | |||
| 1569 | ```zig | ||
| 1570 | //! One thread that owns a transport from dial to close: it attaches to ONE | ||
| 1571 | //! session, reads frames, applies them to a Replica it owns under a mutex, | ||
| 1572 | //! feeds every non-replay frame to the client core, and rings a wake | ||
| 1573 | //! callback once per pass that changed anything. The window thread never | ||
| 1574 | //! touches the transport: it posts to the mailbox and rings the doorbell. | ||
| 1575 | //! | ||
| 1576 | //! What it does NOT handle is the point: no agent channels, no births, no | ||
| 1577 | //! end arms, no session lists. A viewer asks none of those questions. | ||
| 1578 | //! | ||
| 1579 | //! This is the third terminal-free attach loop, after webhub.pumpTile and | ||
| 1580 | //! mux a's. pumpTile is the first candidate to move onto it; it is not | ||
| 1581 | //! moved here. | ||
| 1582 | const std = @import("std"); | ||
| 1583 | const proto = @import("term").protocol; | ||
| 1584 | const term = @import("term"); | ||
| 1585 | const client = @import("client.zig"); | ||
| 1586 | const core_mod = @import("client_core.zig"); | ||
| 1587 | |||
| 1588 | const Replica = term.replica.Replica; | ||
| 1589 | const Grid = term.grid.Grid; | ||
| 1590 | |||
| 1591 | pub const Say = union(enum) { | ||
| 1592 | input: []const u8, | ||
| 1593 | resize: struct { cols: u16, rows: u16 }, | ||
| 1594 | detach, | ||
| 1595 | quit, | ||
| 1596 | }; | ||
| 1597 | |||
| 1598 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed }; | ||
| 1599 | |||
| 1600 | pub const State = struct { | ||
| 1601 | phase: Phase = .dialing, | ||
| 1602 | exit_code: u8 = 0, | ||
| 1603 | /// Set by the pump, cleared by whoever shows it. | ||
| 1604 | bell: bool = false, | ||
| 1605 | reason: [128]u8 = undefined, | ||
| 1606 | reason_len: u8 = 0, | ||
| 1607 | |||
| 1608 | pub fn reasonText(self: *const State) []const u8 { | ||
| 1609 | return self.reason[0..self.reason_len]; | ||
| 1610 | } | ||
| 1611 | }; | ||
| 1612 | |||
| 1613 | pub const Mailbox = struct { | ||
| 1614 | alloc: std.mem.Allocator, | ||
| 1615 | mu: std.Thread.Mutex = .{}, | ||
| 1616 | items: std.ArrayListUnmanaged(Say) = .empty, | ||
| 1617 | wake_r: std.posix.fd_t, | ||
| 1618 | wake_w: std.posix.fd_t, | ||
| 1619 | |||
| 1620 | pub fn init(alloc: std.mem.Allocator) !Mailbox { | ||
| 1621 | const p = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1622 | return .{ .alloc = alloc, .wake_r = p[0], .wake_w = p[1] }; | ||
| 1623 | } | ||
| 1624 | |||
| 1625 | pub fn deinit(self: *Mailbox) void { | ||
| 1626 | for (self.items.items) |it| self.free(it); | ||
| 1627 | self.items.deinit(self.alloc); | ||
| 1628 | std.posix.close(self.wake_r); | ||
| 1629 | std.posix.close(self.wake_w); | ||
| 1630 | } | ||
| 1631 | |||
| 1632 | pub fn free(self: *Mailbox, s: Say) void { | ||
| 1633 | switch (s) { | ||
| 1634 | .input => |b| self.alloc.free(b), | ||
| 1635 | else => {}, | ||
| 1636 | } | ||
| 1637 | } | ||
| 1638 | |||
| 1639 | /// Copies `input` bytes; rings the doorbell. A full pipe is fine: one | ||
| 1640 | /// byte in it is one wake, and the pump drains everything per wake. | ||
| 1641 | pub fn post(self: *Mailbox, s: Say) !void { | ||
| 1642 | const owned: Say = switch (s) { | ||
| 1643 | .input => |b| .{ .input = try self.alloc.dupe(u8, b) }, | ||
| 1644 | else => s, | ||
| 1645 | }; | ||
| 1646 | { | ||
| 1647 | self.mu.lock(); | ||
| 1648 | defer self.mu.unlock(); | ||
| 1649 | try self.items.append(self.alloc, owned); | ||
| 1650 | } | ||
| 1651 | _ = std.posix.write(self.wake_w, "w") catch {}; | ||
| 1652 | } | ||
| 1653 | |||
| 1654 | /// Moves every pending item into `into`; the caller frees them. | ||
| 1655 | pub fn drain(self: *Mailbox, into: *std.ArrayListUnmanaged(Say)) !void { | ||
| 1656 | self.mu.lock(); | ||
| 1657 | defer self.mu.unlock(); | ||
| 1658 | try into.appendSlice(self.alloc, self.items.items); | ||
| 1659 | self.items.clearRetainingCapacity(); | ||
| 1660 | } | ||
| 1661 | |||
| 1662 | fn drainBell(self: *Mailbox) void { | ||
| 1663 | var buf: [64]u8 = undefined; | ||
| 1664 | while (true) { | ||
| 1665 | const n = std.posix.read(self.wake_r, &buf) catch return; | ||
| 1666 | if (n < buf.len) return; | ||
| 1667 | } | ||
| 1668 | } | ||
| 1669 | }; | ||
| 1670 | |||
| 1671 | pub const Options = struct { | ||
| 1672 | target: client.Target, | ||
| 1673 | session: []const u8, | ||
| 1674 | cols: u16, | ||
| 1675 | rows: u16, | ||
| 1676 | wake: *const fn (ctx: *anyopaque) void, | ||
| 1677 | wake_ctx: *anyopaque, | ||
| 1678 | }; | ||
| 1679 | |||
| 1680 | pub const Pump = struct { | ||
| 1681 | alloc: std.mem.Allocator, | ||
| 1682 | opts: Options, | ||
| 1683 | mailbox: Mailbox, | ||
| 1684 | /// Guards `grid`, `replica` and `last_apply_us`. | ||
| 1685 | mu: std.Thread.Mutex = .{}, | ||
| 1686 | grid: *Grid, | ||
| 1687 | replica: Replica, | ||
| 1688 | last_apply_us: u32 = 0, | ||
| 1689 | core: core_mod.ClientCore = .{}, | ||
| 1690 | state_mu: std.Thread.Mutex = .{}, | ||
| 1691 | state_val: State = .{}, | ||
| 1692 | /// The size the daemon should hear on the next attach. | ||
| 1693 | cols: u16, | ||
| 1694 | rows: u16, | ||
| 1695 | thread: ?std.Thread = null, | ||
| 1696 | /// Pump-thread only: whether `run`'s transport is open (see `reconnect`). | ||
| 1697 | link_open: bool = false, | ||
| 1698 | |||
| 1699 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | ||
| 1700 | const p = try alloc.create(Pump); | ||
| 1701 | errdefer alloc.destroy(p); | ||
| 1702 | const g = try Grid.init(alloc, @max(opts.cols, 1), @max(opts.rows, 1)); | ||
| 1703 | errdefer g.deinit(); | ||
| 1704 | p.* = .{ | ||
| 1705 | .alloc = alloc, | ||
| 1706 | .opts = opts, | ||
| 1707 | .mailbox = try Mailbox.init(alloc), | ||
| 1708 | .grid = g, | ||
| 1709 | .replica = Replica.init(alloc, g), | ||
| 1710 | .cols = opts.cols, | ||
| 1711 | .rows = opts.rows, | ||
| 1712 | }; | ||
| 1713 | p.thread = try std.Thread.spawn(.{}, run, .{p}); | ||
| 1714 | return p; | ||
| 1715 | } | ||
| 1716 | |||
| 1717 | pub fn say(self: *Pump, s: Say) !void { | ||
| 1718 | try self.mailbox.post(s); | ||
| 1719 | } | ||
| 1720 | |||
| 1721 | pub fn state(self: *Pump) State { | ||
| 1722 | self.state_mu.lock(); | ||
| 1723 | defer self.state_mu.unlock(); | ||
| 1724 | return self.state_val; | ||
| 1725 | } | ||
| 1726 | |||
| 1727 | pub fn stop(self: *Pump) void { | ||
| 1728 | self.mailbox.post(.quit) catch {}; | ||
| 1729 | if (self.thread) |t| t.join(); | ||
| 1730 | self.mailbox.deinit(); | ||
| 1731 | self.grid.deinit(); | ||
| 1732 | self.alloc.destroy(self); | ||
| 1733 | } | ||
| 1734 | |||
| 1735 | fn setPhase(self: *Pump, ph: Phase, reason: []const u8) void { | ||
| 1736 | self.state_mu.lock(); | ||
| 1737 | defer self.state_mu.unlock(); | ||
| 1738 | self.state_val.phase = ph; | ||
| 1739 | const n = @min(reason.len, self.state_val.reason.len); | ||
| 1740 | @memcpy(self.state_val.reason[0..n], reason[0..n]); | ||
| 1741 | self.state_val.reason_len = @intCast(n); | ||
| 1742 | } | ||
| 1743 | |||
| 1744 | fn setExit(self: *Pump, code: u8) void { | ||
| 1745 | self.state_mu.lock(); | ||
| 1746 | defer self.state_mu.unlock(); | ||
| 1747 | self.state_val.phase = .exited; | ||
| 1748 | self.state_val.exit_code = code; | ||
| 1749 | } | ||
| 1750 | |||
| 1751 | fn ringBell(self: *Pump) void { | ||
| 1752 | self.state_mu.lock(); | ||
| 1753 | defer self.state_mu.unlock(); | ||
| 1754 | self.state_val.bell = true; | ||
| 1755 | } | ||
| 1756 | |||
| 1757 | fn wake(self: *Pump) void { | ||
| 1758 | self.opts.wake(self.opts.wake_ctx); | ||
| 1759 | } | ||
| 1760 | |||
| 1761 | fn dial(self: *Pump) ?client.Transport { | ||
| 1762 | var backoff: u64 = 0; | ||
| 1763 | while (true) { | ||
| 1764 | if (client.Transport.open(self.alloc, self.opts.target, null, -1, null)) |tr| { | ||
| 1765 | return tr; | ||
| 1766 | } else |err| { | ||
| 1767 | var buf: [client.open_err_len]u8 = undefined; | ||
| 1768 | const f = client.openFailure(&buf, self.opts.target, err, ""); | ||
| 1769 | self.setPhase(.reconnecting, f.message); | ||
| 1770 | self.wake(); | ||
| 1771 | } | ||
| 1772 | if (self.quitRequested()) return null; | ||
| 1773 | backoff = client.nextBackoffMs(backoff); | ||
| 1774 | std.Thread.sleep(backoff * std.time.ns_per_ms); | ||
| 1775 | if (self.quitRequested()) return null; | ||
| 1776 | } | ||
| 1777 | } | ||
| 1778 | |||
| 1779 | /// True when a quit is waiting; leaves everything else in the mailbox. | ||
| 1780 | fn quitRequested(self: *Pump) bool { | ||
| 1781 | self.mailbox.mu.lock(); | ||
| 1782 | defer self.mailbox.mu.unlock(); | ||
| 1783 | for (self.mailbox.items.items) |it| if (it == .quit) return true; | ||
| 1784 | return false; | ||
| 1785 | } | ||
| 1786 | |||
| 1787 | fn sendAttach(self: *Pump, tr: *client.Transport, fresh: bool) !void { | ||
| 1788 | const args = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.replica.attachArgs(); | ||
| 1789 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 1790 | try tr.writeFrame(.attach, proto.encodeAttachNamed( | ||
| 1791 | &buf, | ||
| 1792 | self.cols, | ||
| 1793 | self.rows, | ||
| 1794 | args.have_seq, | ||
| 1795 | args.have_epoch, | ||
| 1796 | proto.wireName(self.opts.session), | ||
| 1797 | )); | ||
| 1798 | tr.flushQuic(); | ||
| 1799 | } | ||
| 1800 | |||
| 1801 | /// Every pending mailbox item onto the wire. Returns false on quit or | ||
| 1802 | /// detach: the loop ends either way. | ||
| 1803 | fn deliver(self: *Pump, tr: *client.Transport) bool { | ||
| 1804 | self.mailbox.drainBell(); | ||
| 1805 | var items: std.ArrayListUnmanaged(Say) = .empty; | ||
| 1806 | defer { | ||
| 1807 | for (items.items) |it| self.mailbox.free(it); | ||
| 1808 | items.deinit(self.alloc); | ||
| 1809 | } | ||
| 1810 | self.mailbox.drain(&items) catch return false; | ||
| 1811 | for (items.items) |it| { | ||
| 1812 | switch (it) { | ||
| 1813 | .input => |b| tr.writeFrame(.input, b) catch return true, // the read side reports the loss | ||
| 1814 | .resize => |r| { | ||
| 1815 | self.cols = r.cols; | ||
| 1816 | self.rows = r.rows; | ||
| 1817 | tr.writeFrame(.resize, &proto.encodeSize(r.cols, r.rows)) catch return true; | ||
| 1818 | }, | ||
| 1819 | .detach => { | ||
| 1820 | tr.writeFrame(.detach, "") catch {}; | ||
| 1821 | tr.flushQuic(); | ||
| 1822 | return false; | ||
| 1823 | }, | ||
| 1824 | .quit => return false, | ||
| 1825 | } | ||
| 1826 | } | ||
| 1827 | tr.flushQuic(); | ||
| 1828 | return true; | ||
| 1829 | } | ||
| 1830 | |||
| 1831 | /// One frame. Returns true when something the window paints changed. | ||
| 1832 | fn onFrame(self: *Pump, tr: *client.Transport, frame: proto.Frame) !bool { | ||
| 1833 | switch (frame.type) { | ||
| 1834 | .snapshot, .delta => { | ||
| 1835 | var t = try std.time.Timer.start(); | ||
| 1836 | self.mu.lock(); | ||
| 1837 | defer self.mu.unlock(); | ||
| 1838 | const applied = self.replica.apply(frame.type, frame.payload) catch .resync; | ||
| 1839 | self.last_apply_us = @intCast(@min(t.read() / std.time.ns_per_us, std.math.maxInt(u32))); | ||
| 1840 | switch (applied) { | ||
| 1841 | .painted => { | ||
| 1842 | if (self.state().phase != .attached) self.setPhase(.attached, ""); | ||
| 1843 | return true; | ||
| 1844 | }, | ||
| 1845 | .resync => { | ||
| 1846 | // The replica is suspect, not the transport: re-attach | ||
| 1847 | // at (0,0), since a quoted seq invites an unfixable | ||
| 1848 | // delta. The same rule as wall_pump's. | ||
| 1849 | self.replica.state_since_attach = false; | ||
| 1850 | try self.sendAttach(tr, true); | ||
| 1851 | return false; | ||
| 1852 | }, | ||
| 1853 | } | ||
| 1854 | }, | ||
| 1855 | .exit_status => { | ||
| 1856 | // Before any replay frame this is the refusal path; after, | ||
| 1857 | // the session really ended. | ||
| 1858 | if (self.replica.state_since_attach) { | ||
| 1859 | self.setExit(if (frame.payload.len >= 1) frame.payload[0] else 1); | ||
| 1860 | } else { | ||
| 1861 | self.setPhase(.refused, "the daemon refused the attach"); | ||
| 1862 | } | ||
| 1863 | return error.SessionOver; | ||
| 1864 | }, | ||
| 1865 | .taken_over => { | ||
| 1866 | // Unsent by this daemon (wire-compat): treated as an end, as | ||
| 1867 | // the wall does. | ||
| 1868 | self.setPhase(.taken, ""); | ||
| 1869 | return error.SessionOver; | ||
| 1870 | }, | ||
| 1871 | else => { | ||
| 1872 | switch (self.core.receive(frame.type, frame.payload)) { | ||
| 1873 | .effect => |e| switch (e) { | ||
| 1874 | .bell => { | ||
| 1875 | self.ringBell(); | ||
| 1876 | return true; | ||
| 1877 | }, | ||
| 1878 | .clipboard_set => return false, // v1 has no clipboard | ||
| 1879 | }, | ||
| 1880 | else => return false, | ||
| 1881 | } | ||
| 1882 | }, | ||
| 1883 | } | ||
| 1884 | } | ||
| 1885 | |||
| 1886 | fn run(self: *Pump) void { | ||
| 1887 | var tr = self.dial() orelse return; | ||
| 1888 | self.link_open = true; | ||
| 1889 | // `reconnect` closes and reopens; a redial that never came back | ||
| 1890 | // leaves `link_open` false so this does not close twice. | ||
| 1891 | defer if (self.link_open) tr.close(); | ||
| 1892 | tr.adopt(self.alloc); | ||
| 1893 | self.sendAttach(&tr, true) catch { | ||
| 1894 | self.setPhase(.failed, "attach could not be sent"); | ||
| 1895 | self.wake(); | ||
| 1896 | return; | ||
| 1897 | }; | ||
| 1898 | |||
| 1899 | while (true) { | ||
| 1900 | var fds = [2]std.posix.pollfd{ | ||
| 1901 | .{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 1902 | .{ .fd = self.mailbox.wake_r, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 1903 | }; | ||
| 1904 | _ = std.posix.poll(&fds, tr.timeoutMs(100)) catch return; | ||
| 1905 | tr.service(); | ||
| 1906 | |||
| 1907 | if (fds[1].revents != 0) { | ||
| 1908 | if (!self.deliver(&tr)) return; | ||
| 1909 | } | ||
| 1910 | |||
| 1911 | var changed = false; | ||
| 1912 | if (fds[0].revents != 0 or tr.link == .quic) frames: { | ||
| 1913 | while (true) { | ||
| 1914 | const incoming = tr.readFrame(self.alloc) catch { | ||
| 1915 | if (!self.reconnect(&tr)) return; | ||
| 1916 | break :frames; | ||
| 1917 | }; | ||
| 1918 | switch (incoming) { | ||
| 1919 | .incomplete => break :frames, | ||
| 1920 | .closed => { | ||
| 1921 | if (!self.reconnect(&tr)) return; | ||
| 1922 | break :frames; | ||
| 1923 | }, | ||
| 1924 | .frame => |frame| { | ||
| 1925 | defer frame.deinit(self.alloc); | ||
| 1926 | const c = self.onFrame(&tr, frame) catch |err| switch (err) { | ||
| 1927 | error.SessionOver => { | ||
| 1928 | self.wake(); | ||
| 1929 | return; | ||
| 1930 | }, | ||
| 1931 | else => { | ||
| 1932 | if (!self.reconnect(&tr)) return; | ||
| 1933 | break :frames; | ||
| 1934 | }, | ||
| 1935 | }; | ||
| 1936 | changed = changed or c; | ||
| 1937 | }, | ||
| 1938 | } | ||
| 1939 | } | ||
| 1940 | } | ||
| 1941 | if (changed) self.wake(); | ||
| 1942 | } | ||
| 1943 | } | ||
| 1944 | |||
| 1945 | /// The link went away: say so, redial with backoff, re-attach with what | ||
| 1946 | /// the replica already holds. False when a quit arrived meanwhile. | ||
| 1947 | fn reconnect(self: *Pump, tr: *client.Transport) bool { | ||
| 1948 | tr.close(); | ||
| 1949 | self.link_open = false; | ||
| 1950 | self.setPhase(.reconnecting, ""); | ||
| 1951 | self.wake(); | ||
| 1952 | tr.* = self.dial() orelse return false; | ||
| 1953 | self.link_open = true; | ||
| 1954 | tr.adopt(self.alloc); | ||
| 1955 | self.sendAttach(tr, false) catch return false; | ||
| 1956 | return true; | ||
| 1957 | } | ||
| 1958 | }; | ||
| 1959 | ``` | ||
| 1960 | |||
| 1961 | If `client.openFailure` returns a struct whose text field is not named `message`, read `OpenFailure` at `src/client/client.zig` (around line 914) and use its field. If `Transport.close` followed by re-assignment is not how `wall_pump.redial` does it, mirror `redial` (around `src/tui/wall_pump.zig:340-370`). | ||
| 1962 | |||
| 1963 | - [ ] **Step 4: Wire the re-export and run the tests** | ||
| 1964 | |||
| 1965 | 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");`. | ||
| 1966 | |||
| 1967 | 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`). | ||
| 1968 | Expected: rc 0. | ||
| 1969 | |||
| 1970 | - [ ] **Step 5: Commit** | ||
| 1971 | |||
| 1972 | ```sh | ||
| 1973 | make check && git add src/client/session_pump.zig src/client/client.zig | ||
| 1974 | git commit -m "feat: a terminal-free session pump under src/client for the native viewer" | ||
| 1975 | ``` | ||
| 1976 | |||
| 1977 | --- | ||
| 1978 | |||
| 1979 | ### Task 8: `frame.zig` and the real `muxg.zig` | ||
| 1980 | |||
| 1981 | **Files:** | ||
| 1982 | - Create: `src/gui/frame.zig` | ||
| 1983 | - Modify: `src/gui/native.zig` (add `pub const frame`, `run`, `_ = frame;`), `src/cli/muxg.zig` (replace the probe) | ||
| 1984 | |||
| 1985 | **Interfaces:** | ||
| 1986 | - 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`. | ||
| 1987 | - Produces: | ||
| 1988 | - `frame.Options = struct { target: client.Target, session: []const u8, font_px: u16 = 16, width: u32 = 960, height: u32 = 600, test_fifo: ?[]const u8 = null }` | ||
| 1989 | - `frame.run(alloc, opts) !u8` | ||
| 1990 | - `frame.keyEvent(sdl_key: u32, sdl_mod: u16) ?keymap.Event` — the pure key table. | ||
| 1991 | - 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`. | ||
| 1992 | - `SIGUSR1` prints the frame table to stderr; so does exit. | ||
| 1993 | |||
| 1994 | - [ ] **Step 1: Write the failing key-table tests** | ||
| 1995 | |||
| 1996 | Create `src/gui/frame.zig` with the header, imports and, at the bottom: | ||
| 1997 | |||
| 1998 | ```zig | ||
| 1999 | test "named keys map, printable keys do not (they arrive as text)" { | ||
| 2000 | try std.testing.expectEqual(keymap.Key.up, keyEvent(c.SDLK_UP, 0).?.key); | ||
| 2001 | try std.testing.expectEqual(keymap.Key.enter, keyEvent(c.SDLK_RETURN, 0).?.key); | ||
| 2002 | try std.testing.expectEqual(keymap.Key.f5, keyEvent(c.SDLK_F5, 0).?.key); | ||
| 2003 | try std.testing.expect(keyEvent(c.SDLK_A, 0) == null); | ||
| 2004 | try std.testing.expect(keyEvent(c.SDLK_SPACE, 0) == null); | ||
| 2005 | } | ||
| 2006 | |||
| 2007 | test "ctrl and alt with a letter become a char event with mods" { | ||
| 2008 | const ev = keyEvent(c.SDLK_C, c.SDL_KMOD_LCTRL).?; | ||
| 2009 | try std.testing.expectEqual(keymap.Key.char, ev.key); | ||
| 2010 | try std.testing.expectEqual(@as(u21, 'c'), ev.cp); | ||
| 2011 | try std.testing.expect(ev.mods.ctrl and !ev.mods.alt); | ||
| 2012 | const alt = keyEvent(c.SDLK_X, c.SDL_KMOD_RALT).?; | ||
| 2013 | try std.testing.expect(alt.mods.alt); | ||
| 2014 | // Shift alone on a letter is text, not a key event. | ||
| 2015 | try std.testing.expect(keyEvent(c.SDLK_X, c.SDL_KMOD_LSHIFT) == null); | ||
| 2016 | } | ||
| 2017 | |||
| 2018 | test "the window's drawable size floors to whole cells" { | ||
| 2019 | try std.testing.expectEqual(CellsOf{ .cols = 120, .rows = 37 }, cellsOf(963, 601, 8, 16)); | ||
| 2020 | try std.testing.expectEqual(CellsOf{ .cols = 1, .rows = 1 }, cellsOf(3, 5, 8, 16)); | ||
| 2021 | } | ||
| 2022 | |||
| 2023 | test "a test-hook line parses" { | ||
| 2024 | try std.testing.expectEqualStrings("hi there", parseHook("text:hi there").?.text); | ||
| 2025 | try std.testing.expectEqual(keymap.Key.enter, parseHook("key:enter").?.key); | ||
| 2026 | const r = parseHook("resize:640x480").?; | ||
| 2027 | try std.testing.expectEqual(@as(u32, 640), r.resize.w); | ||
| 2028 | try std.testing.expect(parseHook("quit").? == .quit); | ||
| 2029 | try std.testing.expect(parseHook("bogus") == null); | ||
| 2030 | } | ||
| 2031 | ``` | ||
| 2032 | |||
| 2033 | - [ ] **Step 2: Run to verify they fail** | ||
| 2034 | |||
| 2035 | 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` | ||
| 2036 | Expected: compile error, `keyEvent` not found. | ||
| 2037 | |||
| 2038 | - [ ] **Step 3: Implement `frame.zig`** | ||
| 2039 | |||
| 2040 | Above the tests: | ||
| 2041 | |||
| 2042 | ```zig | ||
| 2043 | //! The window thread: SDL owns the window and the GL context; this file | ||
| 2044 | //! owns the loop. It waits on SDL's event queue; a wake from the pump or a | ||
| 2045 | //! resize locks the replica, rebuilds every instance from the whole grid, | ||
| 2046 | //! unlocks, uploads, draws and swaps. Keys become keymap events, text | ||
| 2047 | //! becomes input bytes, and both go to the pump's mailbox. The ONE file | ||
| 2048 | //! under src/gui/ that names SDL (folder rule 9). | ||
| 2049 | //! | ||
| 2050 | //! Whole-grid rebuild every frame is deliberate for v1: a large window is | ||
| 2051 | //! on the order of ten thousand cells, and the timing table is what will | ||
| 2052 | //! say whether dirty rows ever matter. | ||
| 2053 | const std = @import("std"); | ||
| 2054 | const client = @import("client"); | ||
| 2055 | const term = @import("term"); | ||
| 2056 | const keymap = client.keymap; | ||
| 2057 | const session_pump = client.session_pump; | ||
| 2058 | const font = @import("font.zig"); | ||
| 2059 | const atlas = @import("atlas.zig"); | ||
| 2060 | const quads = @import("quads.zig"); | ||
| 2061 | const gl = @import("gl.zig"); | ||
| 2062 | const bench = @import("bench.zig"); | ||
| 2063 | |||
| 2064 | const c = @cImport({ | ||
| 2065 | @cInclude("SDL3/SDL.h"); | ||
| 2066 | }); | ||
| 2067 | |||
| 2068 | pub const Options = struct { | ||
| 2069 | target: client.Target, | ||
| 2070 | session: []const u8, | ||
| 2071 | font_px: u16 = 16, | ||
| 2072 | width: u32 = 960, | ||
| 2073 | height: u32 = 600, | ||
| 2074 | /// The e2e leg's hook: a FIFO of `text:`/`key:`/`resize:`/`quit` lines. | ||
| 2075 | test_fifo: ?[]const u8 = null, | ||
| 2076 | }; | ||
| 2077 | |||
| 2078 | pub const CellsOf = struct { cols: u16, rows: u16 }; | ||
| 2079 | |||
| 2080 | pub fn cellsOf(px_w: u32, px_h: u32, cell_w: u16, cell_h: u16) CellsOf { | ||
| 2081 | return .{ | ||
| 2082 | .cols = @intCast(@max(px_w / cell_w, 1)), | ||
| 2083 | .rows = @intCast(@max(px_h / cell_h, 1)), | ||
| 2084 | }; | ||
| 2085 | } | ||
| 2086 | |||
| 2087 | /// SDL keycode + mods → the keymap's event, or null for a key that types | ||
| 2088 | /// (text input carries it) or means nothing to a session. | ||
| 2089 | pub fn keyEvent(key: u32, mod: u16) ?keymap.Event { | ||
| 2090 | const ctrl = mod & c.SDL_KMOD_CTRL != 0; | ||
| 2091 | const alt = mod & c.SDL_KMOD_ALT != 0; | ||
| 2092 | const shift = mod & c.SDL_KMOD_SHIFT != 0; | ||
| 2093 | const mods: keymap.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift }; | ||
| 2094 | const named: ?keymap.Key = switch (key) { | ||
| 2095 | c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter, | ||
| 2096 | c.SDLK_TAB => .tab, | ||
| 2097 | c.SDLK_BACKSPACE => .backspace, | ||
| 2098 | c.SDLK_ESCAPE => .escape, | ||
| 2099 | c.SDLK_UP => .up, | ||
| 2100 | c.SDLK_DOWN => .down, | ||
| 2101 | c.SDLK_LEFT => .left, | ||
| 2102 | c.SDLK_RIGHT => .right, | ||
| 2103 | c.SDLK_HOME => .home, | ||
| 2104 | c.SDLK_END => .end, | ||
| 2105 | c.SDLK_INSERT => .insert, | ||
| 2106 | c.SDLK_DELETE => .delete, | ||
| 2107 | c.SDLK_PAGEUP => .page_up, | ||
| 2108 | c.SDLK_PAGEDOWN => .page_down, | ||
| 2109 | c.SDLK_F1 => .f1, | ||
| 2110 | c.SDLK_F2 => .f2, | ||
| 2111 | c.SDLK_F3 => .f3, | ||
| 2112 | c.SDLK_F4 => .f4, | ||
| 2113 | c.SDLK_F5 => .f5, | ||
| 2114 | c.SDLK_F6 => .f6, | ||
| 2115 | c.SDLK_F7 => .f7, | ||
| 2116 | c.SDLK_F8 => .f8, | ||
| 2117 | c.SDLK_F9 => .f9, | ||
| 2118 | c.SDLK_F10 => .f10, | ||
| 2119 | c.SDLK_F11 => .f11, | ||
| 2120 | c.SDLK_F12 => .f12, | ||
| 2121 | else => null, | ||
| 2122 | }; | ||
| 2123 | if (named) |k| return .{ .key = k, .mods = mods }; | ||
| 2124 | // A printable key with Ctrl or Alt held: SDL sends no text for it. | ||
| 2125 | if ((ctrl or alt) and key >= 0x20 and key < 0x7f) { | ||
| 2126 | return .{ .key = .char, .cp = @intCast(key), .mods = mods }; | ||
| 2127 | } | ||
| 2128 | return null; | ||
| 2129 | } | ||
| 2130 | |||
| 2131 | pub const Hook = union(enum) { | ||
| 2132 | text: []const u8, | ||
| 2133 | key: keymap.Key, | ||
| 2134 | resize: struct { w: u32, h: u32 }, | ||
| 2135 | quit, | ||
| 2136 | }; | ||
| 2137 | |||
| 2138 | pub fn parseHook(line: []const u8) ?Hook { | ||
| 2139 | if (std.mem.eql(u8, line, "quit")) return .quit; | ||
| 2140 | if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] }; | ||
| 2141 | if (std.mem.startsWith(u8, line, "key:")) { | ||
| 2142 | const name = line["key:".len..]; | ||
| 2143 | inline for (.{ "enter", "tab", "escape", "backspace", "up", "down", "left", "right" }) |n| { | ||
| 2144 | if (std.mem.eql(u8, name, n)) return .{ .key = @field(keymap.Key, n) }; | ||
| 2145 | } | ||
| 2146 | return null; | ||
| 2147 | } | ||
| 2148 | if (std.mem.startsWith(u8, line, "resize:")) { | ||
| 2149 | const rest = line["resize:".len..]; | ||
| 2150 | const x = std.mem.indexOfScalar(u8, rest, 'x') orelse return null; | ||
| 2151 | const w = std.fmt.parseInt(u32, rest[0..x], 10) catch return null; | ||
| 2152 | const h = std.fmt.parseInt(u32, rest[x + 1 ..], 10) catch return null; | ||
| 2153 | return .{ .resize = .{ .w = w, .h = h } }; | ||
| 2154 | } | ||
| 2155 | return null; | ||
| 2156 | } | ||
| 2157 | |||
| 2158 | // --- the loop --- | ||
| 2159 | |||
| 2160 | var usr1_seen = std.atomic.Value(bool).init(false); | ||
| 2161 | |||
| 2162 | fn onUsr1(_: c_int) callconv(.c) void { | ||
| 2163 | usr1_seen.store(true, .release); | ||
| 2164 | } | ||
| 2165 | |||
| 2166 | const Wake = struct { | ||
| 2167 | var event_type: u32 = 0; | ||
| 2168 | fn ring(_: *anyopaque) void { | ||
| 2169 | var ev: c.SDL_Event = undefined; | ||
| 2170 | ev.type = event_type; | ||
| 2171 | _ = c.SDL_PushEvent(&ev); | ||
| 2172 | } | ||
| 2173 | }; | ||
| 2174 | |||
| 2175 | /// A hook line becomes the same SDL event a person would have caused, so | ||
| 2176 | /// the leg exercises the real key and text paths. A resize is pushed as a | ||
| 2177 | /// window event of the given size; on a driver with no real window that is | ||
| 2178 | /// the only way one arrives. | ||
| 2179 | const HookThread = struct { | ||
| 2180 | path: []const u8, | ||
| 2181 | window_id: u32, | ||
| 2182 | |||
| 2183 | fn run(self: HookThread) void { | ||
| 2184 | const f = std.fs.cwd().openFile(self.path, .{}) catch return; | ||
| 2185 | defer f.close(); | ||
| 2186 | var buf: [4096]u8 = undefined; | ||
| 2187 | var reader = f.reader(&buf); | ||
| 2188 | while (reader.interface.takeDelimiterExclusive('\n')) |line| { | ||
| 2189 | const hook = parseHook(line) orelse continue; | ||
| 2190 | var ev: c.SDL_Event = undefined; | ||
| 2191 | @memset(std.mem.asBytes(&ev), 0); | ||
| 2192 | switch (hook) { | ||
| 2193 | .text => |t| { | ||
| 2194 | // SDL_EVENT_TEXT_INPUT carries a pointer; SDL owns it | ||
| 2195 | // only for events it made, so hand it a stable copy. | ||
| 2196 | const z = std.heap.page_allocator.dupeZ(u8, t) catch continue; | ||
| 2197 | ev.text.type = c.SDL_EVENT_TEXT_INPUT; | ||
| 2198 | ev.text.windowID = self.window_id; | ||
| 2199 | ev.text.text = z.ptr; | ||
| 2200 | }, | ||
| 2201 | .key => |k| { | ||
| 2202 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 2203 | ev.key.windowID = self.window_id; | ||
| 2204 | ev.key.key = switch (k) { | ||
| 2205 | .enter => c.SDLK_RETURN, | ||
| 2206 | .tab => c.SDLK_TAB, | ||
| 2207 | .escape => c.SDLK_ESCAPE, | ||
| 2208 | .backspace => c.SDLK_BACKSPACE, | ||
| 2209 | .up => c.SDLK_UP, | ||
| 2210 | .down => c.SDLK_DOWN, | ||
| 2211 | .left => c.SDLK_LEFT, | ||
| 2212 | .right => c.SDLK_RIGHT, | ||
| 2213 | else => continue, | ||
| 2214 | }; | ||
| 2215 | }, | ||
| 2216 | .resize => |r| { | ||
| 2217 | ev.window.type = c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED; | ||
| 2218 | ev.window.windowID = self.window_id; | ||
| 2219 | ev.window.data1 = @intCast(r.w); | ||
| 2220 | ev.window.data2 = @intCast(r.h); | ||
| 2221 | }, | ||
| 2222 | .quit => { | ||
| 2223 | ev.type = c.SDL_EVENT_QUIT; | ||
| 2224 | }, | ||
| 2225 | } | ||
| 2226 | _ = c.SDL_PushEvent(&ev); | ||
| 2227 | } else |_| {} | ||
| 2228 | } | ||
| 2229 | }; | ||
| 2230 | |||
| 2231 | fn sdlFail(what: []const u8) u8 { | ||
| 2232 | std.debug.print("muxg: {s}: {s}\n", .{ what, std.mem.span(c.SDL_GetError()) }); | ||
| 2233 | return 2; | ||
| 2234 | } | ||
| 2235 | |||
| 2236 | const GlyphCache = struct { | ||
| 2237 | alloc: std.mem.Allocator, | ||
| 2238 | face: *font.Face, | ||
| 2239 | atlas: *atlas.Atlas, | ||
| 2240 | |||
| 2241 | fn get(ctx: *anyopaque, cp: u21) ?atlas.Entry { | ||
| 2242 | const self: *GlyphCache = @ptrCast(@alignCast(ctx)); | ||
| 2243 | if (self.atlas.get(cp)) |e| return e; | ||
| 2244 | var g = self.face.render(self.alloc, cp) catch return null; | ||
| 2245 | defer g.deinit(self.alloc); | ||
| 2246 | return self.atlas.put(self.alloc, cp, g.w, g.h, g.left, g.top, g.pixels) catch null; | ||
| 2247 | } | ||
| 2248 | }; | ||
| 2249 | |||
| 2250 | fn setTitle(win: *c.SDL_Window, session: []const u8, suffix: []const u8) void { | ||
| 2251 | var buf: [128]u8 = undefined; | ||
| 2252 | const t = std.fmt.bufPrintZ(&buf, "muxg {s}{s}", .{ session, suffix }) catch "muxg"; | ||
| 2253 | _ = c.SDL_SetWindowTitle(win, t.ptr); | ||
| 2254 | } | ||
| 2255 | |||
| 2256 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | ||
| 2257 | var ring: bench.Ring = .{}; | ||
| 2258 | defer { | ||
| 2259 | var buf: [2048]u8 = undefined; | ||
| 2260 | std.debug.print("{s}", .{ring.report(&buf)}); | ||
| 2261 | } | ||
| 2262 | |||
| 2263 | var sa: std.posix.Sigaction = .{ | ||
| 2264 | .handler = .{ .handler = onUsr1 }, | ||
| 2265 | .mask = std.posix.sigemptyset(), | ||
| 2266 | .flags = 0, | ||
| 2267 | }; | ||
| 2268 | std.posix.sigaction(std.posix.SIG.USR1, &sa, null); | ||
| 2269 | |||
| 2270 | var face = font.Face.open(opts.font_px) catch |err| { | ||
| 2271 | std.debug.print("muxg: font: {s}\n", .{@errorName(err)}); | ||
| 2272 | return 2; | ||
| 2273 | }; | ||
| 2274 | defer face.deinit(); | ||
| 2275 | |||
| 2276 | if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init"); | ||
| 2277 | defer c.SDL_Quit(); | ||
| 2278 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); | ||
| 2279 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3); | ||
| 2280 | _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE); | ||
| 2281 | const win = c.SDL_CreateWindow( | ||
| 2282 | "muxg", | ||
| 2283 | @intCast(opts.width), | ||
| 2284 | @intCast(opts.height), | ||
| 2285 | c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE, | ||
| 2286 | ) orelse return sdlFail("SDL_CreateWindow"); | ||
| 2287 | defer c.SDL_DestroyWindow(win); | ||
| 2288 | const ctx = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext"); | ||
| 2289 | defer _ = c.SDL_GL_DestroyContext(ctx); | ||
| 2290 | _ = c.SDL_GL_SetSwapInterval(1); | ||
| 2291 | var renderer = gl.Renderer.init(@ptrCast(&c.SDL_GL_GetProcAddress)) catch |err| { | ||
| 2292 | std.debug.print("muxg: gl: {s}\n", .{@errorName(err)}); | ||
| 2293 | return 2; | ||
| 2294 | }; | ||
| 2295 | defer renderer.deinit(); | ||
| 2296 | _ = c.SDL_StartTextInput(win); | ||
| 2297 | |||
| 2298 | var glyph_atlas = try atlas.Atlas.init(alloc, 1024, 256); | ||
| 2299 | defer glyph_atlas.deinit(alloc); | ||
| 2300 | var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .atlas = &glyph_atlas }; | ||
| 2301 | |||
| 2302 | var fb_w: c_int = 0; | ||
| 2303 | var fb_h: c_int = 0; | ||
| 2304 | _ = c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h); | ||
| 2305 | var cells = cellsOf(@intCast(fb_w), @intCast(fb_h), face.cell_w, face.cell_h); | ||
| 2306 | |||
| 2307 | Wake.event_type = c.SDL_RegisterEvents(1); | ||
| 2308 | const pump = try session_pump.Pump.start(alloc, .{ | ||
| 2309 | .target = opts.target, | ||
| 2310 | .session = opts.session, | ||
| 2311 | .cols = cells.cols, | ||
| 2312 | .rows = cells.rows, | ||
| 2313 | .wake = Wake.ring, | ||
| 2314 | .wake_ctx = @ptrCast(@constCast(&Wake.event_type)), | ||
| 2315 | }); | ||
| 2316 | defer pump.stop(); | ||
| 2317 | setTitle(win, opts.session, ""); | ||
| 2318 | |||
| 2319 | if (opts.test_fifo) |path| { | ||
| 2320 | const th = try std.Thread.spawn(.{}, HookThread.run, .{HookThread{ .path = path, .window_id = c.SDL_GetWindowID(win) }}); | ||
| 2321 | th.detach(); | ||
| 2322 | } | ||
| 2323 | |||
| 2324 | var instances: std.ArrayListUnmanaged(quads.Instance) = .empty; | ||
| 2325 | defer instances.deinit(alloc); | ||
| 2326 | var seq_buf: [keymap.max_seq_len]u8 = undefined; | ||
| 2327 | var dirty = true; | ||
| 2328 | var last_phase: session_pump.Phase = .dialing; | ||
| 2329 | |||
| 2330 | while (true) { | ||
| 2331 | var ev: c.SDL_Event = undefined; | ||
| 2332 | // 250 ms so a SIGUSR1 with no event traffic still prints. | ||
| 2333 | if (c.SDL_WaitEventTimeout(&ev, 250)) { | ||
| 2334 | switch (ev.type) { | ||
| 2335 | c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => { | ||
| 2336 | pump.say(.detach) catch {}; | ||
| 2337 | return 0; | ||
| 2338 | }, | ||
| 2339 | c.SDL_EVENT_TEXT_INPUT => { | ||
| 2340 | const text = std.mem.span(ev.text.text); | ||
| 2341 | pump.say(.{ .input = text }) catch {}; | ||
| 2342 | }, | ||
| 2343 | c.SDL_EVENT_KEY_DOWN => { | ||
| 2344 | if (keyEvent(ev.key.key, ev.key.mod)) |kev| { | ||
| 2345 | const bytes = keymap.encode(kev, &seq_buf); | ||
| 2346 | if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {}; | ||
| 2347 | } | ||
| 2348 | }, | ||
| 2349 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => { | ||
| 2350 | fb_w = ev.window.data1; | ||
| 2351 | fb_h = ev.window.data2; | ||
| 2352 | const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h); | ||
| 2353 | if (now.cols != cells.cols or now.rows != cells.rows) { | ||
| 2354 | cells = now; | ||
| 2355 | pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {}; | ||
| 2356 | } | ||
| 2357 | dirty = true; | ||
| 2358 | }, | ||
| 2359 | c.SDL_EVENT_WINDOW_EXPOSED => dirty = true, | ||
| 2360 | else => if (ev.type == Wake.event_type) { | ||
| 2361 | dirty = true; | ||
| 2362 | }, | ||
| 2363 | } | ||
| 2364 | // Coalesce: everything else already queued is handled before a paint. | ||
| 2365 | while (c.SDL_PollEvent(&ev)) { | ||
| 2366 | switch (ev.type) { | ||
| 2367 | c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => { | ||
| 2368 | pump.say(.detach) catch {}; | ||
| 2369 | return 0; | ||
| 2370 | }, | ||
| 2371 | c.SDL_EVENT_TEXT_INPUT => pump.say(.{ .input = std.mem.span(ev.text.text) }) catch {}, | ||
| 2372 | c.SDL_EVENT_KEY_DOWN => if (keyEvent(ev.key.key, ev.key.mod)) |kev| { | ||
| 2373 | const bytes = keymap.encode(kev, &seq_buf); | ||
| 2374 | if (bytes.len > 0) pump.say(.{ .input = bytes }) catch {}; | ||
| 2375 | }, | ||
| 2376 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => { | ||
| 2377 | fb_w = ev.window.data1; | ||
| 2378 | fb_h = ev.window.data2; | ||
| 2379 | const now = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h); | ||
| 2380 | if (now.cols != cells.cols or now.rows != cells.rows) { | ||
| 2381 | cells = now; | ||
| 2382 | pump.say(.{ .resize = .{ .cols = cells.cols, .rows = cells.rows } }) catch {}; | ||
| 2383 | } | ||
| 2384 | dirty = true; | ||
| 2385 | }, | ||
| 2386 | else => dirty = true, | ||
| 2387 | } | ||
| 2388 | } | ||
| 2389 | } | ||
| 2390 | |||
| 2391 | if (usr1_seen.swap(false, .acq_rel)) { | ||
| 2392 | var buf: [2048]u8 = undefined; | ||
| 2393 | std.debug.print("{s}", .{ring.report(&buf)}); | ||
| 2394 | } | ||
| 2395 | |||
| 2396 | const st = pump.state(); | ||
| 2397 | if (st.phase != last_phase) { | ||
| 2398 | last_phase = st.phase; | ||
| 2399 | switch (st.phase) { | ||
| 2400 | .attached => setTitle(win, opts.session, ""), | ||
| 2401 | .reconnecting => setTitle(win, opts.session, " [reconnecting]"), | ||
| 2402 | .exited => return st.exit_code, | ||
| 2403 | .refused => { | ||
| 2404 | std.debug.print("muxg: {s}\n", .{st.reasonText()}); | ||
| 2405 | return 1; | ||
| 2406 | }, | ||
| 2407 | .taken => { | ||
| 2408 | std.debug.print("muxg: the session was taken by another client\n", .{}); | ||
| 2409 | return 0; | ||
| 2410 | }, | ||
| 2411 | .failed => { | ||
| 2412 | std.debug.print("muxg: {s}\n", .{st.reasonText()}); | ||
| 2413 | return 1; | ||
| 2414 | }, | ||
| 2415 | .dialing => {}, | ||
| 2416 | } | ||
| 2417 | } | ||
| 2418 | if (st.bell) setTitle(win, opts.session, " [bell]"); | ||
| 2419 | |||
| 2420 | if (!dirty) continue; | ||
| 2421 | dirty = false; | ||
| 2422 | |||
| 2423 | var timer = try std.time.Timer.start(); | ||
| 2424 | var frame_t: bench.Frame = .{}; | ||
| 2425 | instances.clearRetainingCapacity(); | ||
| 2426 | { | ||
| 2427 | pump.mu.lock(); | ||
| 2428 | defer pump.mu.unlock(); | ||
| 2429 | frame_t.apply_us = pump.last_apply_us; | ||
| 2430 | const g = pump.grid; | ||
| 2431 | const qctx: quads.Ctx = .{ | ||
| 2432 | .cell_w = face.cell_w, | ||
| 2433 | .cell_h = face.cell_h, | ||
| 2434 | .ascent = face.ascent, | ||
| 2435 | .atlas_w = @floatFromInt(glyph_atlas.width), | ||
| 2436 | .atlas_h = @floatFromInt(glyph_atlas.height), | ||
| 2437 | .glyphs = .{ .ctx = @ptrCast(&cache), .get = GlyphCache.get }, | ||
| 2438 | }; | ||
| 2439 | var y: u16 = 0; | ||
| 2440 | while (y < g.rows) : (y += 1) { | ||
| 2441 | try quads.rowInstances(&instances, alloc, g.row(y), g.cols, 0, y, qctx); | ||
| 2442 | } | ||
| 2443 | try instances.append(alloc, quads.cursorInstance(g.cursor.x, g.cursor.y, qctx)); | ||
| 2444 | } | ||
| 2445 | frame_t.rebuild_us = bench.usSince(&timer); | ||
| 2446 | // The atlas may have grown during the rebuild: the uv values above | ||
| 2447 | // were computed against the size at rebuild time, so re-run the | ||
| 2448 | // rebuild once when it did. Rare (first sight of a glyph) and cheap. | ||
| 2449 | if (glyph_atlas.dirty) { | ||
| 2450 | renderer.uploadAtlas(&glyph_atlas); | ||
| 2451 | frame_t.atlas_us = bench.usSince(&timer); | ||
| 2452 | } | ||
| 2453 | renderer.uploadInstances(instances.items); | ||
| 2454 | frame_t.upload_us = bench.usSince(&timer); | ||
| 2455 | renderer.draw(instances.items.len, fb_w, fb_h, 0x101010ff); | ||
| 2456 | _ = c.SDL_GL_SwapWindow(win); | ||
| 2457 | frame_t.draw_us = bench.usSince(&timer); | ||
| 2458 | ring.record(frame_t); | ||
| 2459 | } | ||
| 2460 | } | ||
| 2461 | ``` | ||
| 2462 | |||
| 2463 | The atlas-growth note: because `Ctx.atlas_w/h` are read once per rebuild and a `put` inside `GlyphCache.get` can grow the atlas mid-rebuild, a frame where growth happened paints a few glyphs with stale uv. To keep v1 simple and correct: after `uploadAtlas`, if the atlas size changed from what `qctx` used, set `dirty = true` so the NEXT loop pass repaints with the right uv. Implement that by comparing `renderer.tex_h` before and after the upload and setting `dirty = true` when it changed. | ||
| 2464 | |||
| 2465 | - [ ] **Step 4: Replace the probe with the real entry** | ||
| 2466 | |||
| 2467 | `src/cli/muxg.zig`: | ||
| 2468 | |||
| 2469 | ```zig | ||
| 2470 | //! `muxg`: the native client's entry. Parses ONE target the way `mux` | ||
| 2471 | //! does (HOST, --sock PATH, --via CMD, quic://HOST[:PORT]) plus --session | ||
| 2472 | //! and --font-px, resolves it to a client.Target, and hands it to the | ||
| 2473 | //! painter. A session viewer: no wall, no hosts file, no layout. | ||
| 2474 | //! | ||
| 2475 | //! No daemon is started here. The self-exec rule (CLAUDE.md) says an | ||
| 2476 | //! auto-start may only run the image already running, and this image is | ||
| 2477 | //! not the daemon's. A silent socket is a refusal with the command to run. | ||
| 2478 | const std = @import("std"); | ||
| 2479 | const native = @import("native"); | ||
| 2480 | const client = @import("client"); | ||
| 2481 | const term = @import("term"); | ||
| 2482 | const cliflags = @import("cliflags"); | ||
| 2483 | const sockpath = @import("sockpath"); | ||
| 2484 | |||
| 2485 | const proto = term.protocol; | ||
| 2486 | const hosts = client.hosts; | ||
| 2487 | |||
| 2488 | const usage = | ||
| 2489 | \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] | ||
| 2490 | \\ | ||
| 2491 | \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none means the local daemon | ||
| 2492 | \\ --session the session name (default: the daemon's default session) | ||
| 2493 | \\ --sock a local daemon's socket path | ||
| 2494 | \\ --via a command whose stdio is the daemon | ||
| 2495 | \\ --key the QUIC key file (or MUX_KEY_FILE) | ||
| 2496 | \\ --font-px the face's pixel size (default 16) | ||
| 2497 | \\ --help --version | ||
| 2498 | \\ | ||
| 2499 | ; | ||
| 2500 | |||
| 2501 | const Arguments = struct { | ||
| 2502 | sock: ?[]const u8 = null, | ||
| 2503 | via: ?[]const u8 = null, | ||
| 2504 | key: ?[]const u8 = null, | ||
| 2505 | session: ?proto.SessionName = null, | ||
| 2506 | font_px: u16 = 16, | ||
| 2507 | _target: ?[]const u8 = null, | ||
| 2508 | _targets: u8 = 0, | ||
| 2509 | |||
| 2510 | pub fn positional(self: *Arguments, word: []const u8) bool { | ||
| 2511 | self._target = word; | ||
| 2512 | self._targets += 1; | ||
| 2513 | return true; | ||
| 2514 | } | ||
| 2515 | }; | ||
| 2516 | |||
| 2517 | comptime { | ||
| 2518 | cliflags.assertDocumented(Arguments, usage, &.{}); | ||
| 2519 | } | ||
| 2520 | |||
| 2521 | pub fn main() !u8 { | ||
| 2522 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 2523 | defer if (gpa.deinit() == .leak) std.debug.print("muxg: LEAK: allocations outlived deinit\n", .{}); | ||
| 2524 | const alloc = gpa.allocator(); | ||
| 2525 | const args = try std.process.argsAlloc(alloc); | ||
| 2526 | defer std.process.argsFree(alloc, args); | ||
| 2527 | |||
| 2528 | var o: Arguments = .{}; | ||
| 2529 | cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", "0.0.1"); | ||
| 2530 | const named: u8 = @as(u8, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets; | ||
| 2531 | if (named > 1) { | ||
| 2532 | std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); | ||
| 2533 | return 2; | ||
| 2534 | } | ||
| 2535 | const session = if (o.session) |n| n.name else ""; | ||
| 2536 | const key = std.posix.getenv("MUX_KEY_FILE"); | ||
| 2537 | |||
| 2538 | var target: client.Target = undefined; | ||
| 2539 | if (o.via) |cmd| { | ||
| 2540 | target = .{ .via = cmd }; | ||
| 2541 | } else if (o._target) |word| { | ||
| 2542 | const spec = hosts.parse(word) catch |err| { | ||
| 2543 | std.debug.print("muxg: bad target {s}: {s}\n", .{ word, @errorName(err) }); | ||
| 2544 | return 2; | ||
| 2545 | }; | ||
| 2546 | target = client.Target.fromSpec(alloc, spec, o.key orelse key, client.quic_idle_ms_default, true) catch |err| switch (err) { | ||
| 2547 | error.MissingKey => { | ||
| 2548 | std.debug.print("muxg: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n", .{}); | ||
| 2549 | return 2; | ||
| 2550 | }, | ||
| 2551 | else => |e| return e, | ||
| 2552 | }; | ||
| 2553 | if (target == .hand) target.hand.narrate = true; | ||
| 2554 | } else { | ||
| 2555 | const path = if (o.sock) |s| try alloc.dupe(u8, s) else (try sockpath.defaultOrExplain(alloc, "muxg") orelse return 1); | ||
| 2556 | defer alloc.free(path); | ||
| 2557 | if (!sockpath.answers(path)) { | ||
| 2558 | std.debug.print("muxg: no daemon at {s} (run: mux d start -d --sock {s})\n", .{ path, path }); | ||
| 2559 | return 2; | ||
| 2560 | } | ||
| 2561 | return native.run(alloc, .{ | ||
| 2562 | .target = .{ .sock = path }, | ||
| 2563 | .session = session, | ||
| 2564 | .font_px = o.font_px, | ||
| 2565 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), | ||
| 2566 | }); | ||
| 2567 | } | ||
| 2568 | return native.run(alloc, .{ | ||
| 2569 | .target = target, | ||
| 2570 | .session = session, | ||
| 2571 | .font_px = o.font_px, | ||
| 2572 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), | ||
| 2573 | }); | ||
| 2574 | } | ||
| 2575 | ``` | ||
| 2576 | |||
| 2577 | 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. | ||
| 2578 | |||
| 2579 | - [ ] **Step 5: Build, run the unit tests, and try it by hand** | ||
| 2580 | |||
| 2581 | Run: | ||
| 2582 | ```sh | ||
| 2583 | deps/zig/zig build native-test 2>&1 | tail -5; echo rc=$? | ||
| 2584 | make native; echo rc=$? | ||
| 2585 | S=/tmp/claude-1000/-home-xanderle-code-rad-mux/d23565e2-afbd-42d3-8c39-9d1b211180b5/scratchpad | ||
| 2586 | export XDG_STATE_HOME=$S/state XDG_RUNTIME_DIR=$S/run; mkdir -p $S/state $S/run | ||
| 2587 | ./zig-out/bin/mux d start -d --sock $S/run/g.sock; echo daemon rc=$? | ||
| 2588 | ./zig-out/bin/muxg --sock $S/run/g.sock; echo muxg rc=$? | ||
| 2589 | ./zig-out/bin/mux d stop --sock $S/run/g.sock | ||
| 2590 | ``` | ||
| 2591 | 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. | ||
| 2592 | |||
| 2593 | - [ ] **Step 6: Commit** | ||
| 2594 | |||
| 2595 | ```sh | ||
| 2596 | make check && git add src/gui/frame.zig src/gui/native.zig src/cli/muxg.zig | ||
| 2597 | git commit -m "feat: muxg, a window on one daemon session painted from the grid" | ||
| 2598 | ``` | ||
| 2599 | |||
| 2600 | --- | ||
| 2601 | |||
| 2602 | ### Task 9: `test/native.sh` — the end-to-end leg | ||
| 2603 | |||
| 2604 | **Files:** | ||
| 2605 | - Create: `test/native.sh` (mode 755) | ||
| 2606 | |||
| 2607 | **Interfaces:** | ||
| 2608 | - 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`. | ||
| 2609 | |||
| 2610 | - [ ] **Step 1: Write the leg** | ||
| 2611 | |||
| 2612 | `test/native.sh`: | ||
| 2613 | |||
| 2614 | ```sh | ||
| 2615 | #!/bin/sh | ||
| 2616 | # The native client's end-to-end leg: a real daemon, a real muxg on SDL's | ||
| 2617 | # offscreen driver, keys through the real event path, a bounded flood, | ||
| 2618 | # and the frame table read while the flood ran. Opt-in (`make native-e2e`); | ||
| 2619 | # not part of ci, because it needs SDL3 and a GL-capable offscreen driver. | ||
| 2620 | # | ||
| 2621 | # The one pin that matters is the waystty failure: frames were painted | ||
| 2622 | # WHILE output flooded the session. A painter that is fast per frame and | ||
| 2623 | # never asked to paint reports a fine p99 and zero frames; this leg reads | ||
| 2624 | # the frame count twice during the flood and asserts it grew. | ||
| 2625 | set -u | ||
| 2626 | MUX="$1" | ||
| 2627 | MUXG="$2" | ||
| 2628 | [ -x "$MUX" ] && [ -x "$MUXG" ] || { echo "native FAIL: need mux and muxg (run: make native)"; exit 1; } | ||
| 2629 | E2E_DIR=$(dirname "$0") | ||
| 2630 | . "$E2E_DIR/e2e_lib.sh" | ||
| 2631 | |||
| 2632 | # A Debug muxg measures a Debug replica; the numbers would mean nothing. | ||
| 2633 | # The budget below is stated for ReleaseSafe/ReleaseFast and the leg | ||
| 2634 | # refuses to grade anything else. `zig build native -Doptimize=ReleaseSafe`. | ||
| 2635 | case "${MUXG_OPT:-}" in | ||
| 2636 | ReleaseSafe|ReleaseFast) ;; | ||
| 2637 | *) echo "native FAIL: set MUXG_OPT=ReleaseSafe|ReleaseFast to match the build; a Debug number is not a number"; exit 1 ;; | ||
| 2638 | esac | ||
| 2639 | |||
| 2640 | SOCK="${TMPDIR:-/tmp}/muxd-native-$$.sock" | ||
| 2641 | LOG="${TMPDIR:-/tmp}/muxd-native-$$.log" | ||
| 2642 | defer_rm "$LOG" | ||
| 2643 | start_daemon "$SOCK" "$LOG" "native daemon" | ||
| 2644 | |||
| 2645 | FIFO="${TMPDIR:-/tmp}/muxg-hook-$$" | ||
| 2646 | defer_rm "$FIFO" | ||
| 2647 | mkfifo "$FIFO" | ||
| 2648 | GLOG="${TMPDIR:-/tmp}/muxg-native-$$.log" | ||
| 2649 | defer_rm "$GLOG" | ||
| 2650 | |||
| 2651 | SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \ | ||
| 2652 | "$MUXG" --sock "$SOCK" 2>"$GLOG" & | ||
| 2653 | GPID=$! | ||
| 2654 | defer_kill "$GPID" | ||
| 2655 | # Hold the FIFO's write end open for the whole leg; each hook line is one echo. | ||
| 2656 | exec 8>"$FIFO" | ||
| 2657 | |||
| 2658 | # 1. The window attached: the daemon counts a client. | ||
| 2659 | # wait_until SECS LABEL PREDICATE — the predicate is eval'd per tick. | ||
| 2660 | wait_until 10 "muxg attached" '[ "$(attaches_now "$SOCK")" -ge 1 ]' | ||
| 2661 | ok "muxg attached to a real daemon on the offscreen driver" | ||
| 2662 | |||
| 2663 | # 2. Keys through the real event path reach the shell. | ||
| 2664 | printf 'text:echo native-ok-%s\n' "$$" >&8 | ||
| 2665 | printf 'key:enter\n' >&8 | ||
| 2666 | wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid" | ||
| 2667 | ok "text and Enter cross the keymap, the mailbox, the pump and the daemon" | ||
| 2668 | |||
| 2669 | # 3. A bounded flood, and the frame count read twice while it runs. | ||
| 2670 | frames_now() { kill -USR1 "$GPID"; sleep 0.3; grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'; } | ||
| 2671 | before=$(frames_now) | ||
| 2672 | "$MUX" a run --sock "$SOCK" --timeout 60000 'head -c 30000000 /dev/urandom | base64 | head -c 20000000; echo flood-done' >/dev/null 2>&1 & | ||
| 2673 | RUNPID=$! | ||
| 2674 | defer_kill "$RUNPID" | ||
| 2675 | sleep 1.5 | ||
| 2676 | mid=$(frames_now) | ||
| 2677 | wait "$RUNPID" | ||
| 2678 | wait_grid "$SOCK" "flood-done" "the flood ended" | ||
| 2679 | after=$(frames_now) | ||
| 2680 | [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during the flood (before=$before mid=$mid)"; exit 1; } | ||
| 2681 | [ "${after:-0}" -gt "${mid:-0}" ] || { echo "native FAIL: no frame painted late in the flood (mid=$mid after=$after)"; exit 1; } | ||
| 2682 | ok "frames were painted while the session flooded: before=$before mid=$mid after=$after" | ||
| 2683 | |||
| 2684 | # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only). | ||
| 2685 | # Budget: 20 ms per stage on the total row, generous for a 960x600 | ||
| 2686 | # window of ~4,500 cells; the row to read is `total`. | ||
| 2687 | p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}') | ||
| 2688 | [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; } | ||
| 2689 | ok "window-side total p99 ${p99} us under 20000 us" | ||
| 2690 | |||
| 2691 | # 5. A resize through the hook is followed by the daemon: cols shrink from | ||
| 2692 | # whatever the 960 px window gave (the face decides the number) to | ||
| 2693 | # fewer at 640 px. Read before, then wait for after to differ. | ||
| 2694 | cols_of() { "$MUX" a status --sock "$SOCK" --timeout 2000 | sed 's/.*"cols":\([0-9]*\).*/\1/'; } | ||
| 2695 | cols_before=$(cols_of) | ||
| 2696 | printf 'resize:640x400\n' >&8 | ||
| 2697 | wait_until 10 "the daemon's cols changed after the resize" '[ "$(cols_of)" != "$cols_before" ]' | ||
| 2698 | cols_after=$(cols_of) | ||
| 2699 | [ "$cols_after" -lt "$cols_before" ] || { echo "native FAIL: cols $cols_before -> $cols_after did not shrink with the 640 px window"; exit 1; } | ||
| 2700 | ok "the daemon followed the window's resize (cols $cols_before -> $cols_after)" | ||
| 2701 | |||
| 2702 | # 6. Close the window: muxg exits and the session survives on the daemon. | ||
| 2703 | # wait_pid_gone PID LABEL — it allows 2 s. | ||
| 2704 | printf 'quit\n' >&8 | ||
| 2705 | wait_pid_gone "$GPID" "muxg exits on quit" | ||
| 2706 | "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; } | ||
| 2707 | ok "closing the window detaches and leaves the session on its daemon" | ||
| 2708 | |||
| 2709 | echo "native OK ($OK_COUNT checkpoints)" | ||
| 2710 | ``` | ||
| 2711 | |||
| 2712 | 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. | ||
| 2713 | |||
| 2714 | - [ ] **Step 2: Run it** | ||
| 2715 | |||
| 2716 | Run: | ||
| 2717 | ```sh | ||
| 2718 | chmod +x test/native.sh | ||
| 2719 | deps/zig/zig build native -Doptimize=ReleaseSafe 2>&1 | tail -3 | ||
| 2720 | MUXG_OPT=ReleaseSafe test/native.sh zig-out/bin/mux zig-out/bin/muxg; echo rc=$? | ||
| 2721 | ``` | ||
| 2722 | 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` per `src/cli/muxa.zig:1095`. | ||
| 2723 | |||
| 2724 | 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. | ||
| 2725 | |||
| 2726 | - [ ] **Step 3: Make `make native-e2e` pass `MUXG_OPT`** | ||
| 2727 | |||
| 2728 | In `Makefile`, change the recipe to: | ||
| 2729 | |||
| 2730 | ```makefile | ||
| 2731 | native-e2e: mac-sdk | ||
| 2732 | $(ZIG) build native -Doptimize=ReleaseSafe | ||
| 2733 | MUXG_OPT=ReleaseSafe $(ZIG) build native-e2e -Doptimize=ReleaseSafe | ||
| 2734 | ``` | ||
| 2735 | |||
| 2736 | Run: `make native-e2e; echo rc=$?` — expected rc 0. | ||
| 2737 | |||
| 2738 | - [ ] **Step 4: Commit** | ||
| 2739 | |||
| 2740 | ```sh | ||
| 2741 | make check && git add test/native.sh Makefile | ||
| 2742 | git commit -m "test: the native client's end-to-end leg pins painting under a flood" | ||
| 2743 | ``` | ||
| 2744 | |||
| 2745 | --- | ||
| 2746 | |||
| 2747 | ### Task 10: Docs | ||
| 2748 | |||
| 2749 | **Files:** | ||
| 2750 | - Modify: `README.md` (a new section after the browser hub's), `CLAUDE.md` (the layout table, the toolchain block, the folder-rule paragraph) | ||
| 2751 | |||
| 2752 | - [ ] **Step 1: README** | ||
| 2753 | |||
| 2754 | After the `mux web` section, add: | ||
| 2755 | |||
| 2756 | ```markdown | ||
| 2757 | ## muxg: a native window on one session | ||
| 2758 | |||
| 2759 | `muxg [TARGET] [--session NAME]` opens a window on one daemon session and | ||
| 2760 | paints it with OpenGL from the cells the daemon sends. It is a session | ||
| 2761 | viewer: no panes, no picker, no hosts file. Type into it; resize it and | ||
| 2762 | the session follows; close it and the session stays on its daemon. A | ||
| 2763 | wall inside a window is `mux` run inside a `muxg` session. | ||
| 2764 | |||
| 2765 | It is opt-in: `make native` builds it against the system's SDL3, freetype | ||
| 2766 | and fontconfig, and `make native-e2e` runs its leg. `muxg` never starts a | ||
| 2767 | daemon; a silent local socket is refused with the `mux d start` line to | ||
| 2768 | run. `kill -USR1` on a running `muxg` prints its per-stage frame-timing | ||
| 2769 | table to stderr, as does exit. | ||
| 2770 | ``` | ||
| 2771 | |||
| 2772 | - [ ] **Step 2: CLAUDE.md** | ||
| 2773 | |||
| 2774 | - Toolchain block: add `make native native-e2e # opt-in: the muxg viewer against system SDL3/freetype/fontconfig; never in ci`. | ||
| 2775 | - 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`. | ||
| 2776 | - 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.` | ||
| 2777 | - 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.` | ||
| 2778 | |||
| 2779 | - [ ] **Step 3: Commit** | ||
| 2780 | |||
| 2781 | ```sh | ||
| 2782 | make check && git add README.md CLAUDE.md | ||
| 2783 | git commit -m "docs: muxg, the native session viewer, in README and CLAUDE.md" | ||
| 2784 | ``` | ||
| 2785 | |||
| 2786 | --- | ||
| 2787 | |||
| 2788 | ## Self-review | ||
| 2789 | |||
| 2790 | **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. | ||
| 2791 | |||
| 2792 | **Type consistency.** `atlas.Entry {x,y,w,h,left,top}` is produced by Task 3 and consumed by Task 4's stub and Task 8's cache. `quads.Instance` field order (`x y w h u0 v0 u1 v1 rgba kind`) matches Task 6's attribute offsets. `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`. | ||
| 2793 | |||
| 2794 | **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. | ||