---
title: "Multi-window | zenit Zig UI Docs"
description: "Create isolated native windows for editors, previews or tool panels, all driven by one event loop that dispatches by window_id."
url: https://ui.surf/zenit/docs/advanced/multi-window
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/advanced/multi-window.md
alternate_es: https://ui.surf/zenit/es/docs/advanced/multi-window.md
alternate_ja: https://ui.surf/zenit/ja/docs/advanced/multi-window.md
alternate_ko: https://ui.surf/zenit/ko/docs/advanced/multi-window.md
alternate_fr: https://ui.surf/zenit/fr/docs/advanced/multi-window.md
alternate_de: https://ui.surf/zenit/de/docs/advanced/multi-window.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Multi-window apps

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

## 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`

```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.

> NOTE
> 
> **At most 16 windows.** `MultiWindowApp.max_windows` is 16, an explicit bound for the allocation-free lifecycle ledger. Beyond it, `createWindow*` returns `error.TooManyWindows`.

## 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.

**Separate per window**

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

**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.

**Independent teardown**

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

[Video](https://ui.surf/zenit/media/devtools-console.mp4?v=05e8c163e3)

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

| API | What 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` / `bindMenuCommand` | The menu model is process-global; command callbacks bind per window |

`close.zig`

```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
}
```

> WARNING
> 
> **\*App is invalid after close.** Once a window is torn down, any `*App` pointer you held for it dangles. Keep the window id and look it up with `application.window(id)` when needed.

## 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.
    

> TIP
> 
> **DevTools is a window too.** The full Elements / Components / Console / Performance panel can live in a second window, observing another window’s Cx; for a quick look at layout bounds, the one-line overlay is lighter. See [DevTools](https://ui.surf/zenit/docs/advanced/devtools).
