docs/advanced/multi-window
앱 기능 · Native windows

멀티 윈도우 앱

에디터, 미리보기, 도구 패널용으로 서로 격리된 네이티브 창을 만들고, window_id로 분배하는 하나의 이벤트 루프로 구동합니다.

약 6분 분량

언제 사용하나

단일 창 앱은 계속 App을 사용하며, 그 API는 바뀌지 않습니다. 창마다 독립된 생명주기, 입력 라우팅, GPU surface, 접근성 트리가 필요할 때만 MultiWindowApp(zenit_app.Application으로도 export됨)으로 옮깁니다.

창 생성

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()
}

createWindow* 호출은 매번 *App을 반환하며, 단일 창 App과 같은 설정(window, console, frame_pacing 등)을 받습니다. createWindow는 생성만 하고 마운트하지 않으므로 app.mount(mountFn)을 직접 호출합니다. createWindowWith는 생성과 마운트를 하나의 트랜잭션으로 처리합니다. 저장소의 zig build multi-window 예제가 두 방식을 모두 보여 줍니다.

격리 보장

PER-WINDOW STACK
이벤트는 window_id로 선택된 정확히 하나의 창에만 들어갑니다. 공유 모델은 창 바깥에 있고, 앱이 각 창에 명시적으로 알립니다.
01
창마다 독립

네이티브 window, Cx(자체 반응형 그래프 포함), 폰트 컨텍스트, Metal surface / renderer / device queue, IME와 접근성 경로.

02
이벤트 격리

포인터, 키보드, IME, 드래그 이벤트는 네이티브 window_id로 라우팅됩니다. 메뉴 명령은 우연히 큐에서 꺼낸 창이 아니라 발생 시점에 캡처한 key window로 전달됩니다.

03
독립적인 해제

창을 닫으면 그 창의 리소스만 해제되고 나머지 창은 계속 렌더링합니다. 마지막 창이 닫히면 run()이 반환됩니다.

실제 2창 앱: 관찰 대상 target과 DevTools가 각자 독립된 Cx와 창을 가집니다. harness가 도구 창을 Console로 전환해 필터링하는 동안 target 창은 계속 살아 있습니다.

생명주기

API역할
run()마지막 창이 닫히거나 quit()이 호출될 때까지 루프 구동
tick()pump + 프레임 한 회 실행, 루프를 직접 제어할 때
closeWindow(id)창 하나를 닫음. 콜백 안에서는 안전한 경계까지 큐에 대기
quit()앱 전체 종료. 남은 창은 deinit()이 순서대로 해제
window(id)id로 살아 있는 창 조회. 닫힌 뒤에는 null
activateWindow(id)창을 활성 창으로 지정
setMenuModel / bindMenuCommand메뉴 모델은 프로세스 전역. 명령 콜백은 창별로 바인딩
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
}

상태 공유

창을 가로지르는 모델은 앱이 소유하고, 각 창은 자신의 뷰 상태만 가집니다. Cx마다 반응형 그래프가 독립되어 있으므로 한 창의 Effect가 다른 창 Scope의 Signal을 읽게 해서는 안 되며, Node 포인터나 Scope 리소스를 Cx 간에 공유해서도 안 됩니다.

  • ✓

    모델은 창 바깥에 둡니다. 사실 데이터는 일반 struct나 앱 수준 store에 두어 어떤 창보다도 오래 살게 합니다.

  • ✓

    각 창은 필요한 부분만 미러링합니다. 창은 자신의 Scope에 Signal을 만들고, 모델이 바뀌면 앱이 각 창의 미러에 명시적으로 씁니다.

  • ✓

    닫을 때 등록을 해제합니다. 창이 닫힌 뒤에는 모델이 그 창의 Signal을 가리키는 포인터를 유지해서는 안 됩니다.

zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30