docs/guide/state-events
核心概念 · Interaction

状态与事件

把跨帧稳定的状态交给 Cx 保管,用类型安全的 handler 响应用户输入。

预计阅读 9 分钟

绑定状态

cx.bindState(T, init) 在 Cx 的状态仓库里创建一个 T,返回跨帧稳定的 *T。再用 cx.on 把它的方法变成组件可以接收的 HandlerRef,不需要手工分配状态 ID。

counter.zig
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,再把要更新的文本节点记到状态结构体里。

view.zig
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。

prefs.zig
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);
Harness 点击 Checkbox 的真实命中区:勾选视觉、on_change 回调与状态文本在同一帧链路里更新。

一次点击的路径

平台送来的是按下和抬起,不是「点击」。命中测试按 Z 序从上往下找到最上层节点;按下与抬起落在同一元素上时,EventDispatcher 合成 click,沿 capture → target → bubble 三个阶段分发,在节点上调用 on_click 这个 HandlerRef,最终进入你绑定的方法。

EVENT ROUTING
HandlerRef 只是「回调函数 + 上下文指针」。cx.on 生成的 callback 把上下文转回 *Counter 再调用方法;状态本身一直留在 Cx 的状态仓库里。
处理结果含义
.ignored未处理,继续传播,允许默认行为
.handled已处理,继续传播,但阻止默认行为
.stop已处理并停止传播

Handler 的几种构造

构造器方法签名适用
cx.on(State, s, State.m)fn (*State) void按钮点击等「发生了」
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox、Switch、Accordion
ui.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。

需求首选 API
点击、输入、选择ui.widgets.* callbacks
悬停、焦点环ui.hooks.useHover / useFocusRing
快捷键与命令ui.actions
拖拽、范围悬停ui.interaction.drag / range_hover
原始键鼠、滚轮、IMEui.events.Event

生命周期与清理

绑定状态归 Cx 所有,在 Cx 销毁时释放。如果 T 声明了 pub 的 deinit(*T),框架会在释放前自动调用它——ArrayList、HashMap、分配的缓冲区都放在这里清理即可。Signal、Memo、Effect 与 onCleanup 回调则归 Scope,随 scope.dispose() 一起释放。

OWNERSHIP
两种生命周期:Cx 级(整个窗口 / 应用)与 Scope 级(一个页面或面板)。
editor_state.zig
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 释放。

page_cache.zig
// 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 的生命周期内有效;页面卸载或重建后,不要再从状态里访问旧节点。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30