Why I Wrote a Terminal Emulator in Zig
Comptime platform selection, @Vector SIMD rendering, inline tests, and a 1.3MB binary. Why Zig was the right choice for teru.
Why I Wrote a Terminal Emulator in Zig
The first question people ask when they see teru is: “Why Zig?”
Fair question. Alacritty is Rust. WezTerm is Rust. Ghostty is actually Zig — so I’m in good company there — but most people assume a new terminal emulator means Rust. Here’s the honest answer.
Why Not Rust?
Rust is the right choice for a lot of systems software. Its ownership model is a genuine engineering achievement. But I had a specific set of requirements for teru:
- Comptime platform selection (compile away X11 or Wayland at build time, not runtime branching)
@VectorSIMD for the render path without reaching for platform intrinsics- Tests that live in the same file as the code they test, with zero ceremony
- A build system that’s actual code, not a DSL I have to relearn every six months
Rust can do some of these. #[cfg(...)] gets you conditional compilation. SIMD is possible via platform crates or std::simd (still unstable). But for teru specifically, Zig’s comptime and @Vector were a better fit — and the simplicity of the build system was a genuine quality-of-life win.
Comptime for Platform Selection
teru runs on X11 and Wayland. Rather than checking a runtime flag every frame, the platform code is selected at compile time. The build.zig exposes two options:
const enable_x11 = b.option(bool, "x11", "Enable X11 backend (default: true)") orelse true;
const enable_wayland = b.option(bool, "wayland", "Enable Wayland backend (default: true)") orelse true;
if (enable_x11) {
exe_mod.linkSystemLibrary("xcb", .{});
}
if (enable_wayland) {
exe_mod.linkSystemLibrary("wayland-client", .{});
}
These options flow into @import("build_options") in the source, where the platform selector uses comptime to include only the relevant backend:
zig build -Dwayland=false # X11-only, no wayland-client dependency
zig build -Dx11=false # Wayland-only, no libxcb dependency
The Wayland backend is not just disabled at runtime — it doesn’t exist in the binary. There’s no dead code, no absent function pointers, no branches that never execute. This is what comptime is for. In Rust you’d get something similar with features flags, but the ergonomics differ: Zig’s build system is a Zig program, so the logic expressing “if X11 is enabled, link xcb” is the same language you write everything else in.
@Vector for SIMD Rendering
teru’s renderer is CPU-only. No GPU, no OpenGL, no Vulkan. The renderer produces an ARGB pixel buffer; the platform layer (X11 SHM or Wayland SHM) copies it to screen. For a terminal — monospace grid, fixed-size cells, mostly static content — this is the right model. GPU overhead dominates at this workload size.
The hot path is glyph blitting: take a grayscale alpha value from the font atlas, blend it against foreground and background colors, write ARGB pixels to the framebuffer. teru does this four pixels at a time using @Vector:
// Vec4u16 = @Vector(4, u16)
const alphas = Vec4u16{ a0, a1, a2, a3 };
const inv_alphas = @as(Vec4u16, @splat(255)) - alphas;
const r_blended = (fg_r_vec * alphas + bg_r_vec * inv_alphas) / @as(Vec4u16, @splat(255));
const g_blended = (fg_g_vec * alphas + bg_g_vec * inv_alphas) / @as(Vec4u16, @splat(255));
const b_blended = (fg_b_vec * alphas + bg_b_vec * inv_alphas) / @as(Vec4u16, @splat(255));
// Pack back to ARGB u32
const r32: Vec4u32 = r_blended;
const g32: Vec4u32 = g_blended;
const b32: Vec4u32 = b_blended;
const a32: Vec4u32 = @splat(@as(u32, 0xFF));
const result = (a32 << @splat(24)) | (r32 << @splat(16)) | (g32 << @splat(8)) | b32;
dst[i..][0..4].* = result;
@Vector(4, u16) is a portable 128-bit vector type. On SSE2 and NEON it maps to a single register. On AVX2, the compiler fuses adjacent operations to 256-bit width automatically. There’s a scalar fallback for any remaining pixels that don’t fill a 4-wide chunk.
The key design constraint: zero allocations in the render path. All buffers are pre-allocated at init time or on resize. The SIMD path has no branching per pixel — for the hot case (opaque glyph pixels), alpha=255 is handled in the scalar fallback with an early return. The full frame for a 200-column grid runs in under 50 microseconds.
Inline Tests
Zig’s test system is one of the things I appreciate most about the language. Tests live in the same file as the code they test, in test blocks that compile to nothing in release builds:
test "CSI cursor movement" {
const allocator = std.testing.allocator;
var grid = try Grid.init(allocator, 24, 80);
defer grid.deinit(allocator);
var parser = VtParser.init(allocator, &grid);
// Move cursor to row 5, col 10 (1-based in VT, 0-based internally)
parser.feed("\x1b[5;10H");
try std.testing.expectEqual(@as(u16, 4), grid.cursor_row);
try std.testing.expectEqual(@as(u16, 9), grid.cursor_col);
// Cursor up 2
parser.feed("\x1b[2A");
try std.testing.expectEqual(@as(u16, 2), grid.cursor_row);
}
No separate tests/ directory. No test framework to install. std.testing.allocator catches leaks and double-frees automatically. teru has 250 tests covering the VT parser, grid, tiling engine, scrollback compression, session serialization, agent protocol, URL detector, font atlas, and renderer. They run in under a second:
make test # zig build test — all 250 tests, ~0.8s on an i7
This isn’t revolutionary, but it’s good design. Tests that live next to the code they cover tend to stay current. Tests in a separate directory have a way of drifting.
Binary Size
teru ships as a 1.3MB binary (ReleaseSafe + strip). There’s a ReleaseSmall profile at around 800KB if you want it.
The key decision: no FreeType, no fontconfig. Font rasterization uses stb_truetype.h, a single-header C library vendored directly into the repo. It handles glyph rasterization for ASCII, Latin-1, box-drawing, and block elements — 351 glyphs. That’s enough for a terminal. Full Unicode (CJK, emoji) is on the roadmap but not in the 1.3MB binary.
Runtime dependencies: three system libraries (libxcb, libxkbcommon, wayland-client). Clipboard uses xclip or wl-clipboard, exec’d at runtime when needed — not linked. No GTK. No EGL. No OpenGL loader.
For comparison: Ghostty is ~30MB, WezTerm is ~25MB, Alacritty is ~6MB.
The Build System
build.zig is a Zig program. There’s no separate DSL, no Makefile syntax embedded in XML, no YAML with special keys. You write functions, you call methods on the build graph, you use regular control flow. The platform option logic above is just an if statement.
I still use a Makefile as a thin wrapper for common profiles — make release, make dev, make test — because typing zig build -Doptimize=ReleaseSafe repeatedly gets old. But the actual build logic is in build.zig, and it’s readable:
make release # zig build -Doptimize=ReleaseSafe (1.3MB)
make release-small # zig build -Doptimize=ReleaseSmall (~800KB)
make dev # zig build (debug, full safety, 4MB)
make test # zig build test
The build compiles in a few seconds. The Zig compiler is fast.
What Zig Is Less Good At
I’d be doing a disservice to only write the positive side.
The ecosystem is small. If you need a well-maintained HTTP client, a mature async runtime, or a production-grade TLS library, Rust has better options today. Zig has std.http and std.crypto, but the third-party ecosystem is thin compared to crates.io.
IDE support is behind. zls works but it’s not at the level of rust-analyzer. Some completions are missing; some error highlighting is delayed.
Zig 0.16 is a development build. The API changes between releases are real. I hit several breaking changes migrating to 0.16 — std.posix.fork() was removed, file I/O migrated to a new std.Io interface, ArrayListUnmanaged init changed from .{} to .empty. None of these were hard to fix, but they required attention. This is the cost of building on a language before 1.0.
The Result
teru is a terminal emulator that compiles in seconds, runs at under 50 microseconds per frame, fits in 1.3MB, and has 250 tests that run in under a second. The platform code is compiled away rather than branched around at runtime. The SIMD path blends four pixels at a time without GPU dependencies.
Zig was the right choice for this project. The comptime system, @Vector, inline tests, and build-as-code were a better fit than what Rust offers for this specific workload and architecture.
If you’re building systems software that benefits from comptime metaprogramming or SIMD without a GPU, Zig is worth serious consideration. The language is not 1.0 yet, but it’s ready for projects like this one.
teru is open source, available on the AUR, and built from source with zig build. The architecture documentation covers the full module structure.