Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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[6n and read until the terminating R. 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[2K per row. Never \x1b[J or \x1b[2J — those destroy the user’s scrollback.
  • 5. Park the cursor with \x1b[row;1H at 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_ALT may. Emitting ?1049l without a matching ?1049h warps 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[K on 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 SequenceDescriptionMode
\x1b[?1049hSwitch to alternate screen bufferAlt-Screen
\x1b[?1049lExit alternate screen bufferAlt-Screen
\x1b[?1000hEnable standard mouse click trackingInput
\x1b[?1003hEnable any-motion mouse tracking (drag/hover)Input
\x1b[?1006hEnable SGR mouse coordinate reporting formatInput
\x1b[?7lDisable auto-wrap (required for inline renders)Draw
\x1b[?25lHide terminal cursorDraw
\x1b[?25hShow terminal cursorDraw
\x1b[HHome cursor to row 1, col 1Draw
\x1b[2KErase active row completelyDraw
\x1b[KErase to end of line — unsafe on full-width rowsDraw
\x1b[0mReset character attributesDraw
\x1b[6nQuery Cursor Position (CPR)Info
\x1b[ROW;COLHAbsolute cursor repositioningDraw

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:

  1. A VTE terminal (GNOME Terminal, Tilix, Ptyxis) — the only place the ?1049l cursor warp is visible.
  2. Foot — check its stderr log for unimplemented sequences and bad modes.
  3. Tmux — multiplexer compatibility.

And two states that no harness will stumble into by accident, because each is a single point in the parameter space:

  1. Exact terminal width, for the pending-wrap erase bug.
  2. 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 NameBackground Color (Hex)Calculated LuminanceContrasted Text Color
Adwaita Dark#30303018.8%#e0e0e0 (Light)
Adwaita Light#ebebeb92.1%#1e1e1e (Dark)
Yaru Aubergine#2c001e2.4%#e0e0e0 (Light)
Gruvbox Dark#28282815.6%#e0e0e0 (Light)
Solarized Light#fdf6e396.4%#1e1e1e (Dark)