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 4c50245 2026-10-01