Status: future · Priority: high
Note: This is a future plan, not a commitment. The syntax and API shown here are proposals — they can be completely different when actually implemented.
Decisions update (2026-09-20): ids intern to integers (WID for instances, RID for routes — MID-style, no runtime string lookup), markup navigation is
<a href>, and C++ gets a first-class window API (app::windows::*). See Decisions — old vs new.
Shipped → main docs.
new Window,useWindow(+methods/properties), andnavigateare implemented and documented for users in Windows & Routes and `Window` / `useWindow`. This page keeps the full target surface, open questions, and what remains. Two honest deltas from the original design: handles lowered as WID ints (not objects —useWindow(id)yields an invalid handle tested via.closed, notnull), and static WID interning gave way to runtime minting + registry lookup (switch dispatch is future work).
A programmatic API for creating and managing windows from JavaScript — new Window(...), the useWindow hook, App.quit(), App.on(...) — layered on top of the existing declarative system. Today windows are declared via the windowConfig export or <morph-window>; this adds runtime control.
The target surface (shipped subset: constructor, navigate, close, show/hide, closed, title, on('close'), useWindow — see main docs; the rest below is future):
class Window {
constructor(routeId: string, config?: WindowConfig) // handle is valid immediately (Electron-style)
title: string
width: number
height: number
id: string | null // explicit id, if given at creation
closed: boolean // true once closed — by you OR by the user (X button, task manager)
ready: boolean // true once content is mounted and the first frame is drawn
show(): void // shows immediately (blank until ready)
hide(): void
close(): boolean // safe no-op if already closed; returns whether it closed
load(routeId: string, props?: object): Promise<void> // async content (Electron's loadFile)
navigate(routeId: string, props?: object): boolean // swap this window's page; false if closed
on(event: 'close' | 'resize' | 'focus' | 'ready', handler: Function): void
ready(): Promise<this> // resolves when content is mounted (Electron's 'ready-to-show')
}
interface WindowConfig {
title?: string
width?: number
height?: number
id?: string // addressable by useWindow(id)
data?: object // passed to the page component as props
}
// Access the window that rendered the current component — no argument needed,
// the compiler resolves it from the component's compiled window tree:
function useWindow(): Window
// Or resolve any window by id/route — sync, null if it doesn't exist:
function useWindow(id: string): Window | null
class App {
static quit(): void
static on(event: 'ready' | 'before-quit', handler: Function): void
}
class CSS {
static load(path: string): void // already exists today
}Route/id strings above are what you write — at build time they lower to integers (RID for routes, WID for instances; the MID pattern). new Window("/auth/login") emits create_RID(app::routes::kAuthLogin, …); useWindow("login-window") emits useWindow_WID(3). No hash lookup runs per call; the string tables are consulted once at registration.
app::windows::*)Native code (native.cpp) gets the same power with no JS round-trip — tray icons, global hotkeys, and C++-initiated flows. Shipped in the per-project morph_api.h next to the state wrappers, documented in native-cpp.md:
#include "morph_api.h"
// RID in, WID out — same integers the JSX lowers to
int wid = app::windows::open(app::routes::kSettings, {.width = 500});
app::windows::navigate(wid, app::routes::kAuthLogin); // false if closed
app::windows::close(wid); // safe no-op, like JS close()
app::windows::on_close(wid, []{ /* fires for X-button too */ });
app::windows::set_title(wid, "New Title");
bool gone = app::windows::closed(wid);Rules:
data/props cross over as JsObject (exists today): app::windows::open(route, {.data = JsObject{…}})useWindow() with no argument — a component's "current window" is compile-time context that doesn't cross the FFI. If native code needs the invoker's window, JS passes the WID in.on_close takes a std::function<void()> stored in the registry; handles resolve by WID at fire time, so a closed window's callback never dangles.new Window(routeId, config) returns a valid handle immediately, exactly like Electron's new BrowserWindow():
// Electron
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
// Morph
const a = new Window("/auth/login", {
width: 400,
height: 320, // overrides the route's windowConfig
data: { userId: 42 } // delivered to the page as props
})
a.show()Native window creation is fast — the handle exists before any heavy work. What's asynchronous is content: mounting the route's component, layout, and the first frame. If your code depends on content, wait for it:
await a.ready() // wait for first frame (or: a.on('ready', ...))
a.navigate("/settings") // safe anytime afterThe constructor itself loads the route (like loadFile); win.load(routeId, props) re-loads a different route into an existing window.
The registry is the single source of truth — WindowManager owns the window; handles are views. Every operation on a handle re-resolves the window id through the registry at call time, so handles never dangle:
const loginWin = useWindow("login-window") // null if it doesn't exist — sync
if (!loginWin) return // handle the missing case
await loginWin.ready() // wait until content is mounted
loginWin.close() // close it ourselves
// …but the user might close it via the X button or the task manager —
// the handle stays safe and reports the truth:
loginWin.closed // true — updated via registry close events
loginWin.on('close', () => { /* fires for ANY close: user X, task manager, App.quit */ })
loginWin.close() // safe no-op — returns false, doesn't crash
loginWin.navigate("/settings") // fails gracefully (returns false)Key rules:
WindowManager marks the window closed, erases it from the registry, and fires close events on every live handlefalse / no-op, because the registry lookup fails instead of dereferencing a dead windownew Window("/auth/login") again gives a fresh handle; old handles stay marked closedWindowManager holds shared_ptr<MorphWindow>; JS handles hold weak references resolved by id. A raw pointer to a deleted window is the segfault this design preventsmx-route-* / mx-window-* lint rules + generated typed routes); runtime null/false behavior is only the last line of defense. See Typo safety — validated at build timeclass + new (e.g. new Window(...)) — the handle is synchronous, like Electron; async only where content is involved (load, ready)new (e.g. App.quit())Constructors take a config object; Window additionally takes a route id for the page it renders.
Window — each instance owns one GLFW window + one OpenGL context and one root layout tree. Creation wires into the existing window stack (WindowManager), layout engine, and dirty-rendering system. Resize marks layout dirty + repaint, matching today's MorphWindow behavior.App — a thin static facade over the runtime lifecycle. ready fires after first frame, before-quit before teardown (so users can save state)..d.ts in the shipped node_modules/morph module so autocomplete works; never leak compiler-internal types.new Window(routeId, config) calls in user JS are translated by TSToCppTranslator into WindowManager operations. The route id is resolved through the route.mx manifest (see File-Based Windows & Pages). useWindow(...) is resolved at compile time — the component's containing window id is threaded through the IR.
Lowering detail: string literals never reach the runtime. The manifest pass owns the string→int tables and every call site emits the interned integer (create_RID(app::routes::kSettings, …), useWindow_WID(3)) — the same interning the MID system uses for state tags. Only non-literal (dynamic) ids keep a runtime string lookup, flagged by mx-window-dynamic.
| Piece | State |
|---|---|
CSS import (import "./x.css"; CSS.load() deprecated) |
✅ Shipped |
windowConfig export + <morph-window> |
✅ Shipped (declarative) |
WindowManager (register/close/allClosed) |
✅ Shipped |
new Window / useWindow / navigate / close / show / hide / closed / title / on('close') |
✅ Shipped (main docs) |
Window / App classes (full surface: ready, load, id, width/height props, resize/focus events) |
❌ Not built |
C++ app::windows::* + app::routes:: in morph_api.h |
✅ Shipped (open/navigate generated per-project; close/show/hide/title/closed/on_close in core/window_api.h; proven via native.cpp in route-test) |
.d.ts for imperative API |
❌ Not built |
destroy() must release textures, buffers, and the GL context; WindowManager::~WindowManager currently owns teardown.logic.so hot reload must re-wire imperatively created windows the same way it re-wires declarative ones.| # | Old | New (2026-09-20) | Why |
|---|---|---|---|
| 1 | String window/route ids with per-call registry lookup | WID/RID integers, MID-style interning, switch dispatch | Zero-cost calls; kill-strings consistency; build-time typos |
| 2 | Two id kinds vague ("auto vs explicit") | RID = what to show (per route.mx, app::routes::); WID = which instance (per new Window, explicit id:) |
Keeps "same route, two windows" working; create(RID) → WID |
| 3 | Window control JS-only | C++ API too (app::windows::*, RID in / WID out, JsObject data, on_close) |
Tray/hotkey/C++-driven flows; explicit WID keeps FFI honest |
| 4 | morph-* event actions for windows (claimed "already generated" — never was) |
<a href> for markup (see file-routing.md) |
Browser-familiar; nothing to migrate |
App singleton (quit / ready / before-quit events)Window.ready(), resize/focus events, .d.ts, full Window object surfacenative.cppMaximize/minimize/fullscreen follow Electron's vocabulary (the closest
spiritual relative — JS-driven desktop windows), mapped onto Morph's
win.show()/win.hide() methods and win.closed/win.title
properties:
| Method | Property | Notes |
|---|---|---|
win.maximize() |
win.isMaximized |
|
win.unmaximize() |
— | Industry word ("unmaximize"); alternatives all worse |
win.minimize() |
win.isMinimized |
|
win.restore() |
— | Undoes minimize or maximize (OS convention, one method) |
win.setFullscreen(flag) |
win.isFullscreen |
Bool setter covers on+off (Electron-style), no unfullscreen |
(Qt's showMaximized/showMinimized/showFullScreen/showNormal and Tauri's
unminimize were considered; Electron wins on guessability.) Lowering
is identical to show/hide at every layer (morpher arm →
window_api.h inline → WindowManager forward → MorphWindow +
GLFW); fullscreen saves/restores geometry. Headless-assertable via
state flags like the wm:* checks.
Prioritized: (1) programmatic geometry (setSize/getSize,
setPosition/getPosition, center() — MorphWindow::setSize exists in
C++ but has no handle method), state-change events, alwaysOnTop,
opacity; (2) frameless + drag regions, focus/blur/resize/move
events, skip-taskbar, min/max enforcement; (3) transparency/blur,
taskbar integrations, display API, monitor-picking for fullscreen.
Morph has CSS cursor: default | pointer | text with hover switching
only. Missing, in framework order:
wait, crosshair, move, not-allowed, grab,
grabbing, resize arrows, zoom-in/out (CSS keywords; map to GLFW's
6 + XCursor theme names). No API — just keywords.setOverrideCursor — CSS can't do this):
win.setCursor("wait") / win.setCursor(null) restores CSS behavior.glfwCreateCursor over the existing
stb_image loader) and hide (none = 1×1 transparent cursor).win.cursorPosition() (needed by tooltips + drag code);
setters stay out in v1.GLFW already has the one-call version
(GLFW_CURSOR_DISABLED = hidden + centered + relative deltas) that Qt
users reimplement by hand and Electron lacks entirely — a genuine
differentiator, not catch-up. Proposed: win.setCursorLock(bool) +
win.isCursorLocked + movementX/Y on mouse events while locked (routed
from the existing cursorPosCb pipeline) + glfwRawMouseMotion where
supported + ESC releases by default. Headless-untestable (screenshot/GIF
verification like all display behavior). Use cases: 3D/model viewers,
infinite-pan canvas, dial widgets — needs no render-pipeline changes.
Yes, we know this is not a game engine. No, that will not stop us from stealing the game engines' best trick — the call costs one line, the feel win is enormous, and somebody out there is absolutely going to ship a tiny FPS in a popup window just to prove a point.