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
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
Native window, Cx (with its own reactive graph), font context, Metal surface / renderer / device queue, IME and accessibility route.
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.
Closing a window tears down only its own resources while the others keep rendering; run() returns when the last one closes.
Lifecycle
run()Drive the loop until the last window closes or quit() is calledtick()One round of pump + frame, for driving the loop yourselfcloseWindow(id)Close one window; from inside a callback it is queued until a safe boundaryquit()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 closedactivateWindow(id)Make a window the active onesetMenuModel / bindMenuCommandThe menu model is process-global; command callbacks bind per windowconst 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.