Status: future · Priority: high
Runtime control for showing, closing, and navigating between windows. The WindowManager singleton owns every window (shared_ptr, WID-keyed); open(), close(), hide(), and helper-level navigate() are wired and proven.
Decisions update (2026-09-20): window ids are interned to integers (WID, MID-style — no runtime string lookup), routes intern to integers (RID,
app::routes::consts), navigation uses<a href>(themorph-open/close/navigateactions never existed — the old claim below was stale and is struck through), and C++ gets its own window API. See Decisions — old vs new.
Shipped → main docs. Window control from JSX is implemented and documented for users in Windows & Routes and `Window` / `useWindow`. This page keeps the runtime design, open questions, and what remains. Implementation note: dynamic WIDs are runtime-minted (switch dispatch is future work).
morph-open, morph-close, morph-navigate) are already generated by the event emitter<a href> instead (see file-routing.md) — this bullet is kept only so the history is honest.useWindowNavigation and window control go through the useWindow hook, not static App calls. Shipped subset (handles are WID ints; missing windows yield invalid handles tested via .closed): `Window` / `useWindow`. The original proposal follows — ready() and object-typed handles are still future:
const win = useWindow() // the window rendering this component
const win = useWindow("settings-win") // any window, by id — null if not running
const win = useWindow("/auth/login") // or by route id — most-recently-focused live window on that route
win.navigate("/settings", { theme: "dark" }) // swap this window's page
win.close()
win.show() / win.hide()
win.title = "New Title"
win.on('close', () => { ... })(Id/route literals lower to RID consts at build time; dynamic WIDs mint at runtime — the strings above are what you write, never what runs. ready() is not built; content mounts synchronously today.)
New windows are created synchronously like Electron — new Window(routeId, config) returns a valid handle immediately; only content is async:
const settings = new Window("/settings", { width: 500, height: 400 })
settings.show()
await settings.ready() // only when you need the first frameWindows are owned by the registry, not by your handle. That's what keeps handles safe when the user closes a window by the X button or the task manager:
const loginWin = useWindow("login-window") // invalid handle if it doesn't exist — sync
if (loginWin.closed) return // handle the missing case
await loginWin.ready() // wait until content is mounted
loginWin.close()
// user closes it via the X button — the stale handle reports the truth:
loginWin.closed // true — registry notified via the GLFW close callback
loginWin.close() // safe no-op, returns false — never crashes
loginWin.navigate("/x") // fails gracefully, returns false
loginWin.on('close', ...) // fired for ANY close: user X, task manager, App.quitThe user can always defeat your bookkeeping — the X button, the task manager, the OS. The design assumption is: every window can die at any moment, and every operation must survive that. Re-open with new Window(...) again; old handles stay closed.
WindowManager)void open(WID id); // show a registered window
void close(WID id); // ✅ implemented (delete + erase; becomes shared_ptr)
bool navigate(WID win, RID route); // swap the window's page — direct swap, no historyIds are integers, not strings. Route literals lower to RID consts at build time (the MID pattern); dynamic window ids mint at runtime (mintWid) with explicit id: values registered once as aliases. Non-literal lookups resolve through the registry. Route ids intern the same way to an RID ("/settings" → app::routes::kSettings, see file-routing.md) — RID says what to show, WID says which instance. (Static WID switch dispatch remains future work.)
The event emitter does not generate window calls today (see the struck-through claim above). Navigation from markup goes through <a href>:
<a href="/settings">Settings</a> {/* navigate current window */}
<a href="/settings" target="_blank">Settings</a> {/* open as new window */}
<a href="https://example.com">Help</a> {/* external → OS browser */}open() ✅ — windows can be created hidden (GLFW_VISIBLE hint); open shows + schedules first paint.navigate() ✅ at helper level — swaps the root content and delivers props to the new page; per-window mount contexts keep instances independent.close() ✅ — deletes the window and erases it from the manager. Safety rule: the manager holds shared_ptr<MorphWindow>; JS handles resolve through the registry by id — never raw pointers to deleted windows. Renderer teardown binds the dying context; clipboard handle can't dangle.| Piece | State |
|---|---|
WindowManager (register / close / allClosed / teardown) |
✅ Shipped — now shared_ptr + WID-keyed, handles resolve by id |
event_emitter → wm.open/close/navigate |
✅ Generated (historical — the morph-* attrs never existed; <a href> replaces them) |
open() |
✅ Shipped (glfwShowWindow + hidden creation via GLFW_VISIBLE) |
navigate() |
✅ Shipped at the helper level (__morph_navigate_window; the raw C++ stub waits for the C++ API step) |
| Hidden-but-registered window state | ✅ Shipped (proven by window-test popup) |
| Per-window frame channels + context-safe teardown | ✅ Shipped (global frame state caused black screens; renderer deletes on own context) |
useWindow hook |
✅ Shipped (WID-int handles; see file-routing) |
navigate need a history stack (back/forward) or is a direct swap enough?glfwCreateWindow(…, nullptr, nullptr) already passes share=nullptr). Sharing saves only duplicate texture/atlas memory while adding cross-window resource-lifetime hazards, and the per-frame MakeContextCurrent switch exists either way. If memory ever matters, share the font atlas via the CPU-side glyph cache, not the GL context.| # | Old | New (2026-09-20) | Why |
|---|---|---|---|
| 1 | Window ids are strings, looked up in unordered_map<string, …> per call |
WID: build-time interning to ints, switch dispatch (MID pattern); strings resolved once at registration | Zero-cost calls; consistent with kill-runtime-strings; typos die at build time |
| 2 | morph-open / morph-close / morph-navigate JSX actions, "already generated" |
Removed before birth — the claim was stale, they never existed; navigation is <a href> |
Browser-familiar; nothing to migrate; URL scheme separates internal vs external |
| 3 | Route ids are runtime strings resolved through the manifest | RID: "/settings" lowers to app::routes::kSettings (int const); JSX keeps strings |
No string lookup; C++ compiler independently rejects typos (app::routes::setings doesn't exist) |
| 4 | navigate semantics undecided (history vs swap) |
Direct swap, no history stack | Simpler; history is additive later |
| 5 | GL context sharing undecided | Independent contexts (status quo) | Isolation; sharing's only win is memory, at real lifetime risk |
| 6 | Window control is JS-only | C++ API too: app::windows::{open,navigate,close,on_close} in morph_api.h, taking RID in / WID out |
Tray icons, hotkeys, C++-driven flows; C++ always addresses a WID explicitly (no ambient "current window" across FFI) |
<a href> desugar in the translator → navigate / new-window / browser calls; most-recently-focused wins by-route lookup; data→propsapp::windows::* + app::routes:: in morph_api.h, documented in native-cpp.mddata, driven independently from JSX and native.cpp; deliberate typos proving linter + C++ compiler both catch them