docs/advanced/multi-window
Capabilities · Native windows

Multi-window apps

Create isolated native windows for editors, previews or tool panels, all driven by one event loop that dispatches by window_id.

6 min read

When to use it

Single-window apps keep using App; its API is unchanged. Move to MultiWindowApp (also exported as zenit_app.Application) only when windows need their own lifecycle, input routing, GPU surface or accessibility tree.

Creating windows

main.zig
const std = @import("std");
const ui = @import("ui");
const zenit_app = @import("zenit_app");

fn mountEditor(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    _ = scope;
    return ui.box(cx, .{ .width = .fill(), .height = .fill() }, .{});
}

fn mountPreview(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    _ = scope;
    return ui.box(cx, .{ .width = .fill(), .height = .fill() }, .{});
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var application = zenit_app.MultiWindowApp.init(gpa.allocator(), .{});
    defer application.deinit();

    // createWindowWith = create + mount in one transaction: if mount fails,
    // every native / GPU resource of that window is torn down again.
    const editor = try application.createWindowWith(.{
        .window = .{ .width = 900, .height = 700, .title = "Editor" },
    }, mountEditor);

    _ = try application.createWindowWith(.{
        .window = .{ .width = 480, .height = 700, .title = "Preview" },
    }, mountPreview);

    _ = application.activateWindow(editor.windowId());
    try application.run(); // returns after the last window closes or quit()
}

Each createWindow* call returns an *App and takes the same config as a single-window App (window, console, frame_pacing, …). createWindow creates without mounting, so you call app.mount(mountFn) yourself; createWindowWith does both as one transaction. The repo’s zig build multi-window example exercises both.

Isolation guarantees

PER-WINDOW STACK
An event enters exactly one window, chosen by window_id; the shared model sits outside and the app notifies each window explicitly.
01
Separate per window

Native window, Cx (with its own reactive graph), font context, Metal surface / renderer / device queue, IME and accessibility route.

02
Isolated events

Pointer, keyboard, IME and drag events route by native window_id; menu commands route to the key window captured when they fired, not to whichever window happened to dequeue them.

03
Independent teardown

Closing a window tears down only its own resources while the others keep rendering; run() returns when the last one closes.

A real two-window app: the observed target and DevTools each have their own Cx and window. The harness switches the tool window to Console and filters it while the target stays alive.

Lifecycle

APIWhat it does
run()Drive the loop until the last window closes or quit() is called
tick()One round of pump + frame, for driving the loop yourself
closeWindow(id)Close one window; from inside a callback it is queued until a safe boundary
quit()Quit the whole app; remaining windows are torn down in order by deinit()
window(id)Find a live window by id; null once it has closed
activateWindow(id)Make a window the active one
setMenuModel / bindMenuCommandThe menu model is process-global; command callbacks bind per window
close.zig
const preview_id = preview.windowId(); // preview: *App from createWindowWith

// Safe from inside an input / menu / render callback: the close is queued
// and committed at the loop's iteration boundary.
_ = application.closeWindow(preview_id);

// Look windows up by id instead of holding *App across a close.
if (application.window(preview_id)) |app| {
    _ = app; // still alive
}

Sharing state

The cross-window model is owned by the app; each window keeps only its own view state. Every Cx has its own reactive graph, so don’t let one window’s Effect read a Signal from another window’s Scope, and never share Node pointers or Scope resources across Cx.

  • ✓

    The model lives outside the windows. Keep facts in a plain struct or an app-level store that outlives any single window.

  • ✓

    Each window mirrors what it needs. A window creates Signals in its own Scope; when the model changes, the app writes each window’s mirror explicitly.

  • ✓

    Unregister on close. After a window closes, the model must not keep pointers to that window’s Signals.

zenit · Dual-licensedFree 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 4c50245 2026-10-01