{"slug": "gooey-a-gpu-accelerated-ui-framework-for-zig", "title": "Gooey: A GPU-accelerated UI framework for Zig", "summary": "Gooey, a GPU-accelerated UI framework for the Zig programming language, has been released as an open-source project targeting macOS, Linux, and browser platforms. The framework provides declarative UI components, GPU rendering through Metal and Vulkan, and zero external Zig dependencies, enabling developers to build cross-platform applications with native performance. Gooey supports features including animations, text rendering, accessibility, and native file dialogs, with early-stage API development ongoing.", "body_md": "A GPU-accelerated UI framework for Zig, targeting macOS (Metal), Linux (Vulkan/Wayland), and Browser (WASM/WebGPU).\n\nJoin the [Gooey discord](https://discord.gg/bmzAZnZJyw)\n\nEarly Development: API is evolving.\n\nExample app built with Gooey — [ chat-zig](https://github.com/duanebester/chat-zig), an Anthropic Claude client using the Zig 0.16\n\n`std.Io`\n\nstack for async HTTP:\n\n**GPU Rendering**- Metal (macOS), Vulkan (Linux) with MSAA anti-aliasing (WebGPU/WASM is blocked upstream on Zig 0.16 — see[WASM](#wasm))** Declarative UI**- Component-based layout with`ui.*`\n\nprimitives and flexbox-style system**Cx/UI Separation**-`Cx`\n\nfor state, handlers, and focus;`ui.*`\n\nfor layout primitives**Pure State Pattern**- Testable state methods with automatic re-rendering** Animation System**- Built-in animations with easing,`animateOn`\n\ntriggers**Entity System**- Dynamic entity creation/deletion with auto-cleanup** Retained Widgets**- TextInput, TextArea, Checkbox, Scroll containers** Text Rendering**- CoreText (macOS), FreeType/HarfBuzz (Linux), Canvas (WASM)** Custom Shaders**- Drop in your own Metal/GLSL shaders** Drag & Drop**- Type-safe drag sources and drop targets with`pointer_events`\n\ncontrol**Liquid Glass**- macOS 26.0+ Tahoe transparent window effects** Actions & Keybindings**- Contextual action system with keymap** Theming**- Built-in light/dark mode support** Images & SVG**- Load images and render SVG icons with styling** File Dialogs**- Native file open/save dialogs (macOS, Linux, WASM)** Clipboard**- Native clipboard support on all platforms** IME Support**- Input method editor for international text input** Accessibility**- Built-in screen reader support (VoiceOver, Orca, ARIA) with semantic roles and live regions** Zero Dependencies**- No external Zig packages; builds against system frameworks/libraries only (the Objective-C runtime bindings are vendored in-tree)\n\n**Requirements:** Zig 0.16.0+\n\n**Dependencies:** None. Gooey has zero external Zig package dependencies — `build.zig.zon`\n\nlists no dependencies. It links only against platform system frameworks/libraries (see platform notes below).\n\n**macOS:** macOS 12.0+\n\n**Linux:** Wayland compositor, Vulkan drivers, FreeType, HarfBuzz, Fontconfig, libpng, D-Bus\n\n```\nzig build run              # Showcase demo\nzig build run-counter      # Counter example\nzig build run-todo         # Todo app (state, handlers, TextInput, lists)\nzig build run-animation    # Animation demo\nzig build run-pomodoro     # Pomodoro timer\nzig build run-glass        # Liquid glass effect\nzig build run-spaceship    # Space dashboard with shader\nzig build run-dynamic-counters  # Entity system demo\nzig build run-layout       # Flexbox, shrink, text wrapping\nzig build run-actions      # Keybindings demo\nzig build run-select       # Dropdown select component\nzig build run-tooltip      # Tooltip component\nzig build run-modal        # Modal dialogs\nzig build run-images       # Image loading and styling\nzig build run-file-dialog  # Native file dialogs\nzig build run-uniform-list # Virtualized list (10k items)\nzig build run-virtual-list # Variable-height list\nzig build run-data-table   # Virtualized table (10k rows)\nzig build run-code-editor  # Code editor with syntax highlighting\nzig build test             # Run tests\n```\n\nA small todo app that touches a representative slice of the API: a pure,\nUI-free state model; `cx.update`\n\n/ `cx.updateWith`\n\n/ `cx.command`\n\nhandlers; a\nbound `TextInput`\n\n; `Checkbox`\n\nand `Button`\n\n; list iteration; and unit tests that\nexercise the state with no UI in play.\n\nThe full, runnable source lives in [ src/examples/todo.zig](/duanebester/gooey/blob/main/src/examples/todo.zig)\n(\n\n`zig build run-todo`\n\n). Its state model is covered by the tests shown at the\nbottom, which run as part of `zig build test`\n\n.\n\n``` js\nconst std = @import(\"std\");\nconst gooey = @import(\"gooey\");\n\nconst ui = gooey.ui;\nconst Cx = gooey.Cx;\nconst Button = gooey.components.Button;\nconst Checkbox = gooey.components.Checkbox;\nconst TextInput = gooey.components.TextInput;\n\nconst MAX_TODOS = 64;\nconst TEXT_CAP = 128;\nconst draft_input_id = \"new-todo\";\n\n// State is pure — no UI knowledge, fully testable.\nconst Todo = struct {\n    buf: [TEXT_CAP]u8 = [_]u8{0} ** TEXT_CAP,\n    len: usize = 0,\n    done: bool = false,\n\n    fn text(self: *const Todo) []const u8 {\n        return self.buf[0..self.len];\n    }\n};\n\nconst Filter = enum { all, active, done };\n\nconst AppState = struct {\n    todos: [MAX_TODOS]Todo = [_]Todo{.{}} ** MAX_TODOS,\n    count: usize = 0,\n    draft: []const u8 = \"\", // two-way bound to the TextInput\n    filter: Filter = .all,\n\n    // Pure logic — what the tests below drive.\n    fn pushTodo(self: *AppState, value: []const u8) void {\n        const trimmed = std.mem.trim(u8, value, \" \\t\\r\\n\");\n        if (trimmed.len == 0) return;\n        if (self.count >= MAX_TODOS) return;\n        const slot = &self.todos[self.count];\n        const n = @min(trimmed.len, TEXT_CAP);\n        @memcpy(slot.buf[0..n], trimmed[0..n]);\n        slot.len = n;\n        slot.done = false;\n        self.count += 1;\n    }\n\n    pub fn toggle(self: *AppState, index: usize) void {\n        if (index >= self.count) return;\n        self.todos[index].done = !self.todos[index].done;\n    }\n\n    pub fn remove(self: *AppState, index: usize) void {\n        if (index >= self.count) return;\n        var i = index;\n        while (i + 1 < self.count) : (i += 1) self.todos[i] = self.todos[i + 1];\n        self.count -= 1;\n    }\n\n    pub fn setFilter(self: *AppState, filter: Filter) void {\n        self.filter = filter;\n    }\n\n    pub fn clearCompleted(self: *AppState) void {\n        var write: usize = 0;\n        var read: usize = 0;\n        while (read < self.count) : (read += 1) {\n            if (!self.todos[read].done) {\n                self.todos[write] = self.todos[read];\n                write += 1;\n            }\n        }\n        self.count = write;\n    }\n\n    fn remaining(self: *const AppState) u32 {\n        var n: u32 = 0;\n        for (self.todos[0..self.count]) |*t| {\n            if (!t.done) n += 1;\n        }\n        return n;\n    }\n\n    fn visible(self: *const AppState, t: *const Todo) bool {\n        return switch (self.filter) {\n            .all => true,\n            .active => !t.done,\n            .done => t.done,\n        };\n    }\n\n    // Command — needs framework access (the binding only flows widget -> state,\n    // so we reach the retained input widget to clear it after adding).\n    pub fn addTodo(self: *AppState, g: *gooey.Window) void {\n        self.pushTodo(self.draft);\n        self.draft = \"\";\n        if (g.widgetState(gooey.widgets.TextInputState, draft_input_id)) |input| {\n            input.clear();\n        }\n    }\n};\n\nvar state = AppState{};\n\nconst App = gooey.App(AppState, &state, render, .{\n    .title = \"Todos\",\n    .width = 480,\n    .height = 560,\n});\n\ncomptime {\n    _ = App; // Force analysis (also wires @export on WASM).\n}\n\npub fn main(init: std.process.Init) !void {\n    return App.main(init);\n}\n\nfn render(cx: *Cx) void {\n    const s = cx.state(AppState);\n    const size = cx.windowSize();\n\n    cx.render(ui.box(.{\n        .width = size.width,\n        .height = size.height,\n        .direction = .column,\n        .padding = .{ .all = 24 },\n        .gap = 16,\n        .background = ui.Color.rgb(0.96, 0.96, 0.97),\n    }, .{\n        ui.text(\"Todos\", .{ .size = 28 }),\n\n        // Input row: TextInput binds to state.draft; Add is a command.\n        ui.hstack(.{ .gap = 8, .alignment = .center }, .{\n            TextInput{ .id = draft_input_id, .placeholder = \"What needs doing?\", .bind = &s.draft, .fill_width = true },\n            Button{ .label = \"Add\", .on_click_handler = cx.command(AppState.addTodo) },\n        }),\n\n        // Filters: each button packs its enum value into the handler arg.\n        ui.hstack(.{ .gap = 8 }, .{\n            FilterButton{ .label = \"All\", .filter = .all, .active = s.filter == .all },\n            FilterButton{ .label = \"Active\", .filter = .active, .active = s.filter == .active },\n            FilterButton{ .label = \"Done\", .filter = .done, .active = s.filter == .done },\n        }),\n\n        // The list, or an empty-state hint.\n        ui.when(s.count == 0, .{\n            ui.text(\"Nothing yet — add your first todo above.\", .{ .size = 14 }),\n        }),\n        TodoItems{},\n\n        ui.spacer(),\n        ui.hstack(.{ .gap = 12, .alignment = .center }, .{\n            ui.textFmt(\"{d} left\", .{s.remaining()}, .{ .size = 14 }),\n            ui.spacer(),\n            Button{ .label = \"Clear completed\", .variant = .secondary, .size = .small, .on_click_handler = cx.update(AppState.clearCompleted) },\n        }),\n    }));\n}\n\n// Iteration lives in a component because each row needs `cx` for its handlers.\nconst TodoItems = struct {\n    pub fn render(_: @This(), cx: *Cx) void {\n        const s = cx.state(AppState);\n        for (s.todos[0..s.count], 0..) |*todo, index| {\n            if (!s.visible(todo)) continue;\n            cx.render(TodoRow{ .index = index, .done = todo.done, .label = todo.text() });\n        }\n    }\n};\n\nconst TodoRow = struct {\n    index: usize,\n    done: bool,\n    label: []const u8,\n\n    pub fn render(self: @This(), cx: *Cx) void {\n        // A background + cross-axis centering means this is a `box` (with\n        // `.direction = .row`), not an `hstack` — stacks carry only gap/\n        // alignment/padding.\n        cx.render(ui.box(.{\n            .direction = .row,\n            .gap = 12,\n            .alignment = .{ .cross = .center },\n            .padding = .{ .all = 10 },\n            .background = ui.Color.white,\n            .corner_radius = 8,\n        }, .{\n            Checkbox{ .checked = self.done, .on_click_handler = cx.updateWith(self.index, AppState.toggle) },\n            ui.text(self.label, .{ .size = 16 }),\n            ui.spacer(),\n            Button{ .label = \"Delete\", .variant = .danger, .size = .small, .on_click_handler = cx.updateWith(self.index, AppState.remove) },\n        }));\n    }\n};\n\nconst FilterButton = struct {\n    label: []const u8,\n    filter: Filter,\n    active: bool,\n\n    pub fn render(self: @This(), cx: *Cx) void {\n        cx.render(Button{\n            .label = self.label,\n            .size = .small,\n            .variant = if (self.active) .primary else .secondary,\n            .on_click_handler = cx.updateWith(self.filter, AppState.setFilter),\n        });\n    }\n};\n\n// State is testable without UI.\ntest \"remove keeps the list contiguous\" {\n    var s = AppState{};\n    s.pushTodo(\"a\");\n    s.pushTodo(\"b\");\n    s.pushTodo(\"c\");\n    s.remove(1); // drop \"b\"\n    try std.testing.expectEqual(@as(usize, 2), s.count);\n    try std.testing.expectEqualStrings(\"a\", s.todos[0].text());\n    try std.testing.expectEqualStrings(\"c\", s.todos[1].text());\n}\n\ntest \"remaining and clearCompleted\" {\n    var s = AppState{};\n    s.pushTodo(\"a\");\n    s.pushTodo(\"b\");\n    s.toggle(0);\n    try std.testing.expectEqual(@as(u32, 1), s.remaining());\n    s.clearCompleted();\n    try std.testing.expectEqual(@as(usize, 1), s.count);\n    try std.testing.expectEqualStrings(\"b\", s.todos[0].text());\n}\n```\n\nGooey separates concerns between `Cx`\n\n(context) and `ui`\n\n(layout primitives):\n\n| Module | Purpose | Examples |\n|---|---|---|\n`cx.*` |\nState, handlers, animations, focus | `cx.state()` , `cx.update()` , `cx.animate()` , `cx.changed()` , `cx.render()` |\n`ui.*` |\nLayout containers and primitives | `ui.box()` , `ui.rect()` , `ui.hstack()` , `ui.vstack()` , `ui.text()` , `ui.when()` |\n\n``` js\nfn render(cx: *Cx) void {\n    const s = cx.state(AppState);\n\n    cx.render(ui.box(.{ .width = 100 }, .{\n        ui.text(\"Hello\", .{}),\n\n        // Conditional rendering\n        ui.when(s.show_extra, .{\n            ui.text(\"Extra content\", .{}),\n        }),\n\n        // Iterate over items\n        ui.each(&s.items, struct {\n            fn render(item: Item, _: usize) @TypeOf(ui.text(\"\", .{})) {\n                return ui.text(item.name, .{});\n            }\n        }.render),\n    }));\n}\n```\n\n**Key primitives:**\n\n`ui.box()`\n\n- Container with flexbox layout`ui.rect()`\n\n- Childless box (dividers, spacers, colored blocks)`ui.hstack()`\n\n/`ui.vstack()`\n\n- Horizontal/vertical stacks`ui.text()`\n\n/`ui.textFmt()`\n\n- Text rendering`ui.when(cond, children)`\n\n- Conditional rendering`ui.maybe(optional, fn)`\n\n- Render if optional has value`ui.each(items, fn)`\n\n- Render for each item`ui.scroll(id, style, children)`\n\n- Scrollable container`ui.spacer()`\n\n- Flexible space\n\n| Method | Signature | Use Case |\n|---|---|---|\n`cx.update()` |\n`fn(*State) void` |\nPure state mutations |\n`cx.updateWith()` |\n`fn(*State, Arg) void` |\nMutations with argument |\n`cx.command()` |\n`fn(*State, *Gooey) void` |\nFramework access (focus, quit, entities) |\n`cx.commandWith()` |\n`fn(*State, *Gooey, Arg) void` |\nFramework access with argument |\n`cx.defer()` |\n`fn(*State, *Gooey) void` |\nRun after current event completes |\n`cx.deferWith()` |\n`fn(*State, *Gooey, Arg) void` |\nDeferred with argument |\n\nNote:The state type is inferred automatically from the method pointer's first parameter — no need to pass it separately.\n\nThe `*With`\n\nvariants (`updateWith`\n\n, `commandWith`\n\n, `deferWith`\n\n) let you pass data to your handler. The argument is captured at handler creation time and passed when invoked:\n\n```\n// In a list render callback - capture the index\n.on_click_handler = cx.updateWith(index, State.selectItem),\n\n// The handler receives the captured value\npub fn selectItem(self: *State, index: u32) void {\n    self.selected = index;\n}\n```\n\n**The 8-byte limit:** Arguments are packed into a `u64`\n\nfor zero-allocation storage. This means your argument must be ≤8 bytes. If it exceeds this, you'll get a compile error:\n\n```\nerror: updateWith: argument type 'MyLargeStruct' exceeds 8 bytes. Use a pointer or index instead.\n```\n\n**What fits in 8 bytes:**\n\n| Type | Size | ✓/✗ |\n|---|---|---|\n`u8` , `i8` , `bool` |\n1 byte | ✓ |\n`u16` , `i16` |\n2 bytes | ✓ |\n`u32` , `i32` , `f32` |\n4 bytes | ✓ |\n`u64` , `i64` , `f64` |\n8 bytes | ✓ |\n`usize` (64-bit) |\n8 bytes | ✓ |\n`*T` (any pointer) |\n8 bytes | ✓ |\n`struct { x: u32, y: u32 }` |\n8 bytes | ✓ |\n`[2]u32` |\n8 bytes | ✓ |\n`struct { a: u32, b: u32, c: u32 }` |\n12 bytes | ✗ |\n\n**Workarounds for larger data:**\n\n```\n// Option 1: Use an index into your data\n.on_click_handler = cx.updateWith(row_index, State.selectRow),\n\n// Option 2: Use a pointer (if the data outlives the handler)\n.on_click_handler = cx.updateWith(&self.items[i], State.editItem),\n\n// Option 3: Store data in state, pass an ID\npub fn openFile(self: *State, file_id: u32) void {\n    const file = self.files.get(file_id) orelse return;\n    // ... use file.path, file.name, etc.\n}\n```\n\nUse `defer`\n\nwhen you need to run code **after** the current event handler completes. This is essential for:\n\n**Modal dialogs**- They run their own event loop, which would deadlock if called during event handling** File pickers**- Same reason as modals** Heavy operations**- Defer work to avoid blocking the current frame\n\n```\n// In a command handler, use g.deferCommand():\npub fn openFolder(self: *State, g: *Gooey) void {\n    _ = self;\n    g.deferCommand(State, State.openFolderDeferred);\n}\n\nfn openFolderDeferred(self: *State, g: *Gooey) void {\n    _ = g;\n    // Safe to open modal dialog here - we're outside event handling\n    const file_dialog = gooey.file_dialog;\n    if (file_dialog.promptForPaths(allocator, .{ .directories = true })) |result| {\n        defer result.deinit();\n        const path = result.paths[0];\n        self.loadDirectory(path);\n    }\n}\n\n// With an argument (same 8-byte limit applies):\npub fn deleteItem(self: *State, g: *Gooey, index: u32) void {\n    _ = self;\n    g.deferCommandWith(State, u32, index, State.confirmDelete);\n}\n\nfn confirmDelete(self: *State, g: *Gooey, index: u32) void {\n    _ = g;\n    if (dialog.confirm(\"Delete item?\")) {\n        self.items.remove(index);\n    }\n}\n```\n\nThe deferred command queue holds up to 32 commands and is flushed after each event cycle.\n\nRun expensive work — network requests, file I/O, heavy computation — off the UI thread using Zig 0.16's `std.Io`\n\n. The framework owns no executor of its own: background tasks are spawned with `cx.io().async(...)`\n\n, hand their results back through a bounded `std.Io.Queue(T)`\n\n, 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.\n\nThis is the same pattern `src/image/loader.zig`\n\nuses for async image URL fetches.\n\n``` js\n// A typed result the background task hands back to the render loop.\nconst Fetch = union(enum) {\n    ok: []const u8,\n    failed,\n};\n\nconst State = struct {\n    // Fixed-capacity, statically-backed channel — no allocation after init.\n    result_buffer: [16]Fetch = undefined,\n    result_queue: std.Io.Queue(Fetch) = undefined,\n\n    // Owns the in-flight task(s) so they can be cancelled together.\n    fetch_group: std.Io.Group = .init,\n\n    response: []const u8 = \"\",\n\n    // Kick off background work from a handler — runs off the UI thread.\n    pub fn startFetch(self: *State, cx: *Cx) void {\n        const url = \"https://api.example.com/data\";\n        self.result_queue = .init(&self.result_buffer);\n\n        // `io` is passed twice: once to drive `async`, and again inside the\n        // args tuple so the task body can push into the queue.\n        self.fetch_group.async(cx.io(), fetchData, .{ cx.io(), url, &self.result_queue });\n\n        // Auto-cancel on window close so a late task can't write into freed state.\n        cx.registerCancelGroup(&self.fetch_group);\n    }\n};\n\n// Background task — never touches UI state, only pushes a typed result.\nfn fetchData(io: std.Io, url: []const u8, queue: *std.Io.Queue(Fetch)) void {\n    const body = httpGet(io, url) catch {\n        queue.putOneUncancelable(io, .failed) catch {};\n        return;\n    };\n    queue.putOneUncancelable(io, .{ .ok = body }) catch {};\n}\n\nfn render(cx: *Cx) void {\n    const s = cx.state(State);\n\n    // Non-blocking drain — safe to call every frame from `render`.\n    var buffer: [16]Fetch = undefined;\n    for (cx.drainQueue(Fetch, &s.result_queue, &buffer)) |result| switch (result) {\n        .ok => |body| s.response = body,\n        .failed => {},\n    }\n\n    // ... build UI from s.response ...\n}\n```\n\n**Key pieces:**\n\n— the`cx.io()`\n\n`std.Io`\n\ninstance threaded through the framework from`main()`\n\n. Pass it to`async`\n\n, queue, and timing calls.(or`cx.io().async(fn, .{args})`\n\n) — spawn background work. Pass`group.async(io, fn, .{args})`\n\n`io`\n\ninside the args tuple too if the task needs to push into a queue.— bounded, lock-free, statically-backed channel. Tasks push with`std.Io.Queue(T)`\n\n`putOneUncancelable(io, value)`\n\n; capacity is fixed at init (no allocation afterward).— non-blocking drain into your buffer; returns an empty slice when nothing is ready, so it's safe to call every frame.`cx.drainQueue(T, &queue, &buffer)`\n\n— owns one or more in-flight tasks so they can be cancelled together.`std.Io.Group`\n\n— auto-cancel a group on window close (pair with`cx.registerCancelGroup(&group)`\n\n`cx.unregisterCancelGroup`\n\nif the work finishes normally). For per-entity lifecycles, use`cx.entities.attachCancel(id, &group)`\n\nto cancel when the entity is removed.\n\nNote:This rides on Zig 0.16's`std.Io`\n\n, so the threaded backend is unavailable on WASM (single-threaded) — see[WASM]. The earlier`cx.dispatchBackground`\n\n/`dispatchOnMainThread`\n\n/`dispatchAfter`\n\nAPIs were removed in the`std.Io`\n\nmigration; the`Io.Queue`\n\n+`Io.Group`\n\npattern above replaces them. See[.]`docs/zig-0.16-io-migration.md`\n\nBy 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.\n\nSet `.font`\n\nin your app config to use any font installed on the system:\n\n``` js\nconst App = gooey.App(AppState, &state, render, .{\n    .title = \"My App\",\n    .font = \"Inter\",\n    .font_size = 16.0,   // optional, defaults to 16.0\n});\n```\n\nOmitting `.font`\n\nuses the platform default. On Linux, any font discoverable by Fontconfig works — install fonts via your package manager (e.g., `sudo apt install fonts-inter`\n\n) or drop `.ttf`\n\n/`.otf`\n\nfiles into `~/.local/share/fonts/`\n\n.\n\nChange the font on the fly from any event handler:\n\n``` js\nfn onSettingsChanged(cx: *Cx) void {\n    const s = cx.state(AppState);\n    cx.setFont(s.font_name, s.font_size) catch {};\n}\n```\n\nThis clears the glyph and shape caches and triggers a re-render automatically. All text in the UI updates immediately.\n\n| Platform | Font Discovery | System Sans-Serif |\n|---|---|---|\n| Linux | Fontconfig | `sans-serif` (typically DejaVu Sans or Noto Sans) |\n| macOS | CoreText | SF Pro |\n| Web | CSS font stack | `system-ui, -apple-system, sans-serif` |\n\nNote: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_size`\n\nbut not`font_family`\n\n.\n\nGooey ships with two built-in themes — `Theme.light`\n\n(Catppuccin Latte) and `Theme.dark`\n\n(Catppuccin Macchiato). Set the active theme before rendering:\n\n```\nfn render(cx: *Cx) void {\n    cx.setTheme(if (s.dark_mode) &Theme.dark else &Theme.light);\n    // ...\n}\n```\n\nDefine a light/dark pair of `Theme`\n\nvalues 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:\n\n``` js\nconst my_light = gooey.Theme{\n    .bg      = Color.rgb(0.97, 0.97, 0.98),\n    .surface = Color.rgb(0.93, 0.93, 0.95),\n    .overlay = Color.rgb(0.88, 0.88, 0.91),\n\n    .primary   = Color.rgb(0.20, 0.50, 0.90),\n    .secondary = Color.rgb(0.45, 0.48, 0.58),\n    .accent    = Color.rgb(0.55, 0.25, 0.85),\n    .success   = Color.rgb(0.20, 0.65, 0.30),\n    .warning   = Color.rgb(0.85, 0.60, 0.10),\n    .danger    = Color.rgb(0.82, 0.24, 0.24),\n\n    .text    = Color.rgb(0.15, 0.15, 0.20),\n    .subtext = Color.rgb(0.35, 0.37, 0.45),\n    .muted   = Color.rgb(0.55, 0.57, 0.65),\n\n    .border       = Color.rgba(0.55, 0.57, 0.65, 0.3),\n    .border_focus = Color.rgb(0.20, 0.50, 0.90),\n\n    .radius_sm = 4,\n    .radius_md = 8,\n    .radius_lg = 16,\n\n    .font_size_base = 14,\n};\n\nconst my_dark = gooey.Theme{\n    .bg      = Color.rgb(0.10, 0.10, 0.12),\n    .surface = Color.rgb(0.15, 0.15, 0.18),\n    .overlay = Color.rgb(0.20, 0.20, 0.24),\n\n    .primary   = Color.rgb(0.40, 0.70, 1.00),\n    .secondary = Color.rgb(0.45, 0.48, 0.58),\n    .accent    = Color.rgb(0.75, 0.55, 0.95),\n    .success   = Color.rgb(0.45, 0.85, 0.55),\n    .warning   = Color.rgb(0.95, 0.80, 0.35),\n    .danger    = Color.rgb(0.95, 0.40, 0.40),\n\n    .text    = Color.rgb(0.92, 0.92, 0.95),\n    .subtext = Color.rgb(0.70, 0.72, 0.80),\n    .muted   = Color.rgb(0.50, 0.52, 0.60),\n\n    .border       = Color.rgba(0.50, 0.52, 0.60, 0.3),\n    .border_focus = Color.rgb(0.40, 0.70, 1.00),\n\n    .radius_sm = 4,\n    .radius_md = 8,\n    .radius_lg = 16,\n\n    .font_size_base = 14,\n};\n\nfn render(cx: *Cx) void {\n    cx.setTheme(if (s.dark_mode) &my_dark else &my_light);\n    // ...\n}\n```\n\nThe `font_size_base`\n\nfield (default `14`\n\n) is the single source of truth for text sizing across components. Components scale relative to it — for example, `Button`\n\nderives its per-size font sizes as:\n\n| Button size | Font size |\n|---|---|\n`.small` |\n`base - 2` (12) |\n`.medium` |\n`base` (14) |\n`.large` |\n`base + 2` (16) |\n\nSet it once in your theme and every component scales consistently — no per-component font size overrides needed:\n\n``` js\nconst large_text_theme = gooey.Theme{\n    // ...colors...\n    .font_size_base = 18,  // small=16, medium=18, large=20\n};\n```\n\nGooey includes ready-to-use components:\n\n```\n// Button variants\nButton{ .label = \"Save\", .variant = .primary, .on_click_handler = cx.update(State.save) }\nButton{ .label = \"Cancel\", .variant = .secondary, .size = .small, .on_click_handler = ... }\nButton{ .label = \"Delete\", .variant = .danger, .on_click_handler = ... }\n// Single-line text input with binding\nTextInput{\n    .id = \"email\",\n    .placeholder = \"Enter email...\",\n    .bind = &s.email,\n    .width = 250,\n}\n\n// Multi-line text area\nTextArea{\n    .id = \"notes\",\n    .placeholder = \"Enter notes...\",\n    .bind = &s.notes,\n    .width = 400,\n    .height = 200,\n}\nCheckbox{\n    .id = \"terms\",\n    .checked = s.agreed_to_terms,\n    .on_click_handler = cx.update(State.toggleTerms),\n}\n// RadioButton - individual buttons for custom layouts\nRadioButton{\n    .label = \"Email\",\n    .is_selected = s.contact_method == 0,\n    .on_click_handler = cx.updateWith(@as(u8, 0), State.setContactMethod),\n}\n\n// RadioGroup - grouped buttons with handlers array\nRadioGroup{\n    .id = \"priority\",\n    .options = &.{ \"Low\", \"Medium\", \"High\" },\n    .selected = s.priority,\n    .handlers = &.{\n        cx.updateWith(@as(u8, 0), State.setPriority),\n        cx.updateWith(@as(u8, 1), State.setPriority),\n        cx.updateWith(@as(u8, 2), State.setPriority),\n    },\n    .direction = .row,  // or .column\n    .gap = 16,\n}\njs\nconst State = struct {\n    selected_fruit: ?usize = null,\n\n    pub fn selectFruit(self: *State, index: usize) void {\n        self.selected_fruit = index;\n    }\n};\n\n// In render:\nSelect{\n    .id = \"fruit-select\",\n    .options = &.{ \"Apple\", \"Banana\", \"Cherry\", \"Date\" },\n    .selected = s.selected_fruit,\n    .placeholder = \"Choose a fruit...\",\n    .on_select = cx.onSelect(State.selectFruit),\n    .width = 200,\n}\n```\n\nThe widget manages open/close state internally — no toggle/close handlers or per-option handler arrays needed. Just provide `on_select`\n\nand a single handler that receives the selected index.\n\nLegacy API:The explicit`is_open`\n\n/`on_toggle_handler`\n\n/`on_close_handler`\n\n/`handlers`\n\nfields are still supported for full manual control.\n\n``` js\nconst State = struct {\n    show_confirm: bool = false,\n\n    pub fn openConfirm(self: *State) void {\n        self.show_confirm = true;\n    }\n\n    pub fn closeConfirm(self: *State) void {\n        self.show_confirm = false;\n    }\n};\n\n// Trigger button\nButton{ .label = \"Delete Item\", .variant = .danger, .on_click_handler = cx.update(State.openConfirm) }\n\n// Modal with custom content\nModal(ConfirmContent){\n    .id = \"confirm-dialog\",\n    .is_open = s.show_confirm,\n    .on_close = cx.update(State.closeConfirm),\n    .child = ConfirmContent{\n        .message = \"Are you sure you want to delete?\",\n        .on_confirm = cx.update(State.doDelete),\n        .on_cancel = cx.update(State.closeConfirm),\n    },\n    .animate = true,\n    .close_on_backdrop = true,\n}\n// Wrap any component with a tooltip\nTooltip(Button){\n    .text = \"Click to save your changes\",\n    .child = Button{ .label = \"Save\", .on_click_handler = ... },\n    .position = .top,  // .top, .bottom, .left, .right\n}\n\n// With custom styling\nTooltip(IconButton){\n    .text = \"This field is required\",\n    .child = HelpIcon{},\n    .position = .right,\n    .max_width = 200,\n    .background = Color.rgb(0.2, 0.2, 0.25),\n}\n// Simple image from path\ngooey.Image{ .src = \"assets/logo.png\" }\n\n// With explicit sizing\ngooey.Image{ .src = \"photo.jpg\", .width = 200, .height = 150 }\n\n// Rounded avatar\ngooey.Image{ .src = \"avatar.png\", .size = 48, .rounded = true }\n\n// Cover image (fills container, may crop)\ngooey.Image{ .src = \"banner.jpg\", .width = 800, .height = 200, .fit = .cover }\n\n// With effects\ngooey.Image{\n    .src = \"icon.png\",\n    .size = 64,\n    .grayscale = 1.0,           // 0.0 = color, 1.0 = grayscale\n    .tint = gooey.Color.blue,   // Color overlay\n    .opacity = 0.8,\n    .corner_radius = 8,\n}\njs\nconst gooey = @import(\"gooey\");\nconst Svg = gooey.Svg;\nconst Icons = gooey.Icons;\n\n// Using built-in icon paths\nSvg{ .path = Icons.star, .size = 24, .color = Color.gold }\nSvg{ .path = Icons.check, .size = 20, .color = Color.green }\nSvg{ .path = Icons.close, .size = 16, .color = Color.red }\n\n// Stroked icon (outline only)\nSvg{ .path = Icons.star_outline, .size = 24, .stroke_color = Color.white, .stroke_width = 2 }\n\n// Both fill and stroke\nSvg{ .path = Icons.favorite, .size = 24, .color = Color.red, .stroke_color = Color.black, .stroke_width = 1 }\n\n// Available icons: arrow_back, arrow_forward, menu, close, more_vert,\n// check, add, remove, edit, delete, search, star, star_outline, favorite,\n// info, warning, error_icon, play, pause, skip_next, skip_prev, volume_up,\n// visibility, visibility_off, folder, file, download, upload\nProgressBar{\n    .progress = s.completion,  // 0.0 to 1.0\n    .width = 200,\n    .height = 8,\n    .corner_radius = 4,\n}\n// Individual tabs for custom navigation\ncx.render(ui.hstack(.{ .gap = 4 }, .{\n    Tab{\n        .label = \"Home\",\n        .is_active = s.tab == 0,\n        .on_click_handler = cx.updateWith(@as(u8, 0), State.setTab),\n    },\n    Tab{\n        .label = \"Settings\",\n        .is_active = s.tab == 1,\n        .on_click_handler = cx.updateWith(@as(u8, 1), State.setTab),\n        .style = .underline,  // .pills (default), .underline, .segmented\n    },\n}))\n```\n\nVirtualized list for efficiently rendering large datasets with uniform item heights. Only visible items are rendered, regardless of total count. The render callback receives `*Cx`\n\nfor full access to state and handlers.\n\n``` js\nconst State = struct {\n    list_state: UniformListState = UniformListState.init(10_000, 32.0), // count, item height\n    selected: ?u32 = null,\n\n    pub fn scrollToTop(self: *State) void {\n        self.list_state.scrollToTop();\n    }\n\n    pub fn scrollToMiddle(self: *State) void {\n        self.list_state.scrollToItem(5000, .center);\n    }\n\n    pub fn selectItem(self: *State, index: u32) void {\n        self.selected = index;\n    }\n};\n\n// In render function:\nfn render(cx: *Cx) void {\n    const s = cx.state(State);\n    cx.uniformList(\"my-list\", &s.list_state, .{\n        .fill_width = true,\n        .grow_height = true,\n    }, renderItem);\n}\n\nfn renderItem(index: u32, cx: *Cx) void {\n    const s = cx.stateConst(State);\n    const theme = cx.theme();\n    const is_selected = if (s.selected) |sel| sel == index else false;\n\n    // Color is available via: const Color = gooey.Color;\n    const text_color = if (is_selected) Color.white else theme.text;\n\n    cx.render(ui.box(.{\n        .fill_width = true,\n        .height = 32,\n        .background = if (is_selected) theme.primary else null,\n        .hover_background = theme.overlay,\n        .on_click_handler = cx.updateWith(index, State.selectItem),\n    }, .{\n        ui.text(\"Item\", .{ .color = text_color }),\n    }));\n}\n```\n\nVirtualized 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.\n\n``` js\nconst State = struct {\n    list_state: VirtualListState = VirtualListState.init(1000, 48.0), // count, default height\n};\n\n// In render function - callback returns item height:\nfn render(cx: *Cx) void {\n    const s = cx.state(State);\n    cx.virtualList(\"chat-list\", &s.list_state, .{ .grow_height = true }, renderMessage);\n}\n\nfn renderMessage(index: u32, cx: *Cx) f32 {\n    const s = cx.stateConst(State);\n    const msg = s.messages[index];\n    const height: f32 = if (msg.has_image) 120.0 else 48.0;\n\n    cx.render(ui.box(.{\n        .fill_width = true,\n        .height = height,\n        .on_click_handler = cx.updateWith(index, State.selectMessage),\n    }, .{\n        ui.text(msg.text, .{}),\n    }));\n\n    return height; // Return actual rendered height for caching\n}\n```\n\nInstead of hardcoding heights or guessing character widths, use `cx.measureText()`\n\nto get pixel-accurate dimensions from the platform text shaper (CoreText/HarfBuzz/browser):\n\n``` js\nfn renderMessage(index: u32, cx: *Cx) f32 {\n    const s = cx.stateConst(State);\n    const msg = s.messages[index];\n    const padding: f32 = 32.0;\n    const max_bubble_width: f32 = 400.0;\n\n    // Measure with wrapping — uses the real shaper so kerning matches rendering\n    const m = cx.measureText(msg.text, .{\n        .max_width = max_bubble_width,\n        .font_size = 15,        // null = use the current font size\n    }) catch |_| TextMeasurement{ .width = 0, .height = 48, .line_count = 1 };\n\n    const height = m.height + padding;\n\n    cx.render(ui.box(.{\n        .fill_width = true,\n        .height = height,\n    }, .{\n        ui.text(msg.text, .{ .size = 15, .wrap = .word }),\n    }));\n\n    return height;\n}\n```\n\n`measureText`\n\nreturns a `TextMeasurement`\n\nwith `.width`\n\n, `.height`\n\n, and `.line_count`\n\n.\n\nVirtualized 2D table with both vertical and horizontal virtualization. Supports column resizing, sorting, and selection. Uses a callbacks struct for header and cell rendering.\n\n``` js\nconst State = struct {\n    table_state: DataTableState = blk: {\n        var t = DataTableState.init(10_000, 32.0); // row count, row height\n        t.addColumn(.{ .width_px = 80, .sortable = true }) catch unreachable;   // ID\n        t.addColumn(.{ .width_px = 200, .sortable = true }) catch unreachable;  // Name\n        t.addColumn(.{ .width_px = 100 }) catch unreachable;                     // Status\n        break :blk t;\n    },\n\n    pub fn onHeaderClick(self: *State, col: u32) void {\n        _ = self.table_state.toggleSort(col);\n        // Re-sort your data based on table_state.sort_column and direction\n    }\n\n    pub fn onRowClick(self: *State, row: u32) void {\n        self.table_state.selection.row = row;\n    }\n};\n\n// In render function:\nfn render(cx: *Cx) void {\n    const s = cx.state(State);\n    const theme = cx.theme();\n    cx.dataTable(\"my-table\", &s.table_state, .{\n        .fill_width = true,\n        .grow_height = true,\n        .row_hover_background = theme.overlay,\n        .row_selected_background = theme.primary,\n    }, .{\n        .render_header = renderHeader,\n        .render_cell = renderCell,\n    });\n}\n\nfn renderHeader(col: u32, cx: *Cx) void {\n    const s = cx.stateConst(State);\n    const theme = cx.theme();\n\n    // Add sort indicator if this column is sorted\n    const name = COLUMN_NAMES[col];\n    const label = if (s.table_state.sort_column == col)\n        if (s.table_state.sort_direction == .ascending) name ++ \" ▲\" else name ++ \" ▼\"\n    else\n        name;\n\n    cx.render(ui.box(.{\n        .fill_width = true,\n        .fill_height = true,\n        .on_click_handler = cx.updateWith(col, State.onHeaderClick),\n    }, .{\n        ui.text(label, .{ .weight = .semibold, .color = theme.text }),\n    }));\n}\n\nfn renderCell(row: u32, col: u32, cx: *Cx) void {\n    const theme = cx.theme();\n\n    cx.render(ui.box(.{\n        .fill_width = true,\n        .fill_height = true,\n        .padding = .{ .symmetric = .{ .x = 8, .y = 0 } },\n    }, .{\n        switch (col) {\n            0 => ui.textFmt(\"{d}\", .{row}, .{ .color = theme.text }),\n            1 => ui.text(data[row].name, .{ .color = theme.text }),\n            2 => ui.text(data[row].status, .{ .color = theme.text }),\n            else => ui.text(\"—\", .{}),\n        },\n    }));\n}\n```\n\nGooey provides utilities for form validation with touched-state tracking:\n\n``` js\nconst validation = gooey.validation;\n\n// Single validators\nconst err = validation.required(value);           // Non-empty check\nconst err = validation.email(value);              // Email format\nconst err = validation.minLength(value, 8);       // Minimum length\nconst err = validation.maxLength(value, 100);     // Maximum length\nconst err = validation.numeric(value);            // Digits only\nconst err = validation.alphanumeric(value);       // Letters and numbers\nconst err = validation.matches(value, other);     // Values must match\n\n// Password strength\nconst err = validation.hasUppercase(value);       // At least one uppercase\nconst err = validation.hasLowercase(value);       // At least one lowercase\nconst err = validation.hasDigit(value);           // At least one number\nconst err = validation.hasSpecialChar(value);     // At least one special char\n\n// Chain multiple validators - returns first error or null\nconst err = validation.all(password, .{\n    validation.required,\n    validation.minLengthValidator(8),\n    validation.hasUppercase,\n    validation.hasDigit,\n});\n```\n\nCreate validators with custom messages for internationalization:\n\n``` js\n// Define a locale struct with custom validators\nconst french = struct {\n    pub const required = validation.requiredMsg(\"Ce champ est requis\");\n    pub const email = validation.emailMsg(\"Adresse e-mail invalide\");\n    pub const minLength8 = validation.minLengthMsg(8, \"Au moins 8 caractères\");\n    pub const hasUppercase = validation.hasUppercaseMsg(\"Au moins une majuscule\");\n};\n\n// Use in validation - works with all() combinator\nconst err = validation.all(value, .{\n    french.required,\n    french.email,\n});\n\n// Available message factories:\n// validation.requiredMsg(msg)\n// validation.emailMsg(msg)\n// validation.minLengthMsg(min, msg)\n// validation.maxLengthMsg(max, msg)\n// validation.numericMsg(msg)\n// validation.alphanumericMsg(msg)\n// validation.hasUppercaseMsg(msg)\n// validation.hasLowercaseMsg(msg)\n// validation.hasDigitMsg(msg)\n// validation.hasSpecialCharMsg(msg)\n// validation.matchesMsg(msg)\n```\n\nUse error codes when you need to programmatically handle errors (e.g., focus first invalid field):\n\n``` js\n// Returns ?ErrorCode instead of ?[]const u8\nconst code = validation.requiredCode(value);\nif (code == .required) {\n    cx.setFocus(\"username\");  // Focus first invalid field\n}\n\n// Available error codes:\n// .required, .min_length, .max_length, .invalid_email,\n// .not_numeric, .not_alphanumeric, .mismatch,\n// .no_uppercase, .no_lowercase, .no_digit, .no_special_char\n\n// Find first invalid field - call individual *Code functions in sequence\n// (there's no allCode() combinator; this pattern keeps the API simple)\npub fn getFirstInvalidField(s: *const State) ?[]const u8 {\n    if (validation.requiredCode(s.username) != null) return \"username\";\n    if (validation.emailCode(s.email) != null) return \"email\";\n    if (validation.minLengthCode(s.password, 8) != null) return \"password\";\n    return null;\n}\n```\n\nNote:Unlike`all()`\n\nfor error messages, there's no`allCode()`\n\ncombinator. For multi-field validation with error codes, call individual`*Code`\n\nfunctions in sequence as shown above. This keeps the API simple while covering the common \"focus first invalid field\" use case.\n\nWhen you need different messages for visual display vs screen readers:\n\n``` js\n// Structured result with separate messages\nconst result = validation.requiredResult(value, .{\n    .message = \"Required\",  // Terse for visual display\n    .accessible_message = \"The email field is required. Please enter your email address.\",\n});\n\nif (result) |r| {\n    r.code              // ErrorCode for programmatic handling\n    r.displayMessage()  // Message for visual display\n    r.screenReaderMessage()  // Message for screen readers (falls back to display)\n}\n\n// Use with ValidatedTextInput for full a11y control\ngooey.ValidatedTextInput{\n    .id = \"email\",\n    .error_result = validation.requiredResult(s.email, .{\n        .message = \"Required\",\n        .accessible_message = \"The email address field is required\",\n    }),\n    .show_error = s.touched_email,\n}\n```\n\nAll-in-one form field with label, input, error display, and help text:\n\n``` js\nconst State = struct {\n    email: []const u8 = \"\",\n    touched_email: bool = false,\n\n    pub fn validateEmail(self: *const State) ?[]const u8 {\n        return gooey.validation.all(self.email, .{\n            gooey.validation.required,\n            gooey.validation.email,\n        });\n    }\n\n    pub fn onEmailBlur(self: *State) void {\n        self.touched_email = true;\n    }\n};\n\n// In render:\ngooey.ValidatedTextInput{\n    .id = \"email\",\n    .label = \"Email Address\",\n    .required_indicator = true,        // Shows \"*\" after label\n    .placeholder = \"you@example.com\",\n    .bind = &s.email,\n    .error_message = s.validateEmail(),  // Simple string error\n    .show_error = s.touched_email,       // Only show after interaction\n    .help_text = \"We'll never share your email\",\n    .on_blur_handler = cx.update(State.onEmailBlur),\n    .width = 300,\n}\n\n// Or with structured result for different a11y messages:\ngooey.ValidatedTextInput{\n    .id = \"email\",\n    .label = \"Email Address\",\n    .error_result = validation.emailResult(s.email, .{\n        .message = \"Invalid email\",\n        .accessible_message = \"Please enter a valid email address in the format name@example.com\",\n    }),\n    .show_error = s.touched_email,\n}\njs\n// Track errors for multiple fields\nvar errors = validation.FormErrors(4).init();\nerrors.set(0, validation.required(s.username));\nerrors.set(1, validation.email(s.email));\nerrors.set(2, validation.minLength(s.password, 8));\nerrors.set(3, validation.matches(s.confirm, s.password));\n\nif (errors.isValid()) {\n    // Submit form\n} else {\n    // errors.firstErrorIndex() returns index of first invalid field\n}\n\n// Track touched state\nvar touched = validation.TouchedFields(4).init();\ntouched.touch(0);  // Mark field 0 as touched\nif (touched.isTouched(0)) { ... }\ntouched.touchAll();  // Mark all on submit\ntouched.reset();     // Clear on form reset\n```\n\nRun `zig build run-form-validation`\n\nfor a complete example.\n\nBuilt-in animation support with easing functions:\n\n``` js\n// Simple animation (runs once on mount)\nconst fade = cx.animate(\"fade-in\", .{ .duration_ms = 500 });\n// fade.progress goes 0.0 -> 1.0\n\n// Animation that restarts when a value changes\nconst pulse = cx.animateOn(\"counter-pulse\", s.count, .{\n    .duration_ms = 200,\n    .easing = Easing.easeOutBack,\n});\n\n// Continuous animation\nconst spin = cx.animate(\"spinner\", .{\n    .duration_ms = 1000,\n    .mode = .ping_pong,  // or .loop\n});\n\n// Use animation values\ncx.render(ui.box(.{\n    .background = Color.white.withAlpha(fade.progress),\n    .width = gooey.lerp(100.0, 150.0, pulse.progress),\n}, .{...}));\n```\n\n**Available Easings:** `linear`\n\n, `easeIn`\n\n, `easeOut`\n\n, `easeInOut`\n\n, `easeOutBack`\n\n, `easeOutCubic`\n\n, `easeInOutCubic`\n\n`cx.changed()`\n\ndetects when a value changes between frames — replacing the common pattern of module-level `var last_foo: ?T = null`\n\nwith manual diffing:\n\n```\n// Invalidate caches when dependencies change\nif (cx.changed(\"dark_mode\", s.dark_mode) or cx.changed(\"window_width\", size.width)) {\n    s.invalidateCachedHeights();\n}\n```\n\n**Semantics:**\n\n**First call** for a given key → returns`false`\n\n(no previous value)**Same value** as last frame → returns`false`\n\n**Different value**→ returns`true`\n\n(and stores the new value)\n\nWorks with any value type: `bool`\n\n, `f32`\n\n, `i32`\n\n, enums, small structs.\n\n```\n// Theme change\nif (cx.changed(\"theme\", s.theme)) {\n    s.rebuildStyles();\n}\n\n// Window resize (triggers layout recalc)\nconst size = cx.windowSize();\nif (cx.changed(\"width\", size.width)) {\n    s.onResize(size.width);\n}\n\n// Enum state\nif (cx.changed(\"view\", s.current_view)) {\n    s.scrollToTop();\n}\n```\n\nKeys are comptime strings hashed to `u32`\n\n(same approach as the animation system). Up to 64 tracked values per app.\n\nCross-platform file open/save dialogs via `gooey.file_dialog`\n\n:\n\n``` js\nconst file_dialog = gooey.file_dialog;\n\n// Open dialog\nif (file_dialog.promptForPaths(allocator, .{\n    .files = true,\n    .prompt = \"Attach\",\n    .allowed_extensions = &.{ \"txt\", \"png\", \"pdf\" },\n})) |result| {\n    defer result.deinit();\n    for (result.paths) |path| {\n        // ...\n    }\n}\n\n// Save dialog\nif (file_dialog.promptForNewPath(allocator, .{\n    .suggested_name = \"untitled.txt\",\n    .prompt = \"Save\",\n})) |path| {\n    defer allocator.free(path);\n    // ...\n}\n```\n\n**macOS**: NSOpenPanel / NSSavePanel (blocking)** Linux**: XDG Desktop Portal via D-Bus (blocking)** WASM**: Returns`null`\n\n— use`gooey.platform.web.file_dialog`\n\nfor the async callback API\n\nUse `file_dialog.supported`\n\n(comptime bool) for feature detection. File dialogs block the thread, so call them from a [deferred command](#deferred-commands) to avoid deadlocks during event handling.\n\nDynamic creation and deletion with automatic cleanup:\n\n``` js\nconst Counter = struct {\n    count: i32 = 0,\n    pub fn increment(self: *Counter) void { self.count += 1; }\n};\n\nconst AppState = struct {\n    counters: [10]gooey.Entity(Counter) = ...,\n\n    // Command method - needs Gooey access for entity operations\n    pub fn addCounter(self: *AppState, g: *gooey.Gooey) void {\n        const entity = g.createEntity(Counter, .{ .count = 0 }) catch return;\n        self.counters[self.counter_count] = entity;\n        self.counter_count += 1;\n    }\n};\n\n// In render - use entityCx for entity-scoped handlers\nvar entity_cx = cx.entityCx(Counter, counter_entity) orelse return;\nButton{ .label = \"+\", .on_click_handler = entity_cx.update(Counter.increment) }\n\n// Read entity data\nif (cx.gooey().readEntity(Counter, entity)) |data| {\n    ui.textFmt(\"{d}\", .{data.count}, .{});\n}\n```\n\nFlexbox-inspired layout with shrink behavior and text wrapping:\n\n```\ncx.render(ui.box(.{\n    .direction = .row,           // or .column\n    .gap = 16,\n    .padding = .{ .all = 24 },   // or .symmetric, .each\n    .alignment = .{ .main = .space_between, .cross = .center },\n    .fill_width = true,\n    .grow = true,\n}, .{...}));\n\n// Childless boxes — use ui.rect() for dividers, spacers, colored blocks\nui.rect(.{ .width = 1, .height = 18, .background = t.border })  // divider\nui.rect(.{ .grow = true })                                       // spacer\nui.rect(.{ .width = 40, .height = 40, .background = color, .corner_radius = 4 })\n\n// Shrink behavior - elements shrink when container is too small\ncx.render(ui.box(.{ .width = 150, .min_width = 60 }, .{...}));\n\n// Text wrapping\nui.text(\"Long text...\", .{ .wrap = .words });  // .none, .words, .newlines\n```\n\nAdd custom post-processing shaders for visual effects. Shaders are cross-platform with MSL for macOS and WGSL for web:\n\n``` js\n// MSL shader (macOS)\npub const plasma_msl =\n    \\\\void mainImage(thread float4& fragColor, float2 fragCoord,\n    \\\\               constant ShaderUniforms& uniforms,\n    \\\\               texture2d<float> iChannel0,\n    \\\\               sampler iChannel0Sampler) {\n    \\\\    float2 uv = fragCoord / uniforms.iResolution.xy;\n    \\\\    float time = uniforms.iTime;\n    \\\\    // ... shader code\n    \\\\    fragColor = float4(color, 1.0);\n    \\\\}\n;\n\n// WGSL shader (Web)\npub const plasma_wgsl =\n    \\\\fn mainImage(\n    \\\\    fragCoord: vec2<f32>,\n    \\\\    u: ShaderUniforms,\n    \\\\    tex: texture_2d<f32>,\n    \\\\    samp: sampler\n    \\\\) -> vec4<f32> {\n    \\\\    let uv = fragCoord / u.iResolution.xy;\n    \\\\    let time = u.iTime;\n    \\\\    // ... shader code\n    \\\\    return vec4<f32>(color, 1.0);\n    \\\\}\n;\n\ntry gooey.runCx(AppState, &state, render, .{\n    .custom_shaders = &.{.{ .msl = plasma_msl, .wgsl = plasma_wgsl }},\n});\n```\n\nYou can also provide only one platform's shader:\n\n```\n// macOS only\n.custom_shaders = &.{.{ .msl = plasma_msl }},\n\n// Web only\n.custom_shaders = &.{.{ .wgsl = plasma_wgsl }},\n```\n\nTransparent window with liquid glass effect:\n\n```\ntry gooey.runCx(AppState, &state, render, .{\n    .title = \"Glass Demo\",\n    .background_color = gooey.Color.rgba(0.1, 0.1, 0.15, 1.0),\n    .background_opacity = 0.2,\n    .glass_style = .glass_regular,  // .glass_clear, .blur, .none\n    .glass_corner_radius = 10.0,\n    .titlebar_transparent = true,\n});\n\n// Change glass style at runtime\npub fn cycleStyle(self: *AppState, g: *gooey.Gooey) void {\n    g.window.setGlassStyle(.glass_clear, 0.7, 10.0);\n}\n```\n\nContextual action system with keyboard shortcuts:\n\n``` js\nconst Undo = struct {};\nconst Save = struct {};\n\nfn setupKeymap(cx: *Cx) void {\n    const g = cx.gooey();\n    g.keymap.bind(Undo, \"cmd-z\", null);        // Global\n    g.keymap.bind(Save, \"cmd-s\", \"Editor\");    // Context-specific\n}\n\nfn render(cx: *Cx) void {\n    cx.render(ui.box(.{}, .{\n        ui.onAction(Undo, doUndo),  // Handle action\n\n        // Scoped context\n        ui.keyContext(\"Editor\"),\n        ui.onAction(Save, doSave),\n    }));\n}\n```\n\nUse `g.quit()`\n\nfrom a `cx.command()`\n\nhandler to quit portably across macOS, Linux, and WASM (no-op).\n\nBoth `ui.onActionHandler`\n\nand `Button.on_click_handler`\n\naccept a `HandlerRef`\n\n, so the same `cx.command()`\n\nhandler works for both the keybinding and the button:\n\n``` js\nconst QuitApp = struct {};\n\nconst AppState = struct {\n    initialized: bool = false,\n\n    fn quitApp(_: *AppState, g: *gooey.Gooey) void {\n        g.quit();\n    }\n};\n\nfn setupKeymap(cx: *Cx) void {\n    const s = cx.state(AppState);\n    if (s.initialized) return;\n    s.initialized = true;\n\n    cx.gooey().keymap.bind(QuitApp, \"cmd-q\", null);\n}\n\nfn render(cx: *Cx) void {\n    setupKeymap(cx);\n\n    const quit_handler = cx.command(AppState.quitApp);\n\n    cx.render(ui.box(.{ .padding = .{ .all = 24 }, .gap = 16 }, .{\n        // cmd+q triggers quitApp via the action system\n        ui.onActionHandler(QuitApp, quit_handler),\n\n        // Button triggers the same handler on click\n        Button{\n            .label = \"Quit\",\n            .variant = .danger,\n            .on_click_handler = quit_handler,\n        },\n    }));\n}\n```\n\n| Example | Command | Description |\n|---|---|---|\n| Showcase | `zig build run` |\nFull feature demo with navigation |\n| Counter | `zig build run-counter` |\nSimple state management |\n| Animation | `zig build run-animation` |\nAnimation system with animateOn |\n| Pomodoro | `zig build run-pomodoro` |\nTimer with tasks and custom shader |\n| Dynamic Counters | `zig build run-dynamic-counters` |\nEntity creation and deletion |\n| Layout | `zig build run-layout` |\nFlexbox, shrink, text wrapping |\n| Glass | `zig build run-glass` |\nLiquid glass transparency effect |\n| Spaceship | `zig build run-spaceship` |\nSci-fi dashboard with hologram shader |\n| Actions | `zig build run-actions` |\nKeybindings and action system |\n| Select | `zig build run-select` |\nDropdown select component |\n| Tooltip | `zig build run-tooltip` |\nTooltip positioning and styling |\n| Modal | `zig build run-modal` |\nModal dialogs with animation |\n| Images | `zig build run-images` |\nImage loading and effects |\n| File Dialog | `zig build run-file-dialog` |\nNative file open/save dialogs |\n| A11y Demo | `zig build run-a11y-demo` |\nVoiceOver accessibility demo |\n| Accessible Form | `zig build run-accessible-form` |\nComplete accessible form example |\n| Drag & Drop | `zig build run-drag-drop` |\nDraggable items and drop targets |\n| Uniform List | `zig build run-uniform-list` |\nVirtualized list with 10,000 items |\n| Virtual List | `zig build run-virtual-list` |\nVariable-height virtualized list |\n| Data Table | `zig build run-data-table` |\nVirtualized table with 10,000 rows |\n| Code Editor | `zig build run-code-editor` |\nCode editor with syntax highlighting |\n\nSee [docs/accessibility.md](/duanebester/gooey/blob/main/docs/accessibility.md) for comprehensive accessibility documentation.\n\nTwo options for cross-platform logging (native + WASM):\n\n**Option A: gooey.std_options** — one-liner for\n\n`std.log`\n\ncompatibility:\n\n``` js\nconst gooey = @import(\"gooey\");\n\n// Routes std.log through console.log on WASM, default on native\npub const std_options = gooey.std_options;\n```\n\n**Option B: gooey.log** — zero-config, no\n\n`std_options`\n\nneeded:\n\n``` js\nconst log = gooey.log.scoped(.myapp);\n\nlog.info(\"connected to {s}\", .{host});\nlog.err(\"request failed: {}\", .{code});\n```\n\nOn native, `gooey.log`\n\ndelegates to `std.log.scoped()`\n\n. On WASM, it writes directly to the browser console. Use option A if you need third-party libraries to log through `std.log`\n\n. Use option B if you just want logging that works everywhere.\n\n⚠️ Temporarily deferred on Zig 0.16.0 (upstream`Io.Threaded`\n\n).`std.Io.Threaded`\n\ndoes not compile for`wasm32-freestanding`\n\non Zig 0.16.0 — its comptime body eagerly references`posix.system.getrandom`\n\nand`posix.IOV_MAX`\n\n, which resolve to`void`\n\n/absent on that target. This is an upstream issue, not a Gooey one. The`zig build wasm*`\n\nsteps have been removed from`build.zig`\n\n(the commands no longer exist), while the web code paths (`src/platform/web/`\n\n,`WebApp`\n\nin`app.zig`\n\n, and`src/examples/*_wasm.zig`\n\n) are deliberately left in place to resume compiling once upstream gates those references. Tracking:[.]`docs/zig-0.16-io-migration.md`\n\nOnce the upstream fix lands, the WASM build steps will be restored. The commands\nbelow are the intended interface — **currently inactive**:\n\n```\n# (currently disabled — see the note above)\n# zig build wasm                 # showcase\n# zig build wasm-counter\n# zig build wasm-dynamic-counters\n# zig build wasm-pomodoro\n# zig build wasm-spaceship\n# zig build wasm-layout\n# zig build wasm-select\n# zig build wasm-tooltip\n# zig build wasm-modal\n# zig build wasm-images\n# zig build wasm-file-dialog\n\n# Run with a local server\n# python3 -m http.server 8080 -d zig-out/web\n```\n\nSimple brute-force hot reload for development:\n\n```\nzig build hot                    # Showcase (default)\nzig build hot -- run-counter     # Specific example\nzig build hot -- run-pomodoro\nzig build hot -- run-glass\nsrc/\n├── app.zig          # App entry points (runCx, App, WebApp)\n├── cx.zig           # Unified context (Cx)\n├── root.zig         # Public API exports\n│\n├── core/            # Foundational types (geometry, events, shaders)\n├── input/           # Input handling (events, actions, keymaps)\n├── scene/           # GPU primitives (scene graph, batching)\n├── context/         # App context (focus, entity, dispatch, widget store)\n├── animation/       # Animation system and easing\n├── debug/           # Debugging tools and render stats\n│\n├── ui/              # Declarative builder (box, vstack, hstack, primitives)\n├── components/      # UI components (Button, TextInput, Modal, Tooltip, etc.)\n├── widgets/         # Stateful widget implementations (text input/area state)\n├── layout/          # Flexbox-style layout engine\n│\n├── text/            # Text rendering (CoreText, FreeType/HarfBuzz, Canvas)\n├── image/           # Image loading and atlas management\n├── svg/             # SVG rasterization (CoreGraphics, Linux, Canvas)\n├── platform/        # macOS/Metal, Linux/Vulkan/Wayland, WASM/WebGPU\n├── runtime/         # Frame rendering and input handling\n└── examples/        # Demo applications\n```\n\nGooey has full Linux support using Wayland and Vulkan. The showcase and all demos run on Linux.\n\n```\nLinux Platform Stack:\n┌─────────────────────────────────────┐\n│         gooey Application           │\n├─────────────────────────────────────┤\n│  LinuxPlatform  │  Window           │\n│  (event loop)   │  (XDG shell)      │\n├─────────────────────────────────────┤\n│  VulkanRenderer │  SceneRenderer    │\n│  (direct Vulkan, GLSL shaders)      │\n├─────────────────────────────────────┤\n│  Wayland Client  │  Vulkan Driver   │\n└─────────────────────────────────────┘\n```\n\n| Feature | Implementation |\n|---|---|\nWindowing |\nWayland via XDG shell (xdg-toplevel, xdg-decoration) |\nGPU Rendering |\nDirect Vulkan with GLSL shaders (unified, text, svg, image pipelines) |\nText Rendering |\nFreeType for rasterization, HarfBuzz for shaping, Fontconfig for font discovery |\nInput Handling |\nFull keyboard (evdev keycodes), mouse, scroll with modifier support |\nClipboard |\nWayland data-device protocol (copy/paste text) |\nFile Dialogs |\nXDG Desktop Portal via D-Bus (open, save, directory selection) |\nIME Support |\nzwp_text_input_v3 protocol for international text input |\nHiDPI |\nwp_viewporter protocol with scale factor support |\nServer Decorations |\nzxdg-decoration-manager-v1 protocol |\n\n**Wayland-only**- No X11 fallback (modern approach like Ghostty)** Direct Vulkan**- No wgpu-native dependency, full control over rendering** Native text stack**- FreeType/HarfBuzz/Fontconfig (same as most Linux apps)** XDG Portal integration**- Native file dialogs that respect user's desktop environment\n\n```\n# System packages (Debian/Ubuntu)\nsudo apt install \\\n    libwayland-dev \\\n    libvulkan-dev \\\n    libfreetype-dev \\\n    libharfbuzz-dev \\\n    libfontconfig-dev \\\n    libpng-dev \\\n    libdbus-1-dev\n\n# Fedora/RHEL\nsudo dnf install \\\n    wayland-devel \\\n    vulkan-loader-devel \\\n    freetype-devel \\\n    harfbuzz-devel \\\n    fontconfig-devel \\\n    libpng-devel \\\n    dbus-devel\n\n# Arch Linux\nsudo pacman -S \\\n    wayland \\\n    vulkan-icd-loader \\\n    vulkan-headers \\\n    freetype2 \\\n    harfbuzz \\\n    fontconfig \\\n    libpng \\\n    dbus\n# Build and run the showcase\nzig build run\n\n# Run specific demos\nzig build run-basic        # Simple Wayland + Vulkan test\nzig build run-text         # Text rendering demo\nzig build run-file-dialog  # XDG portal file dialogs\n\n# Compile shaders (only needed if you modify GLSL sources)\nzig build compile-shaders\n```\n\n**Custom cursors**- Cursor theming via wl_cursor not yet implemented** Hot reloading**- macOS-only currently (uses FSEvents)** Glass effects**- macOS-specific (compositor-dependent on Linux)** Multi-window**- Supported in platform but not fully tested\n\n```\n# Run all tests\nzig build test\n\n# Run tests under valgrind (Linux only - detects memory leaks)\nzig build test-valgrind\n\n# Check code formatting\nzig fmt --check src/ charts/\n```\n\nThe project uses GitHub Actions for CI. Every push and pull request runs:\n\n| Job | Platform | Description |\n|---|---|---|\n`test-linux` |\nUbuntu | Unit tests on Linux |\n`test-macos` |\nmacOS | Unit tests on macOS |\n`build-linux` |\nUbuntu | Build all optimization levels (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall) |\n`build-macos` |\nmacOS | Build all optimization levels |\n`build-wasm` |\nUbuntu | WebAssembly targets |\n`valgrind` |\nUbuntu | Memory leak detection via valgrind |\n`zig-fmt` |\nUbuntu | Code formatting check |\n\nValgrind integration helps catch memory issues early:\n\n```\n# Run tests with full leak checking\nzig build test-valgrind\n```\n\nThe `valgrind.supp`\n\nfile contains suppressions for known false positives from system libraries (Vulkan, Wayland, FreeType, HarfBuzz, etc.).", "url": "https://wpnews.pro/news/gooey-a-gpu-accelerated-ui-framework-for-zig", "canonical_source": "https://github.com/duanebester/gooey", "published_at": "2026-06-03 17:12:27+00:00", "updated_at": "2026-06-03 19:43:06.056204+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "ai-products"], "entities": ["Gooey", "Zig", "Metal", "Vulkan", "WebGPU", "WASM", "CoreText", "FreeType"], "alternates": {"html": "https://wpnews.pro/news/gooey-a-gpu-accelerated-ui-framework-for-zig", "markdown": "https://wpnews.pro/news/gooey-a-gpu-accelerated-ui-framework-for-zig.md", "text": "https://wpnews.pro/news/gooey-a-gpu-accelerated-ui-framework-for-zig.txt", "jsonld": "https://wpnews.pro/news/gooey-a-gpu-accelerated-ui-framework-for-zig.jsonld"}}