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

Week 5: Spec-Driven UI & CSD Styling

Theme — Passing for native: GNOME-style decorations drawn in ANSI, and search under 1 ms.

Skip this if — you do not draw chrome and your dataset is small enough that linear scans are free.

Takeaways

  • Copy this: compute text colour from the background’s BT.709 luminance instead of shipping a per-theme table. Six lines of arithmetic cover every theme that will ever exist.
  • Avoid this: a trailing \x1b[K on a row that spans the full terminal width — it erases the character you just drew. See the dead end below.
  • The check that caught it: measuring before optimising. The synonym table was the bottleneck; the fuzzy matcher, which looked more expensive, was not.

The Quest for Milliseconds and Wayland Integration

An instant-popup tool lives or dies by a budget of milliseconds: if a keystroke ever visibly lags, the illusion of a native widget collapses. Week 5 was spent buying those milliseconds back — and, on the GUI front, teaching a terminal emulator to impersonate a GNOME window so convincingly that the desktop cannot tell the difference.

During Week 5 (June 26 – July 2, 2026), the emojig project tackled two advanced engineering frontiers:

  1. Wayland GUI Integration: Supporting client-side decorations (CSD) under GNOME Wayland popups, ensuring text visibility regardless of the desktop theme.
  2. Fuzzy Search Optimization: Replacing linear database searches with binary searches to keep query latency under 1 millisecond.

Week 5 by the Numbers

  • Total Commits: 38.
  • Search Performance Boost: Search latency cut by 10x (from 4.2ms–5.1ms down to 0.36ms–0.61ms per keystroke).
  • Refactor Volume: Centralized 100+ raw ANSI escape literals into src/term.zig utilities, moving 145 LoC out of rendering loops.

Daily Chronology

June 27, 2026: Generic Settings & Modularization

We refactored the settings screen logic, replacing index-based hardcoded choices with generic, spec-driven rows parsed from spec/.gen/settings.json.


June 28, 2026: YAML Schema Migration

We transitioned all specification files from JSON to YAML (.yaml) to allow comments and cleaner syntax.

  • Features Added: central spec/input.yaml mapping input escape sequences and JSON schema validation scripts.
  • What did not change: the loader. YAML is generated down to JSON at build time and embedded with @embedFile exactly as before, so the Zig side of the Week 2 architecture was untouched by a whole-project format migration. From here on, the specs in this book are YAML.

June 30, 2026: Wayland CSD and ITU-R BT.709 Contrast

Under Wayland GNOME sessions, launching foot as a borderless popup overlay removes standard window borders. We introduced the --decorated flag to draw GNOME-style Client-Side Decoration (CSD) title bars directly in raw ANSI.

  • The Contrast Problem: If the user’s title bar theme is light gray, white title text is invisible. If the theme is dark, black text is invisible.
  • The Solution: We implemented auto-contrasting text. We parsed the RGB hex components of the title bar background color and computed the overall luminance using ITU-R BT.709 perceptual weights. If the luminance is below 50%, we write white title text; otherwise, we write black text.

Code Archaeology: Luminance Contrast Math

Here is the contrast algorithm implemented in src/host.zig (Line 184 onwards):

pub fn getContrastColor(bg_hex: []const u8) []const u8 {
    // Parse hex string RRGGBB to integers
    const r = parseHexByte(bg_hex[0..2]) catch 255;
    const g = parseHexByte(bg_hex[2..4]) catch 255;
    const b = parseHexByte(bg_hex[4..6]) catch 255;

    // ITU-R BT.709 luminance formula
    const luminance = (0.2126 * @as(f32, @floatFromInt(r)) +
                       0.7152 * @as(f32, @floatFromInt(g)) +
                       0.0722 * @as(f32, @floatFromInt(b))) / 255.0;

    // If background is light, return dark text (#1e1e1e), else return light (#e0e0e0)
    return if (luminance > 0.5) "#1e1e1e" else "#e0e0e0";
}

The weights are not arbitrary: green carries roughly 71% of perceived brightness, blue barely 7%. A naive (r+g+b)/3 average picks white text on saturated greens and dark text on deep blues — both unreadable. Measured presets for the themes we tested are in the Appendix; the function above is what makes the table unnecessary.

Figure 1: light theme — dark text, light chrome.

Figure 2: the same build on a dark desktop. No theme was configured for either shot — the background colour is read from the environment and every foreground colour, including the decoration text, is derived from it by the function above.

Dead End: The Border Cap That Was Never There

Drawing our own window chrome meant drawing rows that span the entire terminal width — and full-width rows have a rule of their own that we learned the hard way.

  • What we saw: the right-hand border cap (▐) at the end of the title bar was missing. Not mispositioned, not the wrong colour — absent, leaving the frame visibly open on one side. It reappeared the instant the window was resized by a single column, and vanished again when the width was restored.
  • What we assumed: an off-by-one in the width computation — that we were computing W-1 columns and simply never emitting the final character. We instrumented the writer and found the cap was being written. The bytes went out; the glyph did not stay.
  • What it actually was: pending wrap. Writing a character into the final column N does not advance the cursor to a new row; it leaves the terminal in a pending-wrap state with the cursor conceptually still on column N. Our row renderer ended, as every row renderer does, with \x1b[K to clear any leftovers to the right. From the pending-wrap state, that erase starts at column N — and deletes the character we had just drawn. One column narrower, the cursor had genuinely advanced, the erase started past the cap, and the bug disappeared. The bug was only ever visible at exactly-full width, which is exactly the width our own chrome always used.

The fix: rows that span the full terminal width never emit a trailing erase. They end with a plain attribute reset (\x1b[0m) instead, and the renderer is responsible for painting every cell it claims rather than clearing the tail. This is TUI Restoration §5, and it is the reason the appendix’s checklist tells you to test at exact width — a bug that only exists in one column of the parameter space will not be found by chance.


July 2, 2026: Synonym Binary Search Index

To drive search queries, we maintain synonym expansions (e.g. searching “happy” expands to “grinning face”). Previously, every keystroke triggered a linear $O(N)$ lookup table scan over all synonyms, which bloated search times to over 5 milliseconds.

  • The Binary Search Index: We refactored src/search.zig to lazily construct a sorted index of synonym key indices (synonymOrder) at startup and perform binary search queries (synonymLowerBound) on key inputs.

Here is the binary search implementation from src/search.zig (Line 294 onwards):

pub fn findSynonyms(query: []const u8) ?[]const []const u8 {
    var low: usize = 0;
    var high: usize = synonym_count;

    while (low < high) {
        const mid = low + (high - low) / 2;
        const mid_key = getSynonymKey(synonymOrder[mid]);
        const cmp = std.mem.order(u8, query, mid_key);

        switch (cmp) {
            .lt => high = mid,
            .gt => low = mid + 1,
            .eq => return getSynonymValues(synonymOrder[mid]),
        }
    }
    return null;
}

Note what is not sorted: the synonym table itself stays exactly where the spec put it. Only an index of key positions (synonymOrder) is sorted, built lazily on first use, so the embedded data remains a byte-for-byte copy of the spec and the ordering is a runtime detail.

  • Result: Query latencies dropped dramatically, ensuring characters never lag during rapid keyboard entry.

Week 5 verdict: sub-millisecond search and a GUI that passes for native GNOME. The features were done — but main.zig had swollen to nearly 4,800 lines along the way. Week 6 is the cleanup that makes the codebase worth maintaining.