v0.1.0-alpha
官网Home
docs/guide/state-events
核心概念 · InteractionCore concepts · Interaction

状态与事件State & events

把跨帧稳定的状态交给 Cx 保管,用类型安全的 handler 响应用户输入。Let the Cx own state that must survive across frames, and answer user input with type-safe handlers.

预计阅读 9 分钟9 min read

绑定状态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.

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 字段。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.

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

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 回调与状态文本在同一帧链路里更新。The harness clicks the Checkbox’s real hit area: the checked visual, the on_change callback and the status text all update in the same frame pipeline.

一次点击的路径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.

EVENT ROUTING
HandlerRef 只是「回调函数 + 上下文指针」。cx.on 生成的 callback 把上下文转回 *Counter 再调用方法;状态本身一直留在 Cx 的状态仓库里。A HandlerRef is just “callback + context pointer”. The callback built by cx.on casts the context back to *Counter and calls the method; the state itself stays in the Cx state store.
处理结果EventResult含义Meaning
.ignored未处理,继续传播,允许默认行为Not handled; keep propagating and allow the default behavior
.handled已处理,继续传播,但阻止默认行为Handled; keep propagating but prevent the default
.stop已处理并停止传播Handled; stop propagation

Handler 的几种构造Building handlers

构造器Constructor方法签名Method signature适用Use for
cx.on(State, s, State.m)fn (*State) void按钮点击等「发生了」Button clicks and other “it happened” events
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox、Switch、AccordionCheckbox, Switch, Accordion
ui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput、Textarea、Tabs、Radio;切片只在回调期间有效Input, Textarea, Tabs, Radio; the slice is only valid during the call
ui.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.

需求Need首选 APIPreferred API
点击、输入、选择Click, type, selectui.widgets.* callbacks
悬停、焦点环Hover, focus ringui.hooks.useHover / useFocusRing
快捷键与命令Shortcuts and commandsui.actions
拖拽、范围悬停Drag, range hoverui.interaction.drag / range_hover
原始键鼠、滚轮、IMERaw keys/mouse, scroll, IMEui.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().

OWNERSHIP
两种生命周期:Cx 级(整个窗口 / 应用)与 Scope 级(一个页面或面板)。Two lifetimes: Cx-wide (the whole window / app) and Scope-wide (one page or panel).
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 释放。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.

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 之后再建。Initial values own nothing. Pass a zero value or only borrowed fields (pointers, an allocator) to bindState; 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.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30