docs/guide/state-events
Conceptos clave · Interaction

Estado y eventos

Deja que el Cx sea dueño del estado que debe sobrevivir entre frames, y responde a la entrada del usuario con handlers con tipos seguros.

9 min de lectura

Estado vinculado

cx.bindState(T, init) crea un T en el almacén de estado del Cx y devuelve un *T que sigue siendo válido entre frames. Luego cx.on convierte uno de sus métodos en un HandlerRef que acepta cualquier componente — sin IDs de estado asignados a mano.

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);

Cambia el texto desde un callback con node.setTextContent(allocator, text): copia el contenido y lo marca como owned, libera la copia owned anterior y solo marca el nodo como sucio si el texto cambió de verdad — sin markRenderDirty manual. Por eso el struct guarda un campo allocator.

Conectar componentes

Todos los campos de callback de los componentes son ?HandlerRef. Pasa el on_click de arriba a un Button y guarda en el struct de estado el nodo de texto que quieras actualizar.

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);

Los callbacks que llevan un valor — el estado marcado de un Checkbox o Switch, el texto o el id de Input o Tabs — usan un constructor que transporta valor, como 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);
El harness hace clic en el área de impacto real del Checkbox: el aspecto marcado, el callback on_change y el texto de estado se actualizan en el mismo pipeline de frame.

El recorrido de un clic

La plataforma entrega pulsaciones y liberaciones, no “clics”. El hit-testing recorre los hijos de delante hacia atrás (orden Z) para encontrar el nodo superior; cuando la pulsación y la liberación caen sobre el mismo elemento, el EventDispatcher sintetiza un click, lo despacha por capture → target → bubble, llama al HandlerRef on_click del nodo y termina en el método que vinculaste.

EVENT ROUTING
Un HandlerRef es solo “callback + puntero de contexto”. El callback que genera cx.on convierte el contexto de vuelta a *Counter y llama al método; el estado en sí permanece en el almacén de estado del Cx.
EventResultSignificado
.ignoredNo gestionado; sigue propagándose y permite el comportamiento por defecto
.handledGestionado; sigue propagándose pero impide el comportamiento por defecto
.stopGestionado; detiene la propagación

Construir handlers

ConstructorFirma del métodoUso
cx.on(State, s, State.m)fn (*State) voidClics de botón y otros eventos de “ha ocurrido”
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox, Switch, Accordion
ui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput, Textarea, Tabs, Radio; el slice solo es válido durante la llamada
ui.clickable(node, handler)—Añadir on_click a cualquier nodo

Eventos de bajo nivel

Los botones, los campos de entrada y compañía ya encapsulan las interacciones habituales. Recurre a ui.events.Event solo para interacciones personalizadas: cubre el ratón (down, up, click, double_click, movimiento, entrada/salida, scroll, magnify, arrastre de archivos), el teclado, text_input, ime_preedit / ime_commit y el foco. Prefiere ui.actions para los comandos de teclado y ui.focus para mover el foco.

NecesidadAPI preferida
Clic, escritura, selecciónui.widgets.* callbacks
Hover, anillo de focoui.hooks.useHover / useFocusRing
Atajos y comandosui.actions
Arrastre, hover de rangoui.interaction.drag / range_hover
Teclas/ratón en bruto, scroll, IMEui.events.Event

Ciclo de vida y limpieza

El estado vinculado pertenece al Cx y se libera cuando el Cx hace deinit. Si T declara un deinit(*T) pub, el framework lo llama justo antes de liberarlo — pon ahí la limpieza de tus ArrayList, HashMap y buffers. Los Signals, Memos, Effects y callbacks de onCleanup pertenecen a un Scope y desaparecen con scope.dispose().

OWNERSHIP
Dos ciclos de vida: a nivel de Cx (toda la ventana / app) y a nivel de Scope (una página o 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.

Usa scope.onCleanup solo cuando la limpieza no sea un deinit pub, o cuando un recurso deba seguir a un Scope (una página) en lugar de a todo el 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);
  • ✓

    Los valores iniciales no poseen nada. Pasa a bindState un valor cero o solo campos prestados (punteros, un allocator); asigna memoria cuando ya tengas el *T.

  • ✓

    Guarda el allocator como campo. deinit(*T) recibe un solo argumento, así que el allocator usado para liberar tiene que vivir en el struct.

  • ✓

    No retengas nodos muertos. Un puntero a Node solo es válido mientras vivan su árbol y su Scope; después de que una página se desmonte o se reconstruya, no accedas a nodos antiguos a través del estado.

zenit · Doble licenciaGratis para proyectos de código abierto bajo GPL-3.0-only; los productos cerrados o comerciales necesitan una licencia comercial.Contacta con el autor: zongyi.xzy#gmail.com (cambia # por @)zenit 5f9add5+wip 2026-09-30