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[Kon 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:
- Wayland GUI Integration: Supporting client-side decorations (CSD) under GNOME Wayland popups, ensuring text visibility regardless of the desktop theme.
- 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.zigutilities, 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.
- Refactor Output: Extracted clip-spawner controls to
src/clipboard.zigand isolated search ranking validation tests tosrc/ranking_test.zig.
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.yamlmapping 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
@embedFileexactly 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-1columns 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[Kto 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.zigto lazily construct a sorted index of synonym key indices (synonymOrder) at startup and perform binary search queries (synonymLowerBound) on key inputs.
Code Archaeology: Synonym Binary Search
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.