状态与事件State & events
把跨帧稳定的状态交给 Cx 保管,用类型安全的 handler 响应用户输入。Let the Cx own state that must survive across frames, and answer user input with type-safe handlers.
绑定状态Bound state
cx.bindState(T, init) 在 Cx 的状态仓库里创建一个 T,返回跨帧稳定的 *T。再用 cx.on 把它的方法变成组件可以接收的 HandlerRef,不需要手工分配状态 ID。cx.bindState(T, init) creates a T in the Cx state store and returns a *T that stays valid across frames. cx.on then turns one of its methods into a HandlerRef any component accepts — no hand-assigned state IDs.
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 字段。Swap text from a callback with node.setTextContent(allocator, text): it copies the content and marks it owned, frees the previous owned copy, and only marks the node dirty when the text actually changed — no manual markRenderDirty. That’s why the struct keeps an allocator field.
接到组件Wiring components
组件的回调字段都是 ?HandlerRef。把上一步得到的 on_click 交给 Button,再把要更新的文本节点记到状态结构体里。Component callback fields are all ?HandlerRef. Hand the on_click from above to a Button, and store the text node you want to update in the state struct.
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。Callbacks that carry a value — the checked state of a Checkbox or Switch, the text or id from Input or Tabs — use a value-carrying constructor such as 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);一次点击的路径How a click travels
平台送来的是按下和抬起,不是「点击」。命中测试按 Z 序从上往下找到最上层节点;按下与抬起落在同一元素上时,EventDispatcher 合成 click,沿 capture → target → bubble 三个阶段分发,在节点上调用 on_click 这个 HandlerRef,最终进入你绑定的方法。The platform delivers presses and releases, not “clicks”. Hit-testing walks children back to front (Z order) to find the topmost node; when down and up land on the same element, the EventDispatcher synthesizes a click, dispatches it through capture → target → bubble, calls the node’s on_click HandlerRef, and ends up in the method you bound.
.ignored未处理,继续传播,允许默认行为Not handled; keep propagating and allow the default behavior.handled已处理,继续传播,但阻止默认行为Handled; keep propagating but prevent the default.stop已处理并停止传播Handled; stop propagationHandler 的几种构造Building handlers
cx.on(State, s, State.m)fn (*State) void按钮点击等「发生了」Button clicks and other “it happened” eventsui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox、Switch、AccordionCheckbox, Switch, Accordionui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput、Textarea、Tabs、Radio;切片只在回调期间有效Input, Textarea, Tabs, Radio; the slice is only valid during the callui.clickable(node, handler)—给任意节点挂 on_clickAttach on_click to any node底层事件Low-level events
按钮、输入框等组件已经封装了常见交互。只有实现自定义交互时才需要 ui.events.Event:它覆盖鼠标(按下、抬起、click、double_click、移动、进出、滚轮、捏合、文件拖入)、键盘、text_input、ime_preedit / ime_commit 与焦点。键盘命令优先走 ui.actions,焦点移动优先走 ui.focus。Buttons, inputs and friends already wrap the common interactions. Reach for ui.events.Event only for custom interaction: it covers the mouse (down, up, click, double_click, move, enter/leave, scroll, magnify, file drag), keyboard, text_input, ime_preedit / ime_commit, and focus. Prefer ui.actions for keyboard commands and ui.focus for moving focus.
ui.widgets.* callbacksui.hooks.useHover / useFocusRingui.actionsui.interaction.drag / range_hoverui.events.Event生命周期与清理Lifetime & cleanup
绑定状态归 Cx 所有,在 Cx 销毁时释放。如果 T 声明了 pub 的 deinit(*T),框架会在释放前自动调用它——ArrayList、HashMap、分配的缓冲区都放在这里清理即可。Signal、Memo、Effect 与 onCleanup 回调则归 Scope,随 scope.dispose() 一起释放。Bound state is owned by the Cx and freed when the Cx deinits. If T declares a pub deinit(*T), the framework calls it right before freeing — put your ArrayList, HashMap and buffer cleanup there. Signals, Memos, Effects and onCleanup callbacks belong to a Scope and go away with 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 释放。Use scope.onCleanup only when the cleanup is not a pub deinit, or when a resource must follow a Scope (a page) rather than the whole 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之后再建。Initial values own nothing. Pass a zero value or only borrowed fields (pointers, an allocator) tobindState; allocate after you have the*T. - ✓
分配器存成字段。
deinit(*T)只有一个参数,释放时用的分配器要保存在结构体里。Keep the allocator as a field.deinit(*T)takes one argument, so the allocator used for freeing has to live in the struct. - ✓
不要缓存失效节点。Node 指针只在所属树和 Scope 的生命周期内有效;页面卸载或重建后,不要再从状态里访问旧节点。Don’t hold dead nodes. A Node pointer is valid only while its tree and Scope live; after a page unmounts or rebuilds, don’t reach old nodes through state.