docs/guide/project-structure
Primeros pasos · Organizar el código

Estructura del proyecto

Coloca el punto de entrada de la compilación, la UI de cada funcionalidad, los estilos y los recursos donde puedan crecer, y ten claro quién es dueño de cada bloque de memoria.

6 min de lectura

Estructura sugerida

zenit no impone una estructura de directorios. Mantén estables los archivos de compilación y coloca juntos el árbol, el estado y los estilos de cada funcionalidad, para que cambiar una no implique saltar por todo el repositorio.

myapp/
myapp/
├── build.zig
├── build.zig.zon
└── src/
    ├── main.zig
    ├── app_state.zig
    ├── assets.zig
    └── features/
        └── dashboard/
            ├── view.zig
            ├── state.zig
            └── styles.zig

Responsabilidades de cada archivo

ArchivoSe encarga deEvitar
main.zigAllocator, inicialización de App, montaje raízAcumular UI de páginas concretas
view.zigEl árbol de nodos y la conexión de eventosColores literales, transformaciones de datos pesadas
state.zigEl estado de negocio y sus métodosPunteros de larga duración a nodos que pueden eliminarse o reconstruirse
styles.zigFunciones de estilo con nombre basadas en Token, fn (*const ui.ThemeTokens) ui.BoxStyleHacer IO o mutar el estado

Las funciones de estilo son puras; constructores como ui.boxStyled / ui.vstackStyled las evalúan al montar y las vuelven a aplicar con los nuevos tokens cuando cambia el tema; los estilos literales en línea no obtienen eso.

styles.zig + view.zig
// features/dashboard/styles.zig
const ui = @import("ui");

pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
    return .{
        .padding = ui.Padding.all(t.space._6),
        .gap = t.space._4,
        .background = t.color.bg_secondary,
        .corner_radius = t.radius.xl,
    };
}

// features/dashboard/view.zig
const S = @import("styles.zig");
const panel = try ui.vstackStyled(cx, S.card, .{});

Límite de importación

La superficie pública tiene dos niveles: los tipos y constructores comunes están en ui.X, y las funciones avanzadas se agrupan en ui.<group>.X (ui.widgets, ui.fx, ui.events, ui.theme, …). Todo lo que queda fuera de esos dos niveles es interno.

zig
const ui = @import("ui");

// Tier 1: what most UI code needs
const Node = ui.Node;
const Signal = ui.Signal;

// Tier 2: grouped by concern
const Router = ui.fx.Router;
const Button = ui.widgets.Button;
const Event = ui.events.Event;

El sistema de compilación lo garantiza: zenit.attach solo da a la app los módulos ui y zenit_app, así que los módulos internos como render y gpu no se pueden importar en absoluto (consulta el diagrama en la Introducción).

Propiedad y ciclos de vida

Dos dueños, dos ciclos de vida. Cx es de nivel de ventana: es dueño del árbol de nodos y de cada struct asignado por cx.bindState hasta Cx.deinit. Scope es dueño de los recursos reactivos — Signals, Memos, Effects, callbacks de onCleanup y recursos registrados — y los libera en dispose(); los scopes hijos se liberan con su padre.

OWNERSHIP
Hacer dispose de un Scope de página no libera los structs que creaste con bindState en esa página: viven hasta que se cierra la ventana. Cx.deinit ejecuta: dispose del Scope raíz → liberar nodos → liberar el estado.

Estado vinculado: declara un pub deinit

Si el struct declara un pub deinit(*T), el framework lo llama automáticamente antes de liberar el struct en Cx.deinit. Si la limpieza necesita un allocator, guárdalo como campo.

state.zig
const Item = struct { title: []const u8 };

const Model = struct {
    allocator: std.mem.Allocator,
    items: std.ArrayList(Item) = .empty,

    // Must be pub: Cx.deinit detects it and calls it before freeing the struct.
    pub fn deinit(self: *Model) void {
        self.items.deinit(self.allocator);
    }
};

const model = try cx.bindState(Model, .{ .allocator = cx.allocator });
// Do NOT also call scope.onCleanup(Model, model, Model.deinit):
// that would run deinit twice.

Recursos con ciclo de vida de página: onCleanup

Recurre a scope.onCleanup solo en dos casos: la limpieza no es pub, o el recurso debe seguir el ciclo de vida de un Scope (por ejemplo, una página) en lugar del de la ventana. Asigna esos objetos tú mismo en lugar de usar bindState.

features/dashboard/view.zig
const PageCache = struct {
    allocator: std.mem.Allocator,
    rows: std.ArrayList(Item) = .empty,

    fn release(self: *PageCache) void {
        self.rows.deinit(self.allocator);
        self.allocator.destroy(self);
    }
};

fn mountDashboard(cx: *ui.Cx, parent: *ui.Scope) !*ui.Node {
    const scope = try parent.childScope();

    // Page-lifetime resource: freed when this scope is disposed,
    // not when the window closes.
    const cache = try cx.allocator.create(PageCache);
    cache.* = .{ .allocator = cx.allocator };
    scope.onCleanup(PageCache, cache, PageCache.release) catch |err| {
        cx.allocator.destroy(cache);
        return err;
    };

    return ui.vstack(cx, .{ .gap = cx.tokens.space._4 }, .{});
}

Lista de verificación

  • ✓

    Mantén main.zig corto. Allocator, App.init y runWith; nada más.

  • ✓

    Importa solo ui y zenit_app. Si algo que necesitas no está en los dos niveles públicos, abre un issue en lugar de meterte en el código interno.

  • ✓

    Ciclo de vida de ventana → bindState, de página → Scope. Da al primero un pub deinit y al segundo un onCleanup; nunca ambos.

  • ✓

    Escribe los estilos como funciones con nombre. Ponlos en styles.zig y úsalos con los constructores *Styled para que los cambios de tema funcionen sin más.

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