状态与事件
把跨帧稳定的状态交给 Cx 保管,用类型安全的 handler 响应用户输入。
绑定状态
cx.bindState(T, init) 在 Cx 的状态仓库里创建一个 T,返回跨帧稳定的 *T。再用 cx.on 把它的方法变成组件可以接收的 HandlerRef,不需要手工分配状态 ID。
const Counter = struct {
allocator: std.mem.Allocator,
value: u32 = 0,
label: ?*ui.Node = null,
buffer: [32]u8 = undefined,
fn increment(self: *Counter) void {
self.value += 1;
const label = self.label orelse return;
const text = std.fmt.bufPrint(
&self.buffer,
"Clicked {d} times",
.{self.value},
) catch return;
// Copies the text, marks it owned, frees the previous owned copy,
// and marks the node dirty only if the text actually changed.
label.setTextContent(self.allocator, text) catch return;
}
};
const counter = try cx.bindState(Counter, .{ .allocator = cx.allocator });
const on_click = cx.on(Counter, counter, Counter.increment);回调里换文本用 node.setTextContent(allocator, text):它复制内容并标记为 owned,旧的 owned 内容自动释放;只有文本真的变了才标脏,不需要再手动 markRenderDirty。结构体因此存一个 allocator 字段。
接到组件
组件的回调字段都是 ?HandlerRef。把上一步得到的 on_click 交给 Button,再把要更新的文本节点记到状态结构体里。
const label = try ui.text(cx, "Clicked 0 times", .{
.color = cx.tokens.color.fg_primary,
});
counter.label = label;
const button = try ui.widgets.Button(.{
.label = "Increment",
.variant = .primary,
.on_click = on_click,
}).mount(scope, cx);
try root.appendChild(cx.allocator, button);
try root.appendChild(cx.allocator, label);需要拿到新值的回调(Checkbox、Switch 的勾选态,Input、Tabs 的文本或 id)用带值的构造器,例如 ui.Cx.boolHandlerFrom。
const Prefs = struct {
notify: bool = false,
fn setNotify(self: *Prefs, checked: bool) void {
self.notify = checked;
}
};
const prefs = try cx.bindState(Prefs, .{});
const notify = try ui.widgets.Checkbox(.{
.label_text = "Notify me",
.on_change = ui.Cx.boolHandlerFrom(Prefs, prefs, Prefs.setNotify),
}).mount(scope, cx);一次点击的路径
平台送来的是按下和抬起,不是「点击」。命中测试按 Z 序从上往下找到最上层节点;按下与抬起落在同一元素上时,EventDispatcher 合成 click,沿 capture → target → bubble 三个阶段分发,在节点上调用 on_click 这个 HandlerRef,最终进入你绑定的方法。
.ignored未处理,继续传播,允许默认行为.handled已处理,继续传播,但阻止默认行为.stop已处理并停止传播Handler 的几种构造
cx.on(State, s, State.m)fn (*State) void按钮点击等「发生了」ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox、Switch、Accordionui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput、Textarea、Tabs、Radio;切片只在回调期间有效ui.clickable(node, handler)—给任意节点挂 on_click底层事件
按钮、输入框等组件已经封装了常见交互。只有实现自定义交互时才需要 ui.events.Event:它覆盖鼠标(按下、抬起、click、double_click、移动、进出、滚轮、捏合、文件拖入)、键盘、text_input、ime_preedit / ime_commit 与焦点。键盘命令优先走 ui.actions,焦点移动优先走 ui.focus。
ui.widgets.* callbacksui.hooks.useHover / useFocusRingui.actionsui.interaction.drag / range_hoverui.events.Event生命周期与清理
绑定状态归 Cx 所有,在 Cx 销毁时释放。如果 T 声明了 pub 的 deinit(*T),框架会在释放前自动调用它——ArrayList、HashMap、分配的缓冲区都放在这里清理即可。Signal、Memo、Effect 与 onCleanup 回调则归 Scope,随 scope.dispose() 一起释放。
const EditorState = struct {
allocator: std.mem.Allocator,
lines: std.ArrayList([]u8) = .{},
// Must be pub: the state store detects it and calls it when the Cx deinits.
pub fn deinit(self: *EditorState) void {
for (self.lines.items) |line| self.allocator.free(line);
self.lines.deinit(self.allocator);
}
};
// The initial value must not own resources yet; allocate after binding.
const editor = try cx.bindState(EditorState, .{ .allocator = cx.allocator });
// Do NOT also call scope.onCleanup(EditorState, editor, EditorState.deinit):
// the framework already calls deinit, so that would free everything twice.scope.onCleanup 只用在两种情况:清理函数不是 pub deinit,或者资源必须跟随某个 Scope(页面)而不是整个 Cx 释放。
// A resource that must follow the page (Scope), not the whole Cx.
const PageCache = struct {
allocator: std.mem.Allocator,
hits: std.StringHashMapUnmanaged(u32) = .{},
// Not named deinit, so the state store will not call it again.
fn release(self: *PageCache) void {
self.hits.deinit(self.allocator);
self.hits = .{};
}
};
const cache = try cx.bindState(PageCache, .{ .allocator = cx.allocator });
try scope.onCleanup(PageCache, cache, PageCache.release);- ✓
初值不持有资源。传给
bindState的初值应是零值或只含指针、分配器这类借用字段;需要分配的东西在拿到*T之后再建。 - ✓
分配器存成字段。
deinit(*T)只有一个参数,释放时用的分配器要保存在结构体里。 - ✓
不要缓存失效节点。Node 指针只在所属树和 Scope 的生命周期内有效;页面卸载或重建后,不要再从状态里访问旧节点。