Gooey:面向 Zig 的 GPU 加速 UI 框架
摘要
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, bool | 1 字节 | ✓ |
u16, i16 | 2 字节 | ✓ |
u32, i32, f32 | 4 字节 | ✓ |
u64, i64, f64 | 8 字节 | ✓ |
usize (64 位) | 8 字节 | ✓ |
*T (任何指针) | 8 字节 | ✓ |
struct { x: u32, y: u32 } | 8 字节 | ✓ |
[2]u32 | 8 字节 | ✓ |
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,
相似文章
@yff666:最近构建了 Keel,一个纯 Go 的桌面 UI 框架。基于 Gio,无需 HTML/CSS/JS/WebView。支持 macOS、Win…
一位开发者发布了 Keel,一个基于 Gio 构建的纯 Go 桌面 UI 框架,无需 HTML/CSS/JS/WebView,支持 macOS、Windows 和 Linux,并提供组件库、打包工具以及基于 MCP 的 Agent 端到端测试能力。
我为 Emacs 构建了一个 GPU 后端
作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。
@LinearUncle: chatwise 大佬使用rust 做完waku 这个开源的harness 后,不知道在rust身上吃了什么苦,紧锣密鼓又开源了一个纯golang 写的桌面GUI 框架-MyGo. https://github.com/egoist/my…
egoist 开源了 MyGo,一个纯 Go 编写的桌面应用框架,支持系统 WebView 网页界面与纯 Go GPU 绘制的原生 UI 混合使用,无 cgo,单二进制仅几 MB,内置类型化 IPC 和完整的打包签名发布链路。
@Ryrenz: 炸裂!gpui-kit 已经为你准备好桌面应用所需的全部 75 个组件,开箱即用,你再也不用手动编写 UI …
gpui-kit 是基于 Zed 的 GPUI 引擎构建的 Rust 桌面 UI 框架,提供了 75+ 个生产可用的组件,支持 WebAssembly 与 AccessKit 无障碍能力,在 GitHub 上已获得 1.57 万颗星标,并于 9 月迅速从 v0.6.0 迭代至 v0.7.0。
vgpu
vgpu 是一个针对 WebGPU 的 TypeScript 库,具有类型化着色器导入、一个轻量级的 GPU 优先 API,并兼容浏览器、无头 Node 和测试环境。