27 Aug 2026
Building gungnir, and the bugs that only showed up on a real screen
gungnir is a terminal emulator written from scratch in odin: glfw plus opengl, real freetype and harfbuzz text rendering, not a shortcut through stb_truetype. targeting kitty parity and then some. renamed from an earlier working name, otty, partway through. linux only right now, the pty layer is built directly on posix syscalls with no windows equivalent.
the harder, more honest part of the story isn't the feature list, it's that a big chunk of it got built with no display available in the working environment. everything compile-verified, everything logically reviewed, nothing actually looked at. when a real screen finally showed up, i ran a live visual-verification pass, driving it myself and reporting what i actually saw in real time. it found six real bugs sitting unverified in the tree. five got root-caused and fixed on the spot.
italic text corruption
printf '\e[3mitalic text here\e[0m' rendered as it 'ic text here, the "al" in "italic" collapsing into a blank cell followed by a stray oversized glyph fragment. root-caused with harfbuzz plus freetype directly instead of guessing from the screenshot: the cluster mapping was clean 1:1, but the font (maple mono nf italic) implements the "al" cursive connection as a contextual-alternate substitution, not a merged ligature cluster. the 'a' glyph becomes a real empty glyph, and 'l' becomes a wide connector glyph, about 1.8 cells wide with a large negative left bearing, that alone draws the joined shape. my glyph-fitting code gave that connector glyph a single-cell box and squashed it down, producing the fragment. fixed by tracking whether the previous column drew nothing, and letting the current glyph's natural bearing extend the fit-box leftward into that blank cell instead of forcing it into one.
split panes rendering blurry
comparing a cropped screenshot of the same prompt line in a split pane against a fresh non-split window showed the split-pane text visibly softer at the edges. the pane-rect math used plain float arithmetic with no pixel snapping, so at the default 0.5 split ratio on an odd pixel width, a pane's origin landed on a half-pixel boundary. the glyph atlas uses linear filtering, so any sub-pixel offset blurs every glyph in that pane through bilinear blending across texel edges. deterministic given the math, not a guess. fixed by rounding every rect at every level of the split-tree recursion, and computing a child's origin as an integer sum instead of a subtraction that could land off-grid.
the cursor going flaky in split panes
reports were inconsistent at first: missing in the unfocused pane, then the focused one, then working again. that inconsistency was itself the clue, a focus-state bug rather than a permanently broken draw path. the function that syncs each leaf pane's focused flag was only ever called from the pane-cycling keybind, never from splitting or closing a pane, and creating a new pane unconditionally marked it focused too. right after any split, both panes were focused simultaneously, each blinking independently, so at any instant either one could legitimately be mid-blink and look randomly missing. fixed by calling the sync function after every split and close.
panes not closing when their shell exits
running exit inside a split pane left it sitting there instead of closing, unlike a real terminal. the pty read call collapsed "no data right now" and "the child process actually died" into the exact same return value, so gungnir had no way to tell a quiet shell from a dead one. fixed by polling the child's exit status directly with waitpid, once per pane per frame, wired to close just that pane, or the tab, or the whole window if it was the last pane anywhere.
the copy shortcut grabbing the wrong thing
dragging a small text selection and hitting the copy shortcut put the entire visible screen on the clipboard instead of the highlighted text. the keybind was wired straight to a whole-screen dump utility, completely bypassing the actual mouse-drag selection state, which is why the on-screen highlight always looked correct, only the keyboard path was wrong. fixed to copy the active selection when one exists, falling back to the old behavior only when nothing is selected.
the nerd font icon crowding bug, and the fix that was wrong before it was right
icon lists (file-tree icons, status-bar icons) looked cramped and overlapping in gungnir but clean in kitty. the first fix i tried was cropping oversized glyphs to the cell, which made a different, unrelated rendering issue slightly better and this one worse. the real investigation used fontTools directly against the actual font file: a nerd font search icon has a bounding box about 1.9x wider than its own advance width. that's not a bug in the font, it's intentional, nerd font icons are deliberately drawn overflowing their single-cell advance box, and the terminal is expected to scale them down to fit. cropping just chops the overflow off flush against the cell edge with zero margin, which is exactly the glued-together look that got reported. the actual fix was a uniform scale-to-fit, picking whichever axis is more constrained and scaling both by the same factor before centering, leaving real margin instead of zero.
harfbuzz shaping, and a discovery i didn't expect
added real ligature support through harfbuzz: font runs grouped by bold/italic, shaped, glyphs mapped back to source columns through harfbuzz's cluster values. verified it with a throwaway offline harfbuzz test against the actual font file, cross-checked independently against python's uharfbuzz. the surprising part: shaping "a -> b == c != d" produced the exact same number of glyphs as input codepoints, no cluster collapsing. that first read as "no ligatures happening," which was the wrong conclusion. checking glyph ids instead of just counts showed a lone - shapes to one glyph, but - inside -> shapes to a shared connector glyph plus an arrow-specific completion glyph. this font (and, it turns out, every real terminal ligature font i checked later, fira code and hasklig included) implements ligatures as contextual alternates that swap each individual glyph for a connecting-half variant, not as true multi-codepoint substitution. that's not a quirk, it's structural: a real merged ligature would collapse multiple grid columns into one glyph, which breaks monospace column alignment, so no font actually built for terminal use does it that way, no matter how the marketing copy words it.
the font fallback bug that taught me to check which code path is actually live
added a fallback face for glyphs missing from the primary font (maple mono nf has zero unicode emoji, and gungnir was rendering any missing glyph as a blank .notdef box with no fallback at all). wired it into the plain per-cell glyph lookup, compiled clean, passed an offline fontTools check. tested live and a missing glyph still rendered as a blank box. the bug: ligatures default to on, and with ligatures on, the actual render loop draws by harfbuzz-shaped glyph index, a completely different lookup path than the one i patched. the fix i shipped first was, for all practical purposes, dead code, since almost nobody runs with ligatures off. real fix: tag any single-codepoint glyph harfbuzz couldn't resolve in the primary font with its source rune, and route just those specific glyphs through the fallback-aware lookup instead of the raw shaped one, leaving every normal glyph on the fast path unchanged. the lesson that's worth keeping: when a feature has two parallel code paths for the "same" thing, a fix applied to only one of them needs an explicit check for which path is actually live under the default config, not just whether the patched function looks right in isolation.
the neovim-quit stall that wasn't gungnir's bug at all
after :q in neovim, a solid blue screen with no text would sit for one to five seconds before the shell prompt came back. reviewed every recent change for anything that could cause a multi-second stall and found nothing conclusive by reading code alone, which was the right signal to stop guessing and get a real profile instead. ran perf record --call-graph dwarf while reproducing it, and the actual answer was sitting right there in the call chain: nvim's own shada (session file) write, all the way down through a real blocking fsync() syscall. fsync blocking for a few seconds under i/o pressure is an extremely common neovim complaint, completely independent of whatever terminal is hosting it, and it lined up with perf's own unprompted "check io/cpu overload" warning at the start of the capture. the "blue screen" was just nvim's last-rendered frame sitting frozen while nvim itself was stuck inside a syscall, not a gungnir rendering or color-state bug like i'd originally suspected. no gungnir code was implicated, no gungnir code got changed. the same profile did surface a real, unrelated gungnir problem though: font lookup was doing up to nine separate full recursive filesystem walks of the system font directories on every single startup, with zero caching between calls. collapsed into one walk, one in-memory catalog, same matching logic, one ninth the filesystem cost.
touchpad scroll doing nothing
a mouse wheel sends whole ±1.0 deltas per click. a touchpad's two-finger scroll sends a stream of small fractional deltas instead, and the scroll handler was truncating straight to an integer and bailing out on zero, which silently dropped almost every touchpad scroll event. fixed with a running fractional accumulator that carries the remainder forward between events instead of discarding it.
the throughline across basically all of this: every real bug here got found by measuring something directly, fonttools against the actual font file, a real harfbuzz shaping call, a real perf profile, a real live screen, rather than by reasoning about what should probably be happening. the guesses that felt plausible (crop the oversized glyph, it must be a gungnir color bug) were also, every time, wrong.