Starting a New Zig TUI
Six weeks of this chronicle compress into about two hours of setup. This chapter is that setup: the contract every terminal program owes its user, the sequences it will need, a project skeleton with the spec pipeline already wired, the harness that verifies it without human eyes, and the first three canaries to write before any feature exists.
It is written to be worked through top to bottom on a fresh repository. Each section links back to the week that paid for it, and the Recipe Index is the reverse map.
1. The Teardown Contract — a Checklist
Write this before the first feature. Not after — the restore path is the one piece of a TUI that cannot be retrofitted cheaply, because every later feature adds an exit path that must honour it.
One function, four call sites. The restore routine is called from: normal exit, cancellation,
every signal handler (SIGINT, SIGTERM, SIGHUP), and the panic handler. If you can find a
fifth way out of your program, that is a bug in the list, not an exception to it.
The order inside that function is not negotiable — see TUI Restoration §1:
- 1. Disable mouse tracking (
\x1b[?1003l\x1b[?1006l) — first, so the terminal stops producing events while you are tearing down. - 2. Drain in-flight input with a CPR handshake. Write
\x1b[6nand read until the terminatingR. Terminal I/O is FIFO, so that response arriving proves every mouse byte queued before step 1 has been consumed. Skip this and the drained bytes land in the user’s shell. - 3. Restore termios to the saved cooked-mode state.
- 4. Erase only your own rows with
\x1b[2Kper row. Never\x1b[Jor\x1b[2J— those destroy the user’s scrollback. - 5. Park the cursor with
\x1b[row;1Hat the launch row, and write nothing after it. Any stray byte here becomes a blank line before the next prompt.
Three constraints that live alongside the contract:
- Keep two restore strings, not one.
RESTORE(inline) must not contain?1049l;RESTORE_ALTmay. Emitting?1049lwithout a matching?1049hwarps the cursor on VTE terminals (Week 1, TUI Restoration §2). - Disable auto-wrap (
\x1b[?7l) during inline renders, and keep the?— without it you are addressing a different mode entirely. - No trailing
\x1b[Kon full-width rows. It erases the last character you drew (Week 5, TUI Restoration §5).
2. TUI Terminal Escape Sequences Reference
The following escape sequences are utilized by emojig’s rendering and input loops:
| Escape Sequence | Description | Mode |
|---|---|---|
\x1b[?1049h | Switch to alternate screen buffer | Alt-Screen |
\x1b[?1049l | Exit alternate screen buffer | Alt-Screen |
\x1b[?1000h | Enable standard mouse click tracking | Input |
\x1b[?1003h | Enable any-motion mouse tracking (drag/hover) | Input |
\x1b[?1006h | Enable SGR mouse coordinate reporting format | Input |
\x1b[?7l | Disable auto-wrap (required for inline renders) | Draw |
\x1b[?25l | Hide terminal cursor | Draw |
\x1b[?25h | Show terminal cursor | Draw |
\x1b[H | Home cursor to row 1, col 1 | Draw |
\x1b[2K | Erase active row completely | Draw |
\x1b[K | Erase to end of line — unsafe on full-width rows | Draw |
\x1b[0m | Reset character attributes | Draw |
\x1b[6n | Query Cursor Position (CPR) | Info |
\x1b[ROW;COLH | Absolute cursor repositioning | Draw |
Every one of these is a DEC private mode except H, K, 2K and 0m. The ? is load-bearing.
3. Project Skeleton: build.zig + Spec + @embedFile
The architecture the chronicle converges on has three moving parts: source of truth in YAML, generated JSON, embedded at compile time. Nothing is read from disk at runtime, so the result is still one static binary.
The pipeline
graph TD
Spec[spec/input.yaml] -->|make gen-spec| GenSpec[spec/.gen/input.json]
GenSpec -->|zig build| Embed[Comptime @embedFile]
Embed -->|Compile| Bin[build/emojig binary]
PNGs[website/about/*.png] -->|go run build_art.go| ArtSpec[spec/about_art.json]
ArtSpec -->|zig build| Embed
build.zig
Two targets from day one: a fast development build with the self-hosted backend, and a size-optimised static release.
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "picker",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Generated spec JSON is a build input, not a runtime file.
exe.root_module.addAnonymousImport("spec_input", .{
.root_source_file = b.path("spec/.gen/input.json"),
});
b.installArtifact(exe);
const run_step = b.step("picker", "Build and run the picker");
run_step.dependOn(&b.addRunArtifact(exe).step);
}
The spec
Start with one file, not five. Split it when a section earns its own schema.
# spec/input.yaml — single source of truth for keys and strings
strings:
search_prompt: "🔍:"
help_text: "Type to search, arrows to navigate, Enter to pick"
keys:
- { seq: "\x1b[A", action: move_up }
- { seq: "\x1b[B", action: move_down }
- { seq: "\r", action: select }
- { seq: "\x1b", action: quit }
The embed
const std = @import("std");
// The generated JSON is compiled into the binary. Nothing is read from disk at runtime.
const spec_json = @embedFile("spec_input");
pub const Spec = struct {
strings: struct {
search_prompt: []const u8,
help_text: []const u8,
},
};
/// Parsed once at startup into an arena that lives for the whole process,
/// so every string in `Spec` is valid for as long as the program runs.
pub fn loadSpec(arena: std.mem.Allocator) !Spec {
return std.json.parseFromSliceLeaky(
Spec,
arena,
spec_json,
.{ .ignore_unknown_fields = true },
);
}
Two properties follow from this arrangement, and neither depends on the file format. A missing
field is a type error against Spec rather than a blank screen, and a malformed spec fails at
make gen-spec with a filename attached, before the compiler is ever invoked. Validate the YAML
against a JSON schema in that generation step and the failure moves one stage earlier still.
4. The Verification Harness
Automated PTY harnesses cannot detect visual coordinate drift or terminal-specific cursor warps, and human eyes cannot detect a regression at 3 a.m. You need both, and you need to know which one covers what.
What the PTY harness covers
Drive the binary through a pseudo-terminal, feed it keystrokes, and assert on the bytes it writes back:
# Run Zig unit tests
zig build test --summary all
# Run the fuzzy-ranking validation suite
go test -v ./src/... -run TestRanking
# Run the PTY simulation script (TUI event loops)
go run scripts/test_tui/main.go
Assert on three things at minimum: that the output contains no \x1b[2J, that the final bytes are
a cursor-park sequence and nothing after it, and that the restore string matches the mode the TUI
actually entered.
What only human eyes cover
Per TUI Restoration §6, verify teardown manually on:
- A VTE terminal (GNOME Terminal, Tilix, Ptyxis) — the only place the
?1049lcursor warp is visible. - Foot — check its stderr log for unimplemented sequences and bad modes.
- Tmux — multiplexer compatibility.
And two states that no harness will stumble into by accident, because each is a single point in the parameter space:
- Exact terminal width, for the pending-wrap erase bug.
- Fast scrollbar drag, for split SGR reads.
Build targets
# Build dev target (fast, self-hosted backend, no LLVM)
zig build picker -fno-llvm
# Build release target (musl static binary, ReleaseSmall size-optimized)
zig build picker -Doptimize=ReleaseSmall --summary all
5. The First Three Canaries
A canary is a minimal probe that proves an external mechanism behaves as assumed, written before the feature that depends on it — see Canary-First Development. For a new TUI, write these three first. Each one takes minutes and each one corresponds to a bug in this book.
Canary 1 — Teardown. Enter raw mode, draw three rows, exit immediately. Run it in a VTE
terminal and in foot. Success is that the prompt returns exactly where it was, with no blank
line and no warp. This canary is the ?1049l bug, pre-caught.
Canary 2 — Input reassembly. Enable any-motion mouse tracking with a deliberately small read buffer, wave the mouse, and log every byte the parser classifies as text. Success is an empty log. Anything in it is a fragment your parser will one day type into an input field. This canary is the split SGR read, pre-caught.
Canary 3 — Full-width render. Draw one row spanning the entire terminal width, ending in a distinctive character, then resize the window by one column and redraw. Success is that the last character survives at both widths. This canary is the pending-wrap erase, pre-caught.
Only after all three pass is it worth writing a feature.
6. GNOME CSD Color Luminance Presets
Reference values from the themes tested in Week 5. These are measurements, not a lookup table to ship — the BT.709 function in that chapter derives the right answer for any background, including the ones not listed here.
| Theme Name | Background Color (Hex) | Calculated Luminance | Contrasted Text Color |
|---|---|---|---|
| Adwaita Dark | #303030 | 18.8% | #e0e0e0 (Light) |
| Adwaita Light | #ebebeb | 92.1% | #1e1e1e (Dark) |
| Yaru Aubergine | #2c001e | 2.4% | #e0e0e0 (Light) |
| Gruvbox Dark | #282828 | 15.6% | #e0e0e0 (Light) |
| Solarized Light | #fdf6e3 | 96.4% | #1e1e1e (Dark) |