docs/guide/state-events
Concepts clés · Interaction

État et événements

Confiez au Cx l'état qui doit survivre d'une frame à l'autre, et répondez aux entrées utilisateur avec des handlers typés.

9 min de lecture

État lié

cx.bindState(T, init) crée un T dans le magasin d'état du Cx et renvoie un *T qui reste valide d'une frame à l'autre. cx.on transforme ensuite l'une de ses méthodes en un HandlerRef accepté par n'importe quel composant — sans ID d'état attribué à la main.

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

Remplacez le texte depuis un callback avec node.setTextContent(allocator, text) : il copie le contenu et le marque owned, libère la copie owned précédente, et ne marque le nœud comme sale que si le texte a réellement changé — pas de markRenderDirty manuel. C'est pourquoi le struct conserve un champ allocator.

Brancher les composants

Les champs de callback des composants sont tous des ?HandlerRef. Passez le on_click obtenu plus haut à un Button, et stockez dans le struct d'état le nœud texte à mettre à jour.

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

Les callbacks porteurs d'une valeur — l'état coché d'un Checkbox ou d'un Switch, le texte ou l'id d'un Input ou de Tabs — utilisent un constructeur qui transmet la valeur, comme 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);
Le harness clique sur la vraie zone de clic du Checkbox : l'aspect coché, le callback on_change et le texte d'état se mettent à jour dans le même pipeline de frame.

Le trajet d'un clic

La plateforme transmet des appuis et des relâchements, pas des « clics ». Le hit-testing parcourt les enfants de l'avant vers l'arrière (ordre Z) pour trouver le nœud le plus haut ; quand l'appui et le relâchement tombent sur le même élément, l'EventDispatcher synthétise un click, le distribue via capture → target → bubble, appelle le HandlerRef on_click du nœud et aboutit dans la méthode que vous avez liée.

EVENT ROUTING
Un HandlerRef n'est qu'un « callback + pointeur de contexte ». Le callback construit par cx.on reconvertit le contexte en *Counter et appelle la méthode ; l'état lui-même reste dans le magasin d'état du Cx.
EventResultSignification
.ignoredNon traité ; la propagation continue et le comportement par défaut est permis
.handledTraité ; la propagation continue mais le comportement par défaut est empêché
.stopTraité ; la propagation s'arrête

Construire des handlers

ConstructeurSignature de méthodeUsage
cx.on(State, s, State.m)fn (*State) voidClics de bouton et autres événements « c'est arrivé »
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox, Switch, Accordion
ui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput, Textarea, Tabs, Radio ; le slice n'est valide que pendant l'appel
ui.clickable(node, handler)—Attacher on_click à n'importe quel nœud

Événements de bas niveau

Les boutons, champs de saisie et consorts encapsulent déjà les interactions courantes. N'utilisez ui.events.Event que pour une interaction personnalisée : il couvre la souris (down, up, click, double_click, déplacement, entrée/sortie, défilement, magnify, glisser de fichiers), le clavier, text_input, ime_preedit / ime_commit et le focus. Préférez ui.actions pour les commandes clavier et ui.focus pour déplacer le focus.

BesoinAPI recommandée
Clic, saisie, sélectionui.widgets.* callbacks
Survol, anneau de focusui.hooks.useHover / useFocusRing
Raccourcis et commandesui.actions
Glisser, survol de plageui.interaction.drag / range_hover
Touches/souris brutes, défilement, IMEui.events.Event

Durée de vie et nettoyage

L'état lié appartient au Cx et est libéré lors du deinit du Cx. Si T déclare un deinit(*T) pub, le framework l'appelle juste avant la libération — placez-y le nettoyage de vos ArrayList, HashMap et buffers. Les Signals, Memos, Effects et callbacks onCleanup appartiennent à un Scope et disparaissent avec scope.dispose().

OWNERSHIP
Deux durées de vie : au niveau du Cx (toute la fenêtre / l'app) et au niveau du Scope (une page ou un panneau).
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.

N'utilisez scope.onCleanup que lorsque le nettoyage n'est pas un deinit pub, ou lorsqu'une ressource doit suivre un Scope (une page) plutôt que tout le 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);
  • ✓

    Les valeurs initiales ne possèdent rien. Passez à bindState une valeur zéro ou uniquement des champs empruntés (pointeurs, un allocator) ; allouez une fois que vous avez le *T.

  • ✓

    Gardez l'allocator dans un champ. deinit(*T) ne prend qu'un argument, donc l'allocator servant à libérer doit vivre dans le struct.

  • ✓

    Ne gardez pas de nœuds morts. Un pointeur de Node n'est valide que tant que son arbre et son Scope vivent ; après le démontage ou la reconstruction d'une page, n'accédez plus aux anciens nœuds via l'état.

zenit · Double licenceGratuit pour les projets open source sous GPL-3.0-only ; les produits propriétaires ou commerciaux nécessitent une licence commerciale.Contacter l’auteur : zongyi.xzy#gmail.com (remplacez # par @)zenit 5f9add5+wip 2026-09-30