Gooey:面向 Zig 的 GPU 加速 UI 框架

Hacker News Top 工具

摘要

Gooey 是一个面向 Zig 的 GPU 加速 UI 框架,通过 Metal、Vulkan/Wayland 和 WebGPU/WASM 支持 macOS、Linux 和浏览器。它提供声明式 UI、动画、主题、无障碍和零外部依赖。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/03 18:41

duanebester/gooey

来源:https://github.com/duanebester/gooey

Gooey

一个用于 Zig 的 GPU 加速 UI 框架,目标平台包括 macOS (Metal)、Linux (Vulkan/Wayland) 和浏览器 (WASM/WebGPU)。

加入 Gooey Discord (https://discord.gg/bmzAZnZJyw)

早期开发阶段:API 正在演进。CI (https://github.com/duanebester/gooey/actions/workflows/ci.yml)

使用 Gooey 构建的示例应用——chat-zig (https://github.com/duanebester/chat-zig),一个基于 Anthropic Claude 的客户端,利用 Zig 0.16 的 std.Io 栈实现异步 HTTP:

特性

  • GPU 渲染 - Metal (macOS)、Vulkan (Linux) 并支持 MSAA 抗锯齿(WebGPU/WASM 在 Zig 0.16 上被上游阻塞——参见 WASM)
  • 声明式 UI - 基于组件的布局,使用 ui.* 基本元素和 flexbox 风格系统
  • Cx/UI 分离 - Cx 负责状态、处理器和焦点;ui.* 负责布局基本元素
  • 纯状态模式 - 可测试的状态方法,自动触发重新渲染
  • 动画系统 - 内置缓动动画,支持 animateOn 触发器
  • 实体系统 - 动态创建/删除实体,自动清理
  • 保留型控件 - TextInput、TextArea、Checkbox、滚动容器
  • 文本渲染 - CoreText (macOS)、FreeType/HarfBuzz (Linux)、Canvas (WASM)
  • 自定义着色器 - 可自行引入 Metal/GLSL 着色器
  • 拖放 - 类型安全的拖放源和目标,支持 pointer_events 控制
  • 液态玻璃 - macOS 26.0+ Tahoe 透明窗口效果
  • 动作与快捷键 - 上下文相关的动作系统,支持快捷键映射
  • 主题 - 内置浅色/深色模式支持
  • 图片与 SVG - 加载图片并渲染带样式的 SVG 图标
  • 文件对话框 - 原生文件打开/保存对话框(macOS、Linux、WASM)
  • 剪贴板 - 全平台原生剪贴板支持
  • IME 支持 - 输入法编辑器,用于国际化文本输入
  • 无障碍 - 内置屏幕阅读器支持(VoiceOver、Orca、ARIA),包含语义角色和实时区域
  • 零依赖 - 无需外部 Zig 包;仅链接系统框架/库(Objective-C 运行时绑定内置于代码树中)

快速开始

要求: Zig 0.16.0+

依赖: 无。Gooey 没有外部 Zig 包依赖——build.zig.zon 未列出任何依赖。它仅链接平台系统框架/库(参见下面的平台说明)。

macOS: macOS 12.0+

Linux: Wayland 合成器、Vulkan 驱动、FreeType、HarfBuzz、Fontconfig、libpng、D-Bus

zig build run                # 展示演示
zig build run-counter        # 计数器示例
zig build run-todo           # 待办事项应用(状态、处理器、TextInput、列表)
zig build run-animation      # 动画演示
zig build run-pomodoro       # 番茄钟计时器
zig build run-glass          # 液态玻璃效果
zig build run-spaceship      # 含着色器的太空仪表盘
zig build run-dynamic-counters # 实体系统演示
zig build run-layout         # Flexbox、收缩、文本换行
zig build run-actions        # 快捷键演示
zig build run-select         # 下拉选择组件
zig build run-tooltip        # 工具提示组件
zig build run-modal          # 模态对话框
zig build run-images         # 图片加载和样式
zig build run-file-dialog    # 原生文件对话框
zig build run-uniform-list   # 虚拟化列表(1 万项)
zig build run-virtual-list   # 可变高度列表
zig build run-data-table     # 虚拟化表格(1 万行)
zig build run-code-editor    # 带语法高亮的代码编辑器
zig build test               # 运行测试

示例

一个小型待办事项应用,展示了 API 中具有代表性的部分:一个纯的、无 UI 的状态模型;cx.update / cx.updateWith / cx.command 处理器;一个绑定的 TextInput;Checkbox 和 Button;列表迭代;以及在无 UI 情况下测试状态的单元测试。完整可运行的源代码位于 src/examples/todo.zig(zig build run-todo)。其状态模型由底部显示的测试覆盖,这些测试作为 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";

// 状态是纯的——不依赖 UI 知识,完全可测试。
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 = "", // 双向绑定到 TextInput
    filter: Filter = .all,

    // 纯逻辑——下面的测试驱动它。
    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,
        };
    }

    // 命令——需要框架访问(绑定仅从控件流向状态,
    // 因此我们访问保留的输入控件以在添加后清除它)。
    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; // 强制分析(也在 WASM 上连接 @export)。
}

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 }),

        // 输入行:TextInput 绑定到 state.draft;Add 是一个命令。
        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),
            },
        }),

        // 过滤器:每个按钮将其枚举值打包到处理器参数中。
        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 },
        }),

        // 列表,或空状态提示。
        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),
            },
        }),
    }));
}

// 迭代位于一个组件中,因为每一行都需要 `cx` 来获取其处理器。
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 {
        // 背景 + 交叉轴居中对齐意味着这是一个 `box`(带有
        // `.direction = .row`),而不是 `hstack`——栈仅携带 gap/对齐/内边距。
        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),
        });
    }
};

// 状态可在无 UI 情况下测试。
test "remove 保持列表连续" {
    var s = AppState{};
    s.pushTodo("a");
    s.pushTodo("b");
    s.pushTodo("c");
    s.remove(1); // 删除 "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 和 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());
}

API 模式

Gooey 将 Cx(上下文)和 ui(布局基本元素)的关注点分离:

模块用途示例
cx.*状态、处理器、动画、焦点cx.state(), cx.update(), cx.animate(), cx.changed(), cx.render()
ui.*布局容器和基本元素ui.box(), ui.rect(), ui.hstack(), ui.vstack(), ui.text(), ui.when()
// /dev/null/example.zig#L1-19
fn render(cx: *Cx) void {
    const s = cx.state(AppState);
    cx.render(ui.box(.{ .width = 100 }, .{
        ui.text("Hello", .{}),

        // 条件渲染
        ui.when(s.show_extra, .{
            ui.text("Extra content", .{}),
        }),

        // 遍历项目
        ui.each(&s.items, struct {
            fn render(item: Item, _: usize) @TypeOf(ui.text("", .{})) {
                return ui.text(item.name, .{});
            }
        }.render),
    }));
}

关键基本元素:

  • ui.box() - 带 flexbox 布局的容器
  • ui.rect() - 无子元素的 box(分割线、间隔块、彩色块)
  • ui.hstack() / ui.vstack() - 水平/垂直堆栈
  • ui.text() / ui.textFmt() - 文本渲染
  • ui.when(cond, children) - 条件渲染
  • ui.maybe(optional, fn) - 如果 optional 有值则渲染
  • ui.each(items, fn) - 为每个项目渲染
  • ui.scroll(id, style, children) - 可滚动容器
  • ui.spacer() - 弹性空间

处理器类型

方法签名使用场景
cx.update()fn(*State) void纯状态变更
cx.updateWith()fn(*State, Arg) void带参数的变更
cx.command()fn(*State, *Gooey) void框架访问(焦点、退出、实体)
cx.commandWith()fn(*State, *Gooey, Arg) void带参数的框架访问
cx.defer()fn(*State, *Gooey) void在当前事件完成后运行
cx.deferWith()fn(*State, *Gooey, Arg) void带参数的延迟运行

注意: 状态类型会自动从方法指针的第一个参数推断——无需单独传递。

带参数的处理器

*With 变体(updateWith、commandWith、deferWith)允许你向处理器传递数据。参数在处理器创建时捕获,并在调用时传递:

// 在列表渲染回调中 - 捕获索引
.on_click_handler = cx.updateWith(index, State.selectItem),

// 处理器接收捕获的值
pub fn selectItem(self: *State, index: u32) void {
    self.selected = index;
}

8 字节限制: 参数被打包到一个 u64 中以实现零分配存储。这意味着你的参数必须 ≤ 8 字节。如果超出,将收到编译错误:

error: updateWith: argument type 'MyLargeStruct' exceeds 8 bytes. Use a pointer or index instead.

8 字节内可容纳的内容:

类型大小✓/✗
u8, i8, bool1 字节✓
u16, i162 字节✓
u32, i32, f324 字节✓
u64, i64, f648 字节✓
usize (64 位)8 字节✓
*T (任何指针)8 字节✓
struct { x: u32, y: u32 }8 字节✓
[2]u328 字节✓
struct { a: u32, b: u32, c: u32 }12 字节✗

处理更大数据的变通方法:

// 选项 1:使用数据的索引
.on_click_handler = cx.updateWith(row_index, State.selectRow),

// 选项 2:使用指针(如果数据比处理器生命周期长)
.on_click_handler = cx.updateWith(&self.items[i], State.editItem),

// 选项 3:将数据存储在状态中,传递一个 ID
pub fn openFile(self: *State, file_id: u32) void {
    const file = self.files.get(file_id) orelse return;
    // ... 使用 file.path, file.name 等
}

延迟命令

当你需要在当前事件处理器完成后运行代码时,使用 defer。这对于以下情况至关重要:

  • 模态对话框 - 它们运行自己的事件循环,如果在事件处理期间调用将导致死锁
  • 文件选择器 - 与模态对话框相同的原因
  • 重量级操作 - 延迟工作以避免阻塞当前帧
// 在命令处理器中,使用 g.deferCommand():
pub fn openFolder(self: *State, g: *Gooey) void {
    _ = self;
    g.deferCommand(State, State.openFolderDeferred);
}

fn openFolderDeferred(self: *State, g: *Gooey) void {
    _ = g;
    // 在此处安全地打开模态对话框 - 我们已不在事件处理中
    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);
    }
}

// 带参数(同样适用 8 字节限制):
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);
    }
}

延迟命令队列最多可容纳 32 个命令,并在每个事件周期后刷新。

后台工作(Io.Queue / Io.Group)

使用 Zig 0.16 的 std.Io 在 UI 线程之外运行耗时工作——网络请求、文件 I/O、繁重计算。框架本身不拥有执行器:后台任务通过 cx.io().async(...) 生成,其结果通过一个有界的 std.Io.Queue(T) 返回,渲染循环每帧从该队列中取出结果。后台任务从不直接触碰 UI 状态——它们只推送类型化的结果——因此状态上没有锁。这与 src/image/loader.zig 用于异步图像 URL 获取的模式相同。

// 后台任务交给渲染循环的类型化结果。
const Fetch = union(enum) {
    ok: []const u8,
    failed,
};

const State = struct {
    // 固定容量、静态支持的信道——初始化后无需分配。
    result_buffer: [16]Fetch = undefined,
    result_queue: std.Io.Queue(Fetch) = undefined,

    // 拥有运行中的任务,以便可以一起取消。
    fetch_group: std.Io.Group = .init,

相似文章

我为 Emacs 构建了一个 GPU 后端

Hacker News Top

作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。

vgpu

Product Hunt

vgpu 是一个针对 WebGPU 的 TypeScript 库,具有类型化着色器导入、一个轻量级的 GPU 优先 API,并兼容浏览器、无头 Node 和测试环境。