(Don't tell me here. Make your docs better, so everyone benefits!)
#include <graphics.h> #include <conio.h>
int main() { int gd = DETECT, gm;
initgraph(&gd, &gm, "C:\\TURBOC3\\BGI");
circle(320, 240, 100);
getch();
closegraph();
return 0;
}Making some shapes and forms wasn't that much work either.
If I think back to VB and Windows (whatever it was then) making a basic window, form and some buttons was so simple and easy, they even made GUI builders because they were so good.
Somewhere along the lines GUIs became overly complex to implement.
> GPUI - Zed's GPU UI framework
Cool, but a comparison would also be very helpful.
If I decide to make a GUI app with Zig, how do I choose between Gooey and GPUI?
So far, all I know that GPUI is more mature and has at least one successful project built with it, so...
Also:
> Gooey: Turn (almost) any Python 3 Console Program into a GUI application with one line
(Author of Gooey [1], a GUI framework for WebASM in Go)
https://www.reddit.com/r/rust/comments/1tql7uf/microsofts_wi...
However, you need to remember that these simpler tools were a product of a much simpler set of requirements. Fixed themes, fixed screen size, fixed aspect ratios. I imagine a wysiwyg editor that gives you all the power of, say, CSS, and yet remains simple for simple things, sounds like a much more difficult task. I havenβt worked on UI in 20 years, so maybe such tools do exist.
Compared to the effort:quality of something like tkinter, LibertyBASIC put it to shame! Not to throw shade, tkinter is perfectly fine but I don't think I would have cared for it at that age.
It also taught me how to pirate software, when I found out the borland compiler required to make .exe's I could give my friends was $200 :)
Like, all of that should be expressable with just
<graph>
<circle />
</graph>And in early 2000, I was in a mailing list for designing a successor/replacement to X11, code-named "Gooey" that never went anywhere.
You can optimize a library to make it comparatively simple to draw a circle on a screen. But that tells me nothing about binding state, signals, styling, widget hierarchy, etc. Maybe these frameworks look complicated to you because doing something more than drawing a circle is actually more complicated.
#include <QApplication>
#include <QWidget>
#include <QPainter>
class widget : public QWidget {
void paintEvent(QPaintEvent*) override {
QPainter(this).drawEllipse(QPoint(320, 240), 100, 100);
}
};
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
widget w;
w.resize(640, 480);
w.show();
return app.exec();
}
It doesn't seem too complicated to me.That's the hard part. I'll take on incidental boilerplate (e.g. Elm) if the architecture helps me build and understand applications. Whatever gets me to that latter part.
GPUI is for rust, not zig
https://github.com/duanebester/gooey/blob/main/CLAUDE.md
But still, the project solves a legit pain point. And the author seems pretty hands-on with steering the technical implementation details.
The licensing is trying to paint a picture of LGPL being somehow uncertain/evil, the way Qt advocates non-idiomatic C++ practices - I could go on for days, but shall digress.
from tkinter import \*
root = Tk()
a = Label(root, text ="Hello World")
a.pack()
root.mainloop()The only updates it gets anymore are little data packs when laws/regulations change and it seems like they automated that because it's always ready before it's needed. The last "big" update was a guide to running it in parallels on new macs.
Also looks like a bit of introspection has happened ... https://github.com/duanebester/gooey/blob/main/docs/architec...
I wonder if this is just what we get now: low quality code, expressed rapidly. We are excited by the promise only to be disappointed by the reality of the implementation.
There are still a few new things around that are carefully and thoughtfully developed and put out into the world. zig itself. MitchellH's ghostty. And there's still all the older foundations of really wonderful, robust, software created by people like Linus Torvalds and couple of generations of open source devs, that applied great skill, ingenuity and hard work to produce the very best software.
But I fear that I'm in for a period of lamentation as we get wave after wave of promising sounding developments, but where the reality is low quality, LLM generated crap that you really shouldn't use if you want secure, stable performant, production-ready software.
Seems like perhaps we've been through a golden age of really great software and that now it's coming to a close.
(edited to fix spelling)
> Gooey is in better shape than most ~140 KLOC Zig codebases β every directory has a mod.zig, namespaces are layered, and core/interface_verify.zig provides compile-time platform-backend checks. But the architecture has drifted in a few
I too have used AI to plan cleaning up its own mess, and this self-congratulatory prose is extremely consistent ("every directory has a mod.zig", whoop dee woo!).
In my experience, AI is largely incapable of fixing its own mess to an actually competent degree (and full disclosure: I still ask it to, not pointing fingers here) and it's probably due to it walking on egg shells around its own feelings. I've had to tell it to completely change course during cleanup at least 30 times this week.
Also: https://xcancel.com/mitchellh/status/2060088112257372610
Have seen it from jobseekers trying to boost their profile with fake projects, founders trying to make their product more attractive to VCs, consultants trying to advertise their services...
I don't always have time for OSS, but every PR I've ever sent has always been hand written, and tested, and has taken into consideration the project coding style and architecture choices β I don't like this new world where developers can't even be bothered to write the docs.
There should be some way to measure (and display) how much of a project is understood by the humans behind it.
A year ago it would have taken someone months of nights and weekends effort to get this much code up and running. That person would have developed a good intuition for the architecture and where it should go.
Now Codex or Claude can bang it out in a couple days. You can try to have it do spec documents, code reviews, and cleanup passes but with today's tools these projects reach a point where it's just a swirling mess of pieces duct taped together in a way that passes tests. In my experiments, you quickly reach a point where the usable context depth (which is less than the 1M limits) keeps overflowing before you can get usable refactors in, and you're just going in circles. I know it's theoretically possible to avoid these problems, but in practice you get spaghetti projects like this.
That said, it fills a legit hole in the ecosystem and the author seems to be hands-on with the technical direction.
It's a real problem, so many projects are adding features at breakneck speed, but with so many bugs and so little understanding.
Maybe that's just how it all works now, but I don't like it.
What's so special about Zig dev that puts them aside from the giants they stand on?
A GPU-accelerated UI framework for Zig, targeting macOS (Metal), Linux (Vulkan/Wayland), and Browser (WASM/WebGPU).
Join the Gooey discord
Early Development: API is evolving.
Example app built with Gooey β chat-zig, an Anthropic Claude client using the Zig 0.16 std.Io stack for async HTTP:
ui.* primitives and flexbox-style systemCx for state, handlers, and focus; ui.* for layout primitivesanimateOn triggerspointer_events controlRequirements: Zig 0.16.0+
Dependencies: None. Gooey has zero external Zig package dependencies β build.zig.zon lists no dependencies. It links only against platform system frameworks/libraries (see platform notes below).
macOS: macOS 12.0+
Linux: Wayland compositor, Vulkan drivers, FreeType, HarfBuzz, Fontconfig, libpng, D-Bus
zig build run # Showcase demo
zig build run-counter # Counter example
zig build run-todo # Todo app (state, handlers, TextInput, lists)
zig build run-animation # Animation demo
zig build run-pomodoro # Pomodoro timer
zig build run-glass # Liquid glass effect
zig build run-spaceship # Space dashboard with shader
zig build run-dynamic-counters # Entity system demo
zig build run-layout # Flexbox, shrink, text wrapping
zig build run-actions # Keybindings demo
zig build run-select # Dropdown select component
zig build run-tooltip # Tooltip component
zig build run-modal # Modal dialogs
zig build run-images # Image loading and styling
zig build run-file-dialog # Native file dialogs
zig build run-uniform-list # Virtualized list (10k items)
zig build run-virtual-list # Variable-height list
zig build run-data-table # Virtualized table (10k rows)
zig build run-code-editor # Code editor with syntax highlighting
zig build test # Run tests
A small todo app that touches a representative slice of the API: a pure,
UI-free state model; cx.update / cx.updateWith / cx.command handlers; a
bound TextInput; Checkbox and Button; list iteration; and unit tests that
exercise the state with no UI in play.
The full, runnable source lives in src/examples/todo.zig
(zig build run-todo). Its state model is covered by the tests shown at the
bottom, which run as part of zig build test.
const std = @import("std");
const gooey = @import("gooey");
const ui = gooey.ui;
const Cx = gooey.Cx;
const Button = gooey.components.Button;
const Checkbox = gooey.components.Checkbox;
const TextInput = gooey.components.TextInput;
const MAX_TODOS = 64;
const TEXT_CAP = 128;
const draft_input_id = "new-todo";
// State is pure β no UI knowledge, fully testable.
const Todo = struct {
buf: [TEXT_CAP]u8 = [_]u8{0} ** TEXT_CAP,
len: usize = 0,
done: bool = false,
fn text(self: *const Todo) []const u8 {
return self.buf[0..self.len];
}
};
const Filter = enum { all, active, done };
const AppState = struct {
todos: [MAX_TODOS]Todo = [_]Todo{.{}} ** MAX_TODOS,
count: usize = 0,
draft: []const u8 = "", // two-way bound to the TextInput
filter: Filter = .all,
// Pure logic β what the tests below drive.
fn pushTodo(self: *AppState, value: []const u8) void {
const trimmed = std.mem.trim(u8, value, " \t\r\n");
if (trimmed.len == 0) return;
if (self.count >= MAX_TODOS) return;
const slot = &self.todos[self.count];
const n = @min(trimmed.len, TEXT_CAP);
@memcpy(slot.buf[0..n], trimmed[0..n]);
slot.len = n;
slot.done = false;
self.count += 1;
}
pub fn toggle(self: *AppState, index: usize) void {
if (index >= self.count) return;
self.todos[index].done = !self.todos[index].done;
}
pub fn remove(self: *AppState, index: usize) void {
if (index >= self.count) return;
var i = index;
while (i + 1 < self.count) : (i += 1) self.todos[i] = self.todos[i + 1];
self.count -= 1;
}
pub fn setFilter(self: *AppState, filter: Filter) void {
self.filter = filter;
}
pub fn clearCompleted(self: *AppState) void {
var write: usize = 0;
var read: usize = 0;
while (read < self.count) : (read += 1) {
if (!self.todos[read].done) {
self.todos[write] = self.todos[read];
write += 1;
}
}
self.count = write;
}
fn remaining(self: *const AppState) u32 {
var n: u32 = 0;
for (self.todos[0..self.count]) |*t| {
if (!t.done) n += 1;
}
return n;
}
fn visible(self: *const AppState, t: *const Todo) bool {
return switch (self.filter) {
.all => true,
.active => !t.done,
.done => t.done,
};
}
// Command β needs framework access (the binding only flows widget -> state,
// so we reach the retained input widget to clear it after adding).
pub fn addTodo(self: *AppState, g: *gooey.Window) void {
self.pushTodo(self.draft);
self.draft = "";
if (g.widgetState(gooey.widgets.TextInputState, draft_input_id)) |input| {
input.clear();
}
}
};
var state = AppState{};
const App = gooey.App(AppState, &state, render, .{
.title = "Todos",
.width = 480,
.height = 560,
});
comptime {
_ = App; // Force analysis (also wires @export on WASM).
}
pub fn main(init: std.process.Init) !void {
return App.main(init);
}
fn render(cx: *Cx) void {
const s = cx.state(AppState);
const size = cx.windowSize();
cx.render(ui.box(.{
.width = size.width,
.height = size.height,
.direction = .column,
.padding = .{ .all = 24 },
.gap = 16,
.background = ui.Color.rgb(0.96, 0.96, 0.97),
}, .{
ui.text("Todos", .{ .size = 28 }),
// Input row: TextInput binds to state.draft; Add is a command.
ui.hstack(.{ .gap = 8, .alignment = .center }, .{
TextInput{ .id = draft_input_id, .placeholder = "What needs doing?", .bind = &s.draft, .fill_width = true },
Button{ .label = "Add", .on_click_handler = cx.command(AppState.addTodo) },
}),
// Filters: each button packs its enum value into the handler arg.
ui.hstack(.{ .gap = 8 }, .{
FilterButton{ .label = "All", .filter = .all, .active = s.filter == .all },
FilterButton{ .label = "Active", .filter = .active, .active = s.filter == .active },
FilterButton{ .label = "Done", .filter = .done, .active = s.filter == .done },
}),
// The list, or an empty-state hint.
ui.when(s.count == 0, .{
ui.text("Nothing yet β add your first todo above.", .{ .size = 14 }),
}),
TodoItems{},
ui.spacer(),
ui.hstack(.{ .gap = 12, .alignment = .center }, .{
ui.textFmt("{d} left", .{s.remaining()}, .{ .size = 14 }),
ui.spacer(),
Button{ .label = "Clear completed", .variant = .secondary, .size = .small, .on_click_handler = cx.update(AppState.clearCompleted) },
}),
}));
}
// Iteration lives in a component because each row needs `cx` for its handlers.
const TodoItems = struct {
pub fn render(_: @This(), cx: *Cx) void {
const s = cx.state(AppState);
for (s.todos[0..s.count], 0..) |*todo, index| {
if (!s.visible(todo)) continue;
cx.render(TodoRow{ .index = index, .done = todo.done, .label = todo.text() });
}
}
};
const TodoRow = struct {
index: usize,
done: bool,
label: []const u8,
pub fn render(self: @This(), cx: *Cx) void {
// A background + cross-axis centering means this is a `box` (with
// `.direction = .row`), not an `hstack` β stacks carry only gap/
// alignment/padding.
cx.render(ui.box(.{
.direction = .row,
.gap = 12,
.alignment = .{ .cross = .center },
.padding = .{ .all = 10 },
.background = ui.Color.white,
.corner_radius = 8,
}, .{
Checkbox{ .checked = self.done, .on_click_handler = cx.updateWith(self.index, AppState.toggle) },
ui.text(self.label, .{ .size = 16 }),
ui.spacer(),
Button{ .label = "Delete", .variant = .danger, .size = .small, .on_click_handler = cx.updateWith(self.index, AppState.remove) },
}));
}
};
const FilterButton = struct {
label: []const u8,
filter: Filter,
active: bool,
pub fn render(self: @This(), cx: *Cx) void {
cx.render(Button{
.label = self.label,
.size = .small,
.variant = if (self.active) .primary else .secondary,
.on_click_handler = cx.updateWith(self.filter, AppState.setFilter),
});
}
};
// State is testable without UI.
test "remove keeps the list contiguous" {
var s = AppState{};
s.pushTodo("a");
s.pushTodo("b");
s.pushTodo("c");
s.remove(1); // drop "b"
try std.testing.expectEqual(@as(usize, 2), s.count);
try std.testing.expectEqualStrings("a", s.todos[0].text());
try std.testing.expectEqualStrings("c", s.todos[1].text());
}
test "remaining and clearCompleted" {
var s = AppState{};
s.pushTodo("a");
s.pushTodo("b");
s.toggle(0);
try std.testing.expectEqual(@as(u32, 1), s.remaining());
s.clearCompleted();
try std.testing.expectEqual(@as(usize, 1), s.count);
try std.testing.expectEqualStrings("b", s.todos[0].text());
}
Gooey separates concerns between Cx (context) and ui (layout primitives):
| Module | Purpose | Examples |
|---|---|---|
cx.* |
State, handlers, animations, focus | cx.state(), cx.update(), cx.animate(), cx.changed(), cx.render() |
ui.* |
Layout containers and primitives | ui.box(), ui.rect(), ui.hstack(), ui.vstack(), ui.text(), ui.when() |
fn render(cx: *Cx) void {
const s = cx.state(AppState);
cx.render(ui.box(.{ .width = 100 }, .{
ui.text("Hello", .{}),
// Conditional rendering
ui.when(s.show_extra, .{
ui.text("Extra content", .{}),
}),
// Iterate over items
ui.each(&s.items, struct {
fn render(item: Item, _: usize) @TypeOf(ui.text("", .{})) {
return ui.text(item.name, .{});
}
}.render),
}));
}
Key primitives:
ui.box() - Container with flexbox layoutui.rect() - Childless box (dividers, spacers, colored blocks)ui.hstack() / ui.vstack() - Horizontal/vertical stacksui.text() / ui.textFmt() - Text renderingui.when(cond, children) - Conditional renderingui.maybe(optional, fn) - Render if optional has valueui.each(items, fn) - Render for each itemui.scroll(id, style, children) - Scrollable containerui.spacer() - Flexible space| Method | Signature | Use Case |
|---|---|---|
cx.update() |
fn(*State) void |
Pure state mutations |
cx.updateWith() |
fn(*State, Arg) void |
Mutations with argument |
cx.command() |
fn(*State, *Gooey) void |
Framework access (focus, quit, entities) |
cx.commandWith() |
fn(*State, *Gooey, Arg) void |
Framework access with argument |
cx.defer() |
fn(*State, *Gooey) void |
Run after current event completes |
cx.deferWith() |
fn(*State, *Gooey, Arg) void |
Deferred with argument |
Note: The state type is inferred automatically from the method pointer's first parameter β no need to pass it separately.
The *With variants (updateWith, commandWith, deferWith) let you pass data to your handler. The argument is captured at handler creation time and passed when invoked:
// In a list render callback - capture the index
.on_click_handler = cx.updateWith(index, State.selectItem),
// The handler receives the captured value
pub fn selectItem(self: *State, index: u32) void {
self.selected = index;
}
The 8-byte limit: Arguments are packed into a u64 for zero-allocation storage. This means your argument must be β€8 bytes. If it exceeds this, you'll get a compile error:
error: updateWith: argument type 'MyLargeStruct' exceeds 8 bytes. Use a pointer or index instead.
What fits in 8 bytes:
| Type | Size | β/β |
|---|---|---|
u8, i8, bool |
1 byte | β |
u16, i16 |
2 bytes | β |
u32, i32, f32 |
4 bytes | β |
u64, i64, f64 |
8 bytes | β |
usize (64-bit) |
8 bytes | β |
*T (any pointer) |
8 bytes | β |
struct { x: u32, y: u32 } |
8 bytes | β |
[2]u32 |
8 bytes | β |
struct { a: u32, b: u32, c: u32 } |
12 bytes | β |
Workarounds for larger data:
// Option 1: Use an index into your data
.on_click_handler = cx.updateWith(row_index, State.selectRow),
// Option 2: Use a pointer (if the data outlives the handler)
.on_click_handler = cx.updateWith(&self.items[i], State.editItem),
// Option 3: Store data in state, pass an ID
pub fn openFile(self: *State, file_id: u32) void {
const file = self.files.get(file_id) orelse return;
// ... use file.path, file.name, etc.
}
Use defer when you need to run code after the current event handler completes. This is essential for:
// In a command handler, use g.deferCommand():
pub fn openFolder(self: *State, g: *Gooey) void {
_ = self;
g.deferCommand(State, State.openFolderDeferred);
}
fn openFolderDeferred(self: *State, g: *Gooey) void {
_ = g;
// Safe to open modal dialog here - we're outside event handling
const file_dialog = gooey.file_dialog;
if (file_dialog.promptForPaths(allocator, .{ .directories = true })) |result| {
defer result.deinit();
const path = result.paths[0];
self.loadDirectory(path);
}
}
// With an argument (same 8-byte limit applies):
pub fn deleteItem(self: *State, g: *Gooey, index: u32) void {
_ = self;
g.deferCommandWith(State, u32, index, State.confirmDelete);
}
fn confirmDelete(self: *State, g: *Gooey, index: u32) void {
_ = g;
if (dialog.confirm("Delete item?")) {
self.items.remove(index);
}
}
The deferred command queue holds up to 32 commands and is flushed after each event cycle.
Io.Queue / Io.Group)Run expensive work β network requests, file I/O, heavy computation β off the UI thread using Zig 0.16's std.Io. The framework owns no executor of its own: background tasks are spawned with cx.io().async(...), hand their results back through a bounded std.Io.Queue(T), and the render loop drains that queue each frame. Background tasks never touch UI state directly β they only push typed results β so there are no locks on your state.
This is the same pattern src/image/loader.zig uses for async image URL fetches.
// A typed result the background task hands back to the render loop.
const Fetch = union(enum) {
ok: []const u8,
failed,
};
const State = struct {
// Fixed-capacity, statically-backed channel β no allocation after init.
result_buffer: [16]Fetch = undefined,
result_queue: std.Io.Queue(Fetch) = undefined,
// Owns the in-flight task(s) so they can be cancelled together.
fetch_group: std.Io.Group = .init,
response: []const u8 = "",
// Kick off background work from a handler β runs off the UI thread.
pub fn startFetch(self: *State, cx: *Cx) void {
const url = "https://api.example.com/data";
self.result_queue = .init(&self.result_buffer);
// `io` is passed twice: once to drive `async`, and again inside the
// args tuple so the task body can push into the queue.
self.fetch_group.async(cx.io(), fetchData, .{ cx.io(), url, &self.result_queue });
// Auto-cancel on window close so a late task can't write into freed state.
cx.registerCancelGroup(&self.fetch_group);
}
};
// Background task β never touches UI state, only pushes a typed result.
fn fetchData(io: std.Io, url: []const u8, queue: *std.Io.Queue(Fetch)) void {
const body = httpGet(io, url) catch {
queue.putOneUncancelable(io, .failed) catch {};
return;
};
queue.putOneUncancelable(io, .{ .ok = body }) catch {};
}
fn render(cx: *Cx) void {
const s = cx.state(State);
// Non-blocking drain β safe to call every frame from `render`.
var buffer: [16]Fetch = undefined;
for (cx.drainQueue(Fetch, &s.result_queue, &buffer)) |result| switch (result) {
.ok => |body| s.response = body,
.failed => {},
}
// ... build UI from s.response ...
}
Key pieces:
cx.io() β the std.Io instance threaded through the framework from main(). Pass it to async, queue, and timing calls.cx.io().async(fn, .{args}) (or group.async(io, fn, .{args})) β spawn background work. Pass io inside the args tuple too if the task needs to push into a queue.std.Io.Queue(T) β bounded, lock-free, statically-backed channel. Tasks push with putOneUncancelable(io, value); capacity is fixed at init (no allocation afterward).cx.drainQueue(T, &queue, &buffer) β non-blocking drain into your buffer; returns an empty slice when nothing is ready, so it's safe to call every frame.std.Io.Group β owns one or more in-flight tasks so they can be cancelled together.cx.registerCancelGroup(&group) β auto-cancel a group on window close (pair with cx.unregisterCancelGroup if the work finishes normally). For per-entity lifecycles, use cx.entities.attachCancel(id, &group) to cancel when the entity is removed.Note: This rides on Zig 0.16's
std.Io, so the threaded backend is unavailable on WASM (single-threaded) β see WASM. The earliercx.dispatchBackground/dispatchOnMainThread/dispatchAfterAPIs were removed in thestd.Iomigration; theIo.Queue+Io.Grouppattern above replaces them. Seedocs/zig-0.16-io-migration.md.
By default, Gooey uses the platform's system sans-serif font (e.g., DejaVu Sans on Linux, SF Pro on macOS, system-ui on web). You can set a custom font at app init or switch fonts at runtime.
Set .font in your app config to use any font installed on the system:
const App = gooey.App(AppState, &state, render, .{
.title = "My App",
.font = "Inter",
.font_size = 16.0, // optional, defaults to 16.0
});
Omitting .font uses the platform default. On Linux, any font discoverable by Fontconfig works β install fonts via your package manager (e.g., sudo apt install fonts-inter) or drop .ttf/.otf files into ~/.local/share/fonts/.
Change the font on the fly from any event handler:
fn onSettingsChanged(cx: *Cx) void {
const s = cx.state(AppState);
cx.setFont(s.font_name, s.font_size) catch {};
}
This clears the glyph and shape caches and triggers a re-render automatically. All text in the UI updates immediately.
| Platform | Font Discovery | System Sans-Serif |
|---|---|---|
| Linux | Fontconfig | sans-serif (typically DejaVu Sans or Noto Sans) |
| macOS | CoreText | SF Pro |
| Web | CSS font stack | system-ui, -apple-system, sans-serif |
Note: Gooey currently uses a single global font. Per-component font families (e.g., mixing a serif body font with a monospace code font) are not yet supported β components expose
font_sizebut notfont_family.
Gooey ships with two built-in themes β Theme.light (Catppuccin Latte) and Theme.dark (Catppuccin Macchiato). Set the active theme before rendering:
fn render(cx: *Cx) void {
cx.setTheme(if (s.dark_mode) &Theme.dark else &Theme.light);
// ...
}
Define a light/dark pair of Theme values and swap between them the same way as the built-ins. Every field has a semantic role so components resolve colors automatically without per-component overrides:
const my_light = gooey.Theme{
.bg = Color.rgb(0.97, 0.97, 0.98),
.surface = Color.rgb(0.93, 0.93, 0.95),
.overlay = Color.rgb(0.88, 0.88, 0.91),
.primary = Color.rgb(0.20, 0.50, 0.90),
.secondary = Color.rgb(0.45, 0.48, 0.58),
.accent = Color.rgb(0.55, 0.25, 0.85),
.success = Color.rgb(0.20, 0.65, 0.30),
.warning = Color.rgb(0.85, 0.60, 0.10),
.danger = Color.rgb(0.82, 0.24, 0.24),
.text = Color.rgb(0.15, 0.15, 0.20),
.subtext = Color.rgb(0.35, 0.37, 0.45),
.muted = Color.rgb(0.55, 0.57, 0.65),
.border = Color.rgba(0.55, 0.57, 0.65, 0.3),
.border_focus = Color.rgb(0.20, 0.50, 0.90),
.radius_sm = 4,
.radius_md = 8,
.radius_lg = 16,
.font_size_base = 14,
};
const my_dark = gooey.Theme{
.bg = Color.rgb(0.10, 0.10, 0.12),
.surface = Color.rgb(0.15, 0.15, 0.18),
.overlay = Color.rgb(0.20, 0.20, 0.24),
.primary = Color.rgb(0.40, 0.70, 1.00),
.secondary = Color.rgb(0.45, 0.48, 0.58),
.accent = Color.rgb(0.75, 0.55, 0.95),
.success = Color.rgb(0.45, 0.85, 0.55),
.warning = Color.rgb(0.95, 0.80, 0.35),
.danger = Color.rgb(0.95, 0.40, 0.40),
.text = Color.rgb(0.92, 0.92, 0.95),
.subtext = Color.rgb(0.70, 0.72, 0.80),
.muted = Color.rgb(0.50, 0.52, 0.60),
.border = Color.rgba(0.50, 0.52, 0.60, 0.3),
.border_focus = Color.rgb(0.40, 0.70, 1.00),
.radius_sm = 4,
.radius_md = 8,
.radius_lg = 16,
.font_size_base = 14,
};
fn render(cx: *Cx) void {
cx.setTheme(if (s.dark_mode) &my_dark else &my_light);
// ...
}
The font_size_base field (default 14) is the single source of truth for text sizing across components. Components scale relative to it β for example, Button derives its per-size font sizes as:
| Button size | Font size |
|---|---|
.small |
base - 2 (12) |
.medium |
base (14) |
.large |
base + 2 (16) |
Set it once in your theme and every component scales consistently β no per-component font size overrides needed:
const large_text_theme = gooey.Theme{
// ...colors...
.font_size_base = 18, // small=16, medium=18, large=20
};
Gooey includes ready-to-use components:
// Button variants
Button{ .label = "Save", .variant = .primary, .on_click_handler = cx.update(State.save) }
Button{ .label = "Cancel", .variant = .secondary, .size = .small, .on_click_handler = ... }
Button{ .label = "Delete", .variant = .danger, .on_click_handler = ... }
// Single-line text input with binding
TextInput{
.id = "email",
.placeholder = "Enter email...",
.bind = &s.email,
.width = 250,
}
// Multi-line text area
TextArea{
.id = "notes",
.placeholder = "Enter notes...",
.bind = &s.notes,
.width = 400,
.height = 200,
}
Checkbox{
.id = "terms",
.checked = s.agreed_to_terms,
.on_click_handler = cx.update(State.toggleTerms),
}
// RadioButton - individual buttons for custom layouts
RadioButton{
.label = "Email",
.is_selected = s.contact_method == 0,
.on_click_handler = cx.updateWith(@as(u8, 0), State.setContactMethod),
}
// RadioGroup - grouped buttons with handlers array
RadioGroup{
.id = "priority",
.options = &.{ "Low", "Medium", "High" },
.selected = s.priority,
.handlers = &.{
cx.updateWith(@as(u8, 0), State.setPriority),
cx.updateWith(@as(u8, 1), State.setPriority),
cx.updateWith(@as(u8, 2), State.setPriority),
},
.direction = .row, // or .column
.gap = 16,
}
const State = struct {
selected_fruit: ?usize = null,
pub fn selectFruit(self: *State, index: usize) void {
self.selected_fruit = index;
}
};
// In render:
Select{
.id = "fruit-select",
.options = &.{ "Apple", "Banana", "Cherry", "Date" },
.selected = s.selected_fruit,
.placeholder = "Choose a fruit...",
.on_select = cx.onSelect(State.selectFruit),
.width = 200,
}
The widget manages open/close state internally β no toggle/close handlers or per-option handler arrays needed. Just provide on_select and a single handler that receives the selected index.
Legacy API: The explicit
is_open/on_toggle_handler/on_close_handler/handlersfields are still supported for full manual control.
const State = struct {
show_confirm: bool = false,
pub fn openConfirm(self: *State) void {
self.show_confirm = true;
}
pub fn closeConfirm(self: *State) void {
self.show_confirm = false;
}
};
// Trigger button
Button{ .label = "Delete Item", .variant = .danger, .on_click_handler = cx.update(State.openConfirm) }
// Modal with custom content
Modal(ConfirmContent){
.id = "confirm-dialog",
.is_open = s.show_confirm,
.on_close = cx.update(State.closeConfirm),
.child = ConfirmContent{
.message = "Are you sure you want to delete?",
.on_confirm = cx.update(State.doDelete),
.on_cancel = cx.update(State.closeConfirm),
},
.animate = true,
.close_on_backdrop = true,
}
// Wrap any component with a tooltip
Tooltip(Button){
.text = "Click to save your changes",
.child = Button{ .label = "Save", .on_click_handler = ... },
.position = .top, // .top, .bottom, .left, .right
}
// With custom styling
Tooltip(IconButton){
.text = "This field is required",
.child = HelpIcon{},
.position = .right,
.max_width = 200,
.background = Color.rgb(0.2, 0.2, 0.25),
}
// Simple image from path
gooey.Image{ .src = "assets/logo.png" }
// With explicit sizing
gooey.Image{ .src = "photo.jpg", .width = 200, .height = 150 }
// Rounded avatar
gooey.Image{ .src = "avatar.png", .size = 48, .rounded = true }
// Cover image (fills container, may crop)
gooey.Image{ .src = "banner.jpg", .width = 800, .height = 200, .fit = .cover }
// With effects
gooey.Image{
.src = "icon.png",
.size = 64,
.grayscale = 1.0, // 0.0 = color, 1.0 = grayscale
.tint = gooey.Color.blue, // Color overlay
.opacity = 0.8,
.corner_radius = 8,
}
const gooey = @import("gooey");
const Svg = gooey.Svg;
const Icons = gooey.Icons;
// Using built-in icon paths
Svg{ .path = Icons.star, .size = 24, .color = Color.gold }
Svg{ .path = Icons.check, .size = 20, .color = Color.green }
Svg{ .path = Icons.close, .size = 16, .color = Color.red }
// Stroked icon (outline only)
Svg{ .path = Icons.star_outline, .size = 24, .stroke_color = Color.white, .stroke_width = 2 }
// Both fill and stroke
Svg{ .path = Icons.favorite, .size = 24, .color = Color.red, .stroke_color = Color.black, .stroke_width = 1 }
// Available icons: arrow_back, arrow_forward, menu, close, more_vert,
// check, add, remove, edit, delete, search, star, star_outline, favorite,
// info, warning, error_icon, play, pause, skip_next, skip_prev, volume_up,
// visibility, visibility_off, folder, file, download, upload
ProgressBar{
.progress = s.completion, // 0.0 to 1.0
.width = 200,
.height = 8,
.corner_radius = 4,
}
// Individual tabs for custom navigation
cx.render(ui.hstack(.{ .gap = 4 }, .{
Tab{
.label = "Home",
.is_active = s.tab == 0,
.on_click_handler = cx.updateWith(@as(u8, 0), State.setTab),
},
Tab{
.label = "Settings",
.is_active = s.tab == 1,
.on_click_handler = cx.updateWith(@as(u8, 1), State.setTab),
.style = .underline, // .pills (default), .underline, .segmented
},
}))
Virtualized list for efficiently rendering large datasets with uniform item heights. Only visible items are rendered, regardless of total count. The render callback receives *Cx for full access to state and handlers.
const State = struct {
list_state: UniformListState = UniformListState.init(10_000, 32.0), // count, item height
selected: ?u32 = null,
pub fn scrollToTop(self: *State) void {
self.list_state.scrollToTop();
}
pub fn scrollToMiddle(self: *State) void {
self.list_state.scrollToItem(5000, .center);
}
pub fn selectItem(self: *State, index: u32) void {
self.selected = index;
}
};
// In render function:
fn render(cx: *Cx) void {
const s = cx.state(State);
cx.uniformList("my-list", &s.list_state, .{
.fill_width = true,
.grow_height = true,
}, renderItem);
}
fn renderItem(index: u32, cx: *Cx) void {
const s = cx.stateConst(State);
const theme = cx.theme();
const is_selected = if (s.selected) |sel| sel == index else false;
// Color is available via: const Color = gooey.Color;
const text_color = if (is_selected) Color.white else theme.text;
cx.render(ui.box(.{
.fill_width = true,
.height = 32,
.background = if (is_selected) theme.primary else null,
.hover_background = theme.overlay,
.on_click_handler = cx.updateWith(index, State.selectItem),
}, .{
ui.text("Item", .{ .color = text_color }),
}));
}
Virtualized list supporting variable item heights. Heights are cached after rendering for efficient scroll calculations. Ideal for chat messages or expandable rows. The callback must return the rendered height.
const State = struct {
list_state: VirtualListState = VirtualListState.init(1000, 48.0), // count, default height
};
// In render function - callback returns item height:
fn render(cx: *Cx) void {
const s = cx.state(State);
cx.virtualList("chat-list", &s.list_state, .{ .grow_height = true }, renderMessage);
}
fn renderMessage(index: u32, cx: *Cx) f32 {
const s = cx.stateConst(State);
const msg = s.messages[index];
const height: f32 = if (msg.has_image) 120.0 else 48.0;
cx.render(ui.box(.{
.fill_width = true,
.height = height,
.on_click_handler = cx.updateWith(index, State.selectMessage),
}, .{
ui.text(msg.text, .{}),
}));
return height; // Return actual rendered height for caching
}
measureTextInstead of hardcoding heights or guessing character widths, use cx.measureText() to get pixel-accurate dimensions from the platform text shaper (CoreText/HarfBuzz/browser):
fn renderMessage(index: u32, cx: *Cx) f32 {
const s = cx.stateConst(State);
const msg = s.messages[index];
const padding: f32 = 32.0;
const max_bubble_width: f32 = 400.0;
// Measure with wrapping β uses the real shaper so kerning matches rendering
const m = cx.measureText(msg.text, .{
.max_width = max_bubble_width,
.font_size = 15, // null = use the current font size
}) catch |_| TextMeasurement{ .width = 0, .height = 48, .line_count = 1 };
const height = m.height + padding;
cx.render(ui.box(.{
.fill_width = true,
.height = height,
}, .{
ui.text(msg.text, .{ .size = 15, .wrap = .word }),
}));
return height;
}
measureText returns a TextMeasurement with .width, .height, and .line_count.
Virtualized 2D table with both vertical and horizontal virtualization. Supports column resizing, sorting, and selection. Uses a callbacks struct for header and cell rendering.
const State = struct {
table_state: DataTableState = blk: {
var t = DataTableState.init(10_000, 32.0); // row count, row height
t.addColumn(.{ .width_px = 80, .sortable = true }) catch unreachable; // ID
t.addColumn(.{ .width_px = 200, .sortable = true }) catch unreachable; // Name
t.addColumn(.{ .width_px = 100 }) catch unreachable; // Status
break :blk t;
},
pub fn onHeaderClick(self: *State, col: u32) void {
_ = self.table_state.toggleSort(col);
// Re-sort your data based on table_state.sort_column and direction
}
pub fn onRowClick(self: *State, row: u32) void {
self.table_state.selection.row = row;
}
};
// In render function:
fn render(cx: *Cx) void {
const s = cx.state(State);
const theme = cx.theme();
cx.dataTable("my-table", &s.table_state, .{
.fill_width = true,
.grow_height = true,
.row_hover_background = theme.overlay,
.row_selected_background = theme.primary,
}, .{
.render_header = renderHeader,
.render_cell = renderCell,
});
}
fn renderHeader(col: u32, cx: *Cx) void {
const s = cx.stateConst(State);
const theme = cx.theme();
// Add sort indicator if this column is sorted
const name = COLUMN_NAMES[col];
const label = if (s.table_state.sort_column == col)
if (s.table_state.sort_direction == .ascending) name ++ " β²" else name ++ " βΌ"
else
name;
cx.render(ui.box(.{
.fill_width = true,
.fill_height = true,
.on_click_handler = cx.updateWith(col, State.onHeaderClick),
}, .{
ui.text(label, .{ .weight = .semibold, .color = theme.text }),
}));
}
fn renderCell(row: u32, col: u32, cx: *Cx) void {
const theme = cx.theme();
cx.render(ui.box(.{
.fill_width = true,
.fill_height = true,
.padding = .{ .symmetric = .{ .x = 8, .y = 0 } },
}, .{
switch (col) {
0 => ui.textFmt("{d}", .{row}, .{ .color = theme.text }),
1 => ui.text(data[row].name, .{ .color = theme.text }),
2 => ui.text(data[row].status, .{ .color = theme.text }),
else => ui.text("β", .{}),
},
}));
}
Gooey provides utilities for form validation with touched-state tracking:
const validation = gooey.validation;
// Single validators
const err = validation.required(value); // Non-empty check
const err = validation.email(value); // Email format
const err = validation.minLength(value, 8); // Minimum length
const err = validation.maxLength(value, 100); // Maximum length
const err = validation.numeric(value); // Digits only
const err = validation.alphanumeric(value); // Letters and numbers
const err = validation.matches(value, other); // Values must match
// Password strength
const err = validation.hasUppercase(value); // At least one uppercase
const err = validation.hasLowercase(value); // At least one lowercase
const err = validation.hasDigit(value); // At least one number
const err = validation.hasSpecialChar(value); // At least one special char
// Chain multiple validators - returns first error or null
const err = validation.all(password, .{
validation.required,
validation.minLengthValidator(8),
validation.hasUppercase,
validation.hasDigit,
});
Create validators with custom messages for internationalization:
// Define a locale struct with custom validators
const french = struct {
pub const required = validation.requiredMsg("Ce champ est requis");
pub const email = validation.emailMsg("Adresse e-mail invalide");
pub const minLength8 = validation.minLengthMsg(8, "Au moins 8 caractères");
pub const hasUppercase = validation.hasUppercaseMsg("Au moins une majuscule");
};
// Use in validation - works with all() combinator
const err = validation.all(value, .{
french.required,
french.email,
});
// Available message factories:
// validation.requiredMsg(msg)
// validation.emailMsg(msg)
// validation.minLengthMsg(min, msg)
// validation.maxLengthMsg(max, msg)
// validation.numericMsg(msg)
// validation.alphanumericMsg(msg)
// validation.hasUppercaseMsg(msg)
// validation.hasLowercaseMsg(msg)
// validation.hasDigitMsg(msg)
// validation.hasSpecialCharMsg(msg)
// validation.matchesMsg(msg)
Use error codes when you need to programmatically handle errors (e.g., focus first invalid field):
// Returns ?ErrorCode instead of ?[]const u8
const code = validation.requiredCode(value);
if (code == .required) {
cx.setFocus("username"); // Focus first invalid field
}
// Available error codes:
// .required, .min_length, .max_length, .invalid_email,
// .not_numeric, .not_alphanumeric, .mismatch,
// .no_uppercase, .no_lowercase, .no_digit, .no_special_char
// Find first invalid field - call individual *Code functions in sequence
// (there's no allCode() combinator; this pattern keeps the API simple)
pub fn getFirstInvalidField(s: *const State) ?[]const u8 {
if (validation.requiredCode(s.username) != null) return "username";
if (validation.emailCode(s.email) != null) return "email";
if (validation.minLengthCode(s.password, 8) != null) return "password";
return null;
}
Note: Unlike
all()for error messages, there's noallCode()combinator. For multi-field validation with error codes, call individual*Codefunctions in sequence as shown above. This keeps the API simple while covering the common "focus first invalid field" use case.
When you need different messages for visual display vs screen readers:
// Structured result with separate messages
const result = validation.requiredResult(value, .{
.message = "Required", // Terse for visual display
.accessible_message = "The email field is required. Please enter your email address.",
});
if (result) |r| {
r.code // ErrorCode for programmatic handling
r.displayMessage() // Message for visual display
r.screenReaderMessage() // Message for screen readers (falls back to display)
}
// Use with ValidatedTextInput for full a11y control
gooey.ValidatedTextInput{
.id = "email",
.error_result = validation.requiredResult(s.email, .{
.message = "Required",
.accessible_message = "The email address field is required",
}),
.show_error = s.touched_email,
}
All-in-one form field with label, input, error display, and help text:
const State = struct {
email: []const u8 = "",
touched_email: bool = false,
pub fn validateEmail(self: *const State) ?[]const u8 {
return gooey.validation.all(self.email, .{
gooey.validation.required,
gooey.validation.email,
});
}
pub fn onEmailBlur(self: *State) void {
self.touched_email = true;
}
};
// In render:
gooey.ValidatedTextInput{
.id = "email",
.label = "Email Address",
.required_indicator = true, // Shows "*" after label
.placeholder = "you@example.com",
.bind = &s.email,
.error_message = s.validateEmail(), // Simple string error
.show_error = s.touched_email, // Only show after interaction
.help_text = "We'll never share your email",
.on_blur_handler = cx.update(State.onEmailBlur),
.width = 300,
}
// Or with structured result for different a11y messages:
gooey.ValidatedTextInput{
.id = "email",
.label = "Email Address",
.error_result = validation.emailResult(s.email, .{
.message = "Invalid email",
.accessible_message = "Please enter a valid email address in the format name@example.com",
}),
.show_error = s.touched_email,
}
// Track errors for multiple fields
var errors = validation.FormErrors(4).init();
errors.set(0, validation.required(s.username));
errors.set(1, validation.email(s.email));
errors.set(2, validation.minLength(s.password, 8));
errors.set(3, validation.matches(s.confirm, s.password));
if (errors.isValid()) {
// Submit form
} else {
// errors.firstErrorIndex() returns index of first invalid field
}
// Track touched state
var touched = validation.TouchedFields(4).init();
touched.touch(0); // Mark field 0 as touched
if (touched.isTouched(0)) { ... }
touched.touchAll(); // Mark all on submit
touched.reset(); // Clear on form reset
Run zig build run-form-validation for a complete example.
Built-in animation support with easing functions:
// Simple animation (runs once on mount)
const fade = cx.animate("fade-in", .{ .duration_ms = 500 });
// fade.progress goes 0.0 -> 1.0
// Animation that restarts when a value changes
const pulse = cx.animateOn("counter-pulse", s.count, .{
.duration_ms = 200,
.easing = Easing.easeOutBack,
});
// Continuous animation
const spin = cx.animate("spinner", .{
.duration_ms = 1000,
.mode = .ping_pong, // or .loop
});
// Use animation values
cx.render(ui.box(.{
.background = Color.white.withAlpha(fade.progress),
.width = gooey.lerp(100.0, 150.0, pulse.progress),
}, .{...}));
Available Easings: linear, easeIn, easeOut, easeInOut, easeOutBack, easeOutCubic, easeInOutCubic
cx.changed() detects when a value changes between frames β replacing the common pattern of module-level var last_foo: ?T = null with manual diffing:
// Invalidate caches when dependencies change
if (cx.changed("dark_mode", s.dark_mode) or cx.changed("window_width", size.width)) {
s.invalidateCachedHeights();
}
Semantics:
false (no previous value)falsetrue (and stores the new value)Works with any value type: bool, f32, i32, enums, small structs.
// Theme change
if (cx.changed("theme", s.theme)) {
s.rebuildStyles();
}
// Window resize (triggers layout recalc)
const size = cx.windowSize();
if (cx.changed("width", size.width)) {
s.onResize(size.width);
}
// Enum state
if (cx.changed("view", s.current_view)) {
s.scrollToTop();
}
Keys are comptime strings hashed to u32 (same approach as the animation system). Up to 64 tracked values per app.
Cross-platform file open/save dialogs via gooey.file_dialog:
const file_dialog = gooey.file_dialog;
// Open dialog
if (file_dialog.promptForPaths(allocator, .{
.files = true,
.prompt = "Attach",
.allowed_extensions = &.{ "txt", "png", "pdf" },
})) |result| {
defer result.deinit();
for (result.paths) |path| {
// ...
}
}
// Save dialog
if (file_dialog.promptForNewPath(allocator, .{
.suggested_name = "untitled.txt",
.prompt = "Save",
})) |path| {
defer allocator.free(path);
// ...
}
null β use gooey.platform.web.file_dialog for the async callback APIUse file_dialog.supported (comptime bool) for feature detection. File dialogs block the thread, so call them from a deferred command to avoid deadlocks during event handling.
Dynamic creation and deletion with automatic cleanup:
const Counter = struct {
count: i32 = 0,
pub fn increment(self: *Counter) void { self.count += 1; }
};
const AppState = struct {
counters: [10]gooey.Entity(Counter) = ...,
// Command method - needs Gooey access for entity operations
pub fn addCounter(self: *AppState, g: *gooey.Gooey) void {
const entity = g.createEntity(Counter, .{ .count = 0 }) catch return;
self.counters[self.counter_count] = entity;
self.counter_count += 1;
}
};
// In render - use entityCx for entity-scoped handlers
var entity_cx = cx.entityCx(Counter, counter_entity) orelse return;
Button{ .label = "+", .on_click_handler = entity_cx.update(Counter.increment) }
// Read entity data
if (cx.gooey().readEntity(Counter, entity)) |data| {
ui.textFmt("{d}", .{data.count}, .{});
}
Flexbox-inspired layout with shrink behavior and text wrapping:
cx.render(ui.box(.{
.direction = .row, // or .column
.gap = 16,
.padding = .{ .all = 24 }, // or .symmetric, .each
.alignment = .{ .main = .space_between, .cross = .center },
.fill_width = true,
.grow = true,
}, .{...}));
// Childless boxes β use ui.rect() for dividers, spacers, colored blocks
ui.rect(.{ .width = 1, .height = 18, .background = t.border }) // divider
ui.rect(.{ .grow = true }) // spacer
ui.rect(.{ .width = 40, .height = 40, .background = color, .corner_radius = 4 })
// Shrink behavior - elements shrink when container is too small
cx.render(ui.box(.{ .width = 150, .min_width = 60 }, .{...}));
// Text wrapping
ui.text("Long text...", .{ .wrap = .words }); // .none, .words, .newlines
Add custom post-processing shaders for visual effects. Shaders are cross-platform with MSL for macOS and WGSL for web:
// MSL shader (macOS)
pub const plasma_msl =
\\void mainImage(thread float4& fragColor, float2 fragCoord,
\\ constant ShaderUniforms& uniforms,
\\ texture2d<float> iChannel0,
\\ sampler iChannel0Sampler) {
\\ float2 uv = fragCoord / uniforms.iResolution.xy;
\\ float time = uniforms.iTime;
\\ // ... shader code
\\ fragColor = float4(color, 1.0);
\\}
;
// WGSL shader (Web)
pub const plasma_wgsl =
\\fn mainImage(
\\ fragCoord: vec2<f32>,
\\ u: ShaderUniforms,
\\ tex: texture_2d<f32>,
\\ samp: sampler
\\) -> vec4<f32> {
\\ let uv = fragCoord / u.iResolution.xy;
\\ let time = u.iTime;
\\ // ... shader code
\\ return vec4<f32>(color, 1.0);
\\}
;
try gooey.runCx(AppState, &state, render, .{
.custom_shaders = &.{.{ .msl = plasma_msl, .wgsl = plasma_wgsl }},
});
You can also provide only one platform's shader:
// macOS only
.custom_shaders = &.{.{ .msl = plasma_msl }},
// Web only
.custom_shaders = &.{.{ .wgsl = plasma_wgsl }},
Transparent window with liquid glass effect:
try gooey.runCx(AppState, &state, render, .{
.title = "Glass Demo",
.background_color = gooey.Color.rgba(0.1, 0.1, 0.15, 1.0),
.background_opacity = 0.2,
.glass_style = .glass_regular, // .glass_clear, .blur, .none
.glass_corner_radius = 10.0,
.titlebar_transparent = true,
});
// Change glass style at runtime
pub fn cycleStyle(self: *AppState, g: *gooey.Gooey) void {
g.window.setGlassStyle(.glass_clear, 0.7, 10.0);
}
Contextual action system with keyboard shortcuts:
const Undo = struct {};
const Save = struct {};
fn setupKeymap(cx: *Cx) void {
const g = cx.gooey();
g.keymap.bind(Undo, "cmd-z", null); // Global
g.keymap.bind(Save, "cmd-s", "Editor"); // Context-specific
}
fn render(cx: *Cx) void {
cx.render(ui.box(.{}, .{
ui.onAction(Undo, doUndo), // Handle action
// Scoped context
ui.keyContext("Editor"),
ui.onAction(Save, doSave),
}));
}
Use g.quit() from a cx.command() handler to quit portably across macOS, Linux, and WASM (no-op).
Both ui.onActionHandler and Button.on_click_handler accept a HandlerRef, so the same cx.command() handler works for both the keybinding and the button:
const QuitApp = struct {};
const AppState = struct {
initialized: bool = false,
fn quitApp(_: *AppState, g: *gooey.Gooey) void {
g.quit();
}
};
fn setupKeymap(cx: *Cx) void {
const s = cx.state(AppState);
if (s.initialized) return;
s.initialized = true;
cx.gooey().keymap.bind(QuitApp, "cmd-q", null);
}
fn render(cx: *Cx) void {
setupKeymap(cx);
const quit_handler = cx.command(AppState.quitApp);
cx.render(ui.box(.{ .padding = .{ .all = 24 }, .gap = 16 }, .{
// cmd+q triggers quitApp via the action system
ui.onActionHandler(QuitApp, quit_handler),
// Button triggers the same handler on click
Button{
.label = "Quit",
.variant = .danger,
.on_click_handler = quit_handler,
},
}));
}
| Example | Command | Description |
|---|---|---|
| Showcase | zig build run |
Full feature demo with navigation |
| Counter | zig build run-counter |
Simple state management |
| Animation | zig build run-animation |
Animation system with animateOn |
| Pomodoro | zig build run-pomodoro |
Timer with tasks and custom shader |
| Dynamic Counters | zig build run-dynamic-counters |
Entity creation and deletion |
| Layout | zig build run-layout |
Flexbox, shrink, text wrapping |
| Glass | zig build run-glass |
Liquid glass transparency effect |
| Spaceship | zig build run-spaceship |
Sci-fi dashboard with hologram shader |
| Actions | zig build run-actions |
Keybindings and action system |
| Select | zig build run-select |
Dropdown select component |
| Tooltip | zig build run-tooltip |
Tooltip positioning and styling |
| Modal | zig build run-modal |
Modal dialogs with animation |
| Images | zig build run-images |
Image loading and effects |
| File Dialog | zig build run-file-dialog |
Native file open/save dialogs |
| A11y Demo | zig build run-a11y-demo |
VoiceOver accessibility demo |
| Accessible Form | zig build run-accessible-form |
Complete accessible form example |
| Drag & Drop | zig build run-drag-drop |
Draggable items and drop targets |
| Uniform List | zig build run-uniform-list |
Virtualized list with 10,000 items |
| Virtual List | zig build run-virtual-list |
Variable-height virtualized list |
| Data Table | zig build run-data-table |
Virtualized table with 10,000 rows |
| Code Editor | zig build run-code-editor |
Code editor with syntax highlighting |
See docs/accessibility.md for comprehensive accessibility documentation.
Two options for cross-platform logging (native + WASM):
Option A: gooey.std_options β one-liner for std.log compatibility:
const gooey = @import("gooey");
// Routes std.log through console.log on WASM, default on native
pub const std_options = gooey.std_options;
Option B: gooey.log β zero-config, no std_options needed:
const log = gooey.log.scoped(.myapp);
log.info("connected to {s}", .{host});
log.err("request failed: {}", .{code});
On native, gooey.log delegates to std.log.scoped(). On WASM, it writes directly to the browser console. Use option A if you need third-party libraries to log through std.log. Use option B if you just want logging that works everywhere.
β οΈ Temporarily deferred on Zig 0.16.0 (upstream
Io.Threaded).std.Io.Threadeddoes not compile forwasm32-freestandingon Zig 0.16.0 β its comptime body eagerly referencesposix.system.getrandomandposix.IOV_MAX, which resolve tovoid/absent on that target. This is an upstream issue, not a Gooey one. Thezig build wasm*steps have been removed frombuild.zig(the commands no longer exist), while the web code paths (src/platform/web/,WebAppinapp.zig, andsrc/examples/*_wasm.zig) are deliberately left in place to resume compiling once upstream gates those references. Tracking:docs/zig-0.16-io-migration.md.
Once the upstream fix lands, the WASM build steps will be restored. The commands below are the intended interface β currently inactive:
# (currently disabled β see the note above)
# zig build wasm # showcase
# zig build wasm-counter
# zig build wasm-dynamic-counters
# zig build wasm-pomodoro
# zig build wasm-spaceship
# zig build wasm-layout
# zig build wasm-select
# zig build wasm-tooltip
# zig build wasm-modal
# zig build wasm-images
# zig build wasm-file-dialog
# Run with a local server
# python3 -m http.server 8080 -d zig-out/web
Simple brute-force hot reload for development:
zig build hot # Showcase (default)
zig build hot -- run-counter # Specific example
zig build hot -- run-pomodoro
zig build hot -- run-glass
src/
βββ app.zig # App entry points (runCx, App, WebApp)
βββ cx.zig # Unified context (Cx)
βββ root.zig # Public API exports
β
βββ core/ # Foundational types (geometry, events, shaders)
βββ input/ # Input handling (events, actions, keymaps)
βββ scene/ # GPU primitives (scene graph, batching)
βββ context/ # App context (focus, entity, dispatch, widget store)
βββ animation/ # Animation system and easing
βββ debug/ # Debugging tools and render stats
β
βββ ui/ # Declarative builder (box, vstack, hstack, primitives)
βββ components/ # UI components (Button, TextInput, Modal, Tooltip, etc.)
βββ widgets/ # Stateful widget implementations (text input/area state)
βββ layout/ # Flexbox-style layout engine
β
βββ text/ # Text rendering (CoreText, FreeType/HarfBuzz, Canvas)
βββ image/ # Image loading and atlas management
βββ svg/ # SVG rasterization (CoreGraphics, Linux, Canvas)
βββ platform/ # macOS/Metal, Linux/Vulkan/Wayland, WASM/WebGPU
βββ runtime/ # Frame rendering and input handling
βββ examples/ # Demo applications
Gooey has full Linux support using Wayland and Vulkan. The showcase and all demos run on Linux.
Linux Platform Stack:
βββββββββββββββββββββββββββββββββββββββ
β gooey Application β
βββββββββββββββββββββββββββββββββββββββ€
β LinuxPlatform β Window β
β (event loop) β (XDG shell) β
βββββββββββββββββββββββββββββββββββββββ€
β VulkanRenderer β SceneRenderer β
β (direct Vulkan, GLSL shaders) β
βββββββββββββββββββββββββββββββββββββββ€
β Wayland Client β Vulkan Driver β
βββββββββββββββββββββββββββββββββββββββ
| Feature | Implementation |
|---|---|
| Windowing | Wayland via XDG shell (xdg-toplevel, xdg-decoration) |
| GPU Rendering | Direct Vulkan with GLSL shaders (unified, text, svg, image pipelines) |
| Text Rendering | FreeType for rasterization, HarfBuzz for shaping, Fontconfig for font discovery |
| Input Handling | Full keyboard (evdev keycodes), mouse, scroll with modifier support |
| Clipboard | Wayland data-device protocol (copy/paste text) |
| File Dialogs | XDG Desktop Portal via D-Bus (open, save, directory selection) |
| IME Support | zwp_text_input_v3 protocol for international text input |
| HiDPI | wp_viewporter protocol with scale factor support |
| Server Decorations | zxdg-decoration-manager-v1 protocol |
# System packages (Debian/Ubuntu)
sudo apt install \
libwayland-dev \
libvulkan-dev \
libfreetype-dev \
libharfbuzz-dev \
libfontconfig-dev \
libpng-dev \
libdbus-1-dev
# Fedora/RHEL
sudo dnf install \
wayland-devel \
vulkan-loader-devel \
freetype-devel \
harfbuzz-devel \
fontconfig-devel \
libpng-devel \
dbus-devel
# Arch Linux
sudo pacman -S \
wayland \
vulkan-icd-loader \
vulkan-headers \
freetype2 \
harfbuzz \
fontconfig \
libpng \
dbus
# Build and run the showcase
zig build run
# Run specific demos
zig build run-basic # Simple Wayland + Vulkan test
zig build run-text # Text rendering demo
zig build run-file-dialog # XDG portal file dialogs
# Compile shaders (only needed if you modify GLSL sources)
zig build compile-shaders
# Run all tests
zig build test
# Run tests under valgrind (Linux only - detects memory leaks)
zig build test-valgrind
# Check code formatting
zig fmt --check src/ charts/
The project uses GitHub Actions for CI. Every push and pull request runs:
| Job | Platform | Description |
|---|---|---|
test-linux |
Ubuntu | Unit tests on Linux |
test-macos |
macOS | Unit tests on macOS |
build-linux |
Ubuntu | Build all optimization levels (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall) |
build-macos |
macOS | Build all optimization levels |
build-wasm |
Ubuntu | WebAssembly targets |
valgrind |
Ubuntu | Memory leak detection via valgrind |
zig-fmt |
Ubuntu | Code formatting check |
Valgrind integration helps catch memory issues early:
# Run tests with full leak checking
zig build test-valgrind
The valgrind.supp file contains suppressions for known false positives from system libraries (Vulkan, Wayland, FreeType, HarfBuzz, etc.).
The LLM is the finishing tool, not the architect or core developer.
Previous models both GPT and Claude would struggle with the larger picture more. Pretty quickly theyβd do one off hacks. Eventually theyβd code themselves into a wall if you werenβt careful.
Havenβt hit that wall with GPT-5.5 yet. New changes or improvements on a GUI library Iβm building seem to be constant in time per feature.
Though Iβm talking only 10kβs of LOC. Also Iβm using Nim which is both strongly typed and concise.
I guess I hope that the good stuff keeps coming and the dross falls away. More signal, less noise.
Iβm seeing a similar improvement with Opus 4.8, which is acting like an engineer that cares about correctness. The harder the problem the better it seems to do.
I think a golden age of software is just starting for indie software. Itβs just going to take a while to see the first really good results.
Arguably, there are important things that Zig devs are doing which are not "Zig dev made" (hence the "giants" I mentioned). NASA 10 rules are now rebranded as "tiger style" and marketed along Zig; Data Oriented Design existed before Zig; LLVM existed before Zig; the authors of gpui, the library that seems to have inspired gooey, apparently did a pretty great job (please correct me if I'm wrong, I don't do graphics, don't use gpui, this is what I understood when gpui came out).
I'm sure Zig devs made pretty great things, too! Does Zig make them apart? Do Zig attracts more capable devs than other languages, maybe? Genuine question!
On a small bright note, I've gotten AI to help me produce some of my best work over the last couple of months. It may enable sloppy behavior, but it doesn't require it. I have hope that serious work will win out in the end, and that sheer human effort is still the differentiator.
At least that's how I read it. :-) I'm learning that there's a place for the LLM but it's the sandpaper, not the chisel.
Like even the basic question of "hey did I already merge this branch?" becoming unknowable if you autosquash-on-merge is just nasty.
I've got a million ideas on what the "correct" fix for that problem might be, but imho it's a flaw deep in the heart of git that creates a massive amount of pain.
But I'll give it credit for being rock solid and blazing fast, as you say.
On the subject of Data-oriented design, it's mostly just the default way of thinking about software in most contexts. The DoD movement is not so much building on predecessors as it is ignoring predecessors who tell us to use OOP and "clean code" for everything.
LLVM is the part of Zig that breaks the most, it's the slowest part of the compiler by leaps and bounds, and takes the most time of the core team to fix the constant breakages it introduces. I'm glad it exists obviously, to some extent, but I wouldn't praise it for much more than its widespread appeal and ease of "adding another pass" compared to GCC and having more of a company-friendly license. It could have been designed a lot better and could be managed very differently and we'd see better results.
GPUI - I bet it's great. I guess I just tend to dislike phrasing like "stand on the shoulders of giants" because I tend to think the best thing hasn't been built yet. "Real engineering has never been tried!" I agree though that an AI port of a Rust library to Zig is probably not what any of us consider the holy grail of engineering.
In short, I think Zig and TigerBeetle and the Handmade Ethos exist not primarily because others paved the way, but because others went astray. Andrew Kelley used to argue with people online about how cross-compilation should be easy for systems languages and got hit by the excuse parade every time. Eventually he decided to quit his day job and solve it himself. Is that standing on the shoulders of giants? I don't think so. Yes, he has technically picked up where others left off, but he's picking up on abject failures and fixing them.
Now there is good engineering within LLVM and Clang, and good developers have done good work on it. I'm not saying everyone who worked on it is stupid or did bad work. However, there are glaring issues at a macro level and organizational level that individual contributors can't fix.
I'm wanting to build pieces of software that I've been wanting and often working on for years. These new models are making it possible for me to scale my work to build it.