A small immediate-mode GUI for Zig + SDL3 with Lua-scripted app logic and hot-reload. The same UI code is designed to run locally, or split across an optional "umbilical" socket (UI server ↔ UI client) for desktop, mobile, or a VPS — see Roadmap.
Status: Phase 1 + Phase 2 complete and verified (local window, widgets, layout, Lua logic, live hot-reload). Networking (Phase 3) and Android (Phase 4) are designed-for but not yet built.
The GUI is a function whose edges are plain data:
frame(state, input_events, ui) -> (state', command_buffer)
- The app logic (
frame) is authored in Lua (scripts/app.lua) and runs against a native Zig core. - Widgets don't paint pixels — they append to a command buffer; a layout engine resolves their rectangles.
- Because both the input and the command buffer are serializable, the same logic can later run in-process or across a socket without changing the UI code (the umbilical).
- Zig 0.17.0-dev (this targets the dev build's APIs: translate-c instead of
@cImport, unmanagedArrayList,createModule/root_module).build.zig.zonpinsminimum_zig_versionto the exact dev build (0.17.0-dev.389+f5a1968f6); older nightlies are rejected when this package is consumed as a dependency. - System libraries (Debian/Ubuntu package names):
libsdl3-dev libsdl3-ttf-dev libsdl3-image-dev libluajit-5.1-dev(pkg-config names:sdl3,sdl3-ttf,sdl3-image,luajit). - A TTF font (defaults to DejaVuSans at
/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf).
The build commands below invoke zig from your PATH. If you unpacked the dev
build to a custom directory (e.g. ~/apps/zig, as on this Debian 13 setup), add it
to PATH — and to ~/.bashrc to make it permanent:
# add to ~/.bashrc, then `source ~/.bashrc` (or open a new terminal)
export PATH="$HOME/apps/zig:$PATH"
zig version # -> 0.17.0-dev.389+...zig build # compile
zig build run # compile + run (Lua-driven UI from scripts/app.lua)
zig build test # run unit tests (interaction logic + hot-reload)The binary is zig-out/bin/zigui.
Config is read via SDL_getenv (the std args API is mid-rework in this Zig dev build):
| Var | Meaning |
|---|---|
ZIGUI_FONT |
Path to a .ttf (default: DejaVuSans) |
ZIGUI_SCRIPT |
Path to the Lua app (default: scripts/app.lua) |
ZIGUI_NATIVE |
If set, use the built-in native demo instead of Lua |
ZIGUI_FRAMES |
Render N frames then quit (for headless/CI; 0 = run forever) |
ZIGUI_SHOT |
Save a BMP screenshot of the last frame |
ZIGUI_LUA_DEBUG |
If set, attach to a local LuaPanda session (127.0.0.1:8818) — see Debugging |
Example headless smoke test:
ZIGUI_FRAMES=8 ZIGUI_SHOT=/tmp/shot.bmp ./zig-out/bin/ziguiTwo independent debuggers — use either alone or both in the same run.
zig build is a Debug build with symbols. In VS Code (with the C/C++ extension,
ms-vscode.cpptools) pick Debug zigui (gdb): it builds, launches zig-out/bin/zigui
under gdb, and stops at breakpoints in src/*.zig. Equivalent from a terminal:
gdb --args zig-out/bin/ziguiReal breakpoints/stepping in your live-edited Lua via the LuaPanda extension
(stuartwang.luapanda). The debugger core is vendored at scripts/LuaPanda.lua, and the
connection rides LuaSocket (a system package here) over 127.0.0.1 loopback — no networking
code in the app.
-
Install the LuaPanda VS Code extension.
-
Start the Lua: LuaPanda (listen 8818) debug session first — VS Code listens on 8818.
-
Run the app with the opt-in flag so it connects out:
ZIGUI_LUA_DEBUG=1 zig build run
-
Set breakpoints in
scripts/app.lua; interacting with the window hits them. Sinceframe()runs synchronously, the window pauses while you're stopped and resumes on continue. Hot-reload still works while debugging.
If no listener is up, ZIGUI_LUA_DEBUG=1 is a quiet no-op and the app runs normally. Loopback
only by design (remote/umbilical debugging is a later phase); stepping through Lua tail calls
under LuaJIT can be imperfect — a known LuaPanda limitation.
With the Lua: LuaPanda (listen 8818) session running, launch the app so it connects out:
ZIGUI_LUA_DEBUG=1 zig build runIn scripts/app.lua the counter is bumped inside a guard:
if ui.button("+") then
s.count = s.count + 1 -- ← set the breakpoint on THIS line
endSet the breakpoint on the s.count = s.count + 1 line and click + in the window — execution
stops there; inspect s and ui, step, continue (the window resumes). Edit the script and it
still hot-reloads mid-session.
Break on the statement, not a one-line if … then … end: that if line runs every frame
(the condition is polled each frame), so a breakpoint on it fires constantly. This is why the
project keeps each statement on its own line — .stylua.toml sets
collapse_simple_statement = "Never" (see Pre-commit gate), so the formatter
and editor never collapse a guarded statement back onto the if line.
The compound Dual debug (Zig gdb + Lua LuaPanda) starts the listener and the gdb session
together (it sets ZIGUI_LUA_DEBUG=1 for you): two concurrent sessions — native breakpoints in
src/*.zig and Lua breakpoints in scripts/app.lua. Switch between them in the Call Stack
panel. A gdb breakpoint halts the whole process; a LuaPanda breakpoint parks inside frame(), so
gdb still shows the process as running while you're stopped in Lua.
Edit scripts/app.lua while the app is running — it hot-reloads within a frame and your
state is preserved. Interactions are return values, not callbacks:
function frame(ui, s)
s.count = s.count or 0
ui.label("Hello")
-- a row: 48px button | filling label | 48px button
ui.row{ 48, -1, 48 }
if ui.button("-") then s.count = s.count - 1 end
ui.label("count: " .. s.count)
if ui.button("+") then s.count = s.count + 1 end
s.vol = ui.slider("volume", s.vol or 0.5, 0, 1)
s.dark = ui.checkbox("dark", s.dark or false)
endA custom widget is just a Lua function built from the same primitives:
local function counter(ui, label, v)
ui.row{ 40, -1, 40 }
if ui.button("-") then v = v - 1 end
ui.label(label .. ": " .. v)
if ui.button("+") then v = v + 1 end
return v
end| Call | Returns | Notes |
|---|---|---|
ui.label(text) |
– | left-aligned text |
ui.button(text) |
bool |
true on click |
ui.checkbox(text, value) |
bool |
the (possibly toggled) value |
ui.slider(text, value, min, max) |
number |
the (possibly dragged) value |
ui.row{ w1, w2, ... [, height] } |
– | column widths: >1 px, 0..1 fraction, <=0 fill |
If a row isn't declared, each widget gets its own full-width row.
Deep dive: a living design record — decisions (considered → chosen → why → status), dynamics, schematics, inspirations, and a dated decision log. Read it rendered at https://arkenidar.github.io/zig-sdl-gui/design/architecture.html (source: docs/design/architecture.html).
src/
cdefs.h C headers for the build's translate-c step (SDL3 + ttf + image + LuaJIT)
sdl.zig re-exports the translated C module as `c`
ui/
command.zig Command / InputEvent / Viewport / Rect / Color (the serializable boundary)
core.zig Context: layout engine, IDs, pointer model, public widget-building API
widgets.zig example widgets on the public API (replaceable userland) + tests
theme.zig colors + DPI-scaled metrics
render/
sdl_backend.zig window/renderer/font; renders the command buffer; text cache; input -> InputEvent
script/
lua.zig Zig<->LuaJIT binding; runs frame(); file-watch hot-reload + error guard + tests
main.zig entry point; wires backend + context + (Lua | native) loop
scripts/
app.lua THE app logic you edit live
build.zig{,.zon} targets Zig 0.17-dev; links the system libs via translate-c
Key design choices:
- Widgets are userland. The toolkit's value is the public API on
Context(layout/interaction/drawprimitives).widgets.zigis example widgets nothing depends on; you replace or compose freely (in Zig or Lua). corecalls no SDL. Widgets only appendCommands and read the unified pointer, so the exact same code can run headless on a server (Phase 3).- Touch-ready. Mouse and
SDL_EVENT_FINGER_*map to the sameInputEvents. - Text is measured, not embedded. The core measures via a backend callback so layout is exact; the backend rasterizes + caches glyph textures (tinted per draw via color-mod).
zig build test runs:
- widget interaction logic (button click across press+release, checkbox toggle, slider drag),
- Lua frame output + hot-reload (edit → reload → new output) + the bad-edit error guard.
A git hook autoformats and checks every commit. Enable it once per clone:
git config core.hooksPath .githooksOn commit, .githooks/pre-commit runs:
- format —
zig fmton staged.zig,styluaon staged.lua(the vendoredscripts/LuaPanda.luais excluded); reformatted files are re-staged. Lua style lives in.stylua.toml(2-space; statements kept on their own line so breakpoints can target them). - check —
zig buildthenzig build test; a failure aborts the commit.
It finds zig on PATH or at ~/apps/zig, and stylua on PATH or at ~/.local/bin
(install stylua from its releases or
cargo install stylua; if it's missing, the hook warns and skips Lua formatting rather than
blocking).
VS Code is set to match: .vscode/settings.json makes stylua the Lua formatter with
format-on-save (install the
stylua extension),
reading the same .stylua.toml — so on-save formatting equals the gate, no tug-of-war.
- Phase 3 — Umbilical:
net/with a length-prefixed codec +Transport { Local, Tcp }; modes--server/--client; two channels (remote-render and LuaScriptPush). Works desktop↔desktop, over a VPS, or to a phone viaadb reverse. - Phase 4 — Android: package the native host with SDL3; connect over the USB umbilical.
- Later: multi-session server, TLS/auth, scrolling/text-input widgets, image-heavy demos.
The architecture is built so these are additive — the frame/command-buffer boundary and the
public widget API don't change.