mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
161 lines
7.4 KiB
Markdown
161 lines
7.4 KiB
Markdown
# Window manager
|
|
|
|
Everything in `src/os/`. It has no UI and no knowledge of Nostr — it manages rectangles,
|
|
stacking order and focus, and nothing else. The shell in `src/components/os/` renders
|
|
what it decides.
|
|
|
|
## State
|
|
|
|
```ts
|
|
interface WindowState {
|
|
id: string; // `${appId}-${counter}`, unique for the session
|
|
appId: string; // key into the registry
|
|
title: string; // shown in the title bar, Window menu and browser tab
|
|
x, y: number; // position, relative to the desktop (not the viewport)
|
|
width, height: number;
|
|
z: number; // stacking order
|
|
minimized: boolean;
|
|
maximized: boolean;
|
|
prevRect?: Rect; // geometry before maximizing, restored on un-maximize
|
|
params: AppParams; // Record<string, string> — the app's own state
|
|
}
|
|
|
|
interface WindowManagerState {
|
|
windows: WindowState[];
|
|
focusedId: string | null; // null means the desktop itself has focus
|
|
counter: number; // mints window ids
|
|
maxZ: number; // highest z currently in use
|
|
}
|
|
```
|
|
|
|
Window coordinates are **relative to the desktop surface**, which starts below the menu
|
|
bar. `getViewport()` in `layout.ts` already subtracts `MENUBAR_HEIGHT` (28px), so a
|
|
window at `y: 0` sits directly under the bar rather than behind it.
|
|
|
|
## The reducer
|
|
|
|
`windowReducer.ts` holds every transition and is pure — it is the one place to look when
|
|
window behaviour is wrong. Actions:
|
|
|
|
`OPEN_APP` · `CLOSE_WINDOW` · `FOCUS_WINDOW` · `MOVE_WINDOW` · `RESIZE_WINDOW` ·
|
|
`MINIMIZE` · `RESTORE` · `TOGGLE_MAXIMIZE` · `SET_TITLE` · `SET_PARAMS` ·
|
|
`MINIMIZE_ALL` · `CLOSE_ALL` · `VIEWPORT_CHANGED` · `HYDRATE`
|
|
|
|
Four rules are worth knowing because they are not obvious from the action names:
|
|
|
|
**Singletons.** `OPEN_APP` on an app with `singleton !== false` does not create a second
|
|
window: it focuses the existing one, un-minimizes it, and replaces its params if any were
|
|
passed. Only `notes` opts out (`singleton: false`), because comparing two threads
|
|
side by side is the point.
|
|
|
|
**Z-index normalisation.** Focusing sets `z = maxZ + 1`. Once `maxZ` would pass 9000
|
|
(`Z_NORMALIZE_THRESHOLD`), every window is renumbered from 1 in its current order. Without
|
|
this a long session drifts upwards forever.
|
|
|
|
**Title truncation.** `SET_TITLE` collapses whitespace and cuts at 48 characters
|
|
(`MAX_TITLE_LENGTH`). Apps title themselves after content they loaded from a relay, and
|
|
relay content has no length limit — an article headline would otherwise blow out the title
|
|
bar, the Window menu and the browser tab.
|
|
|
|
**Focus after hiding.** Closing or minimizing the focused window moves focus to the
|
|
topmost window that is still visible, not to nothing.
|
|
|
|
## Geometry (`layout.ts`)
|
|
|
|
| Export | Purpose |
|
|
|---|---|
|
|
| `MENUBAR_HEIGHT` | 28px. The desktop starts here. |
|
|
| `getViewport()` | Desktop size — viewport minus the menu bar |
|
|
| `maximizedRect(vp)` | Full desktop area |
|
|
| `halfRect(side, vp)` | Left or right half, for edge snapping |
|
|
| `clampPosition(x, y, size, vp)` | Keeps ≥ 80px (`KEEP_VISIBLE`) of the window reachable and never lets it go above the desktop origin |
|
|
| `clampRect(rect, minSize, vp)` | Shrinks a window that no longer fits, then re-clamps its position |
|
|
| `cascadePosition(size, openCount, vp)` | Centre, offset by 28px per open window, wrapping every 6 |
|
|
| `fitSize(defaultSize, minSize, vp)` | A new window never exceeds the viewport it opens into |
|
|
|
|
`VIEWPORT_CHANGED` runs `clampRect` over every window when the browser is resized, so
|
|
nothing ends up stranded off-screen. Maximized windows simply take the new full rect.
|
|
|
|
## Gestures
|
|
|
|
`useDrag.ts` and `useResize.ts` are pointer-event based. Both follow the same pattern,
|
|
and the reason for it matters:
|
|
|
|
> During a gesture the geometry is written **straight to the DOM node** via
|
|
> `style.transform` / `style.width` / `style.height`, and the reducer is dispatched
|
|
> **once, on `pointerup`**.
|
|
|
|
Routing every `pointermove` through React state would re-render the whole window stack on
|
|
each frame. `WindowFrame` is additionally wrapped in `React.memo`, and an effect
|
|
re-synchronises the inline styles from state once the gesture commits, so React is the
|
|
single source of truth again between gestures.
|
|
|
|
While a gesture is live, `document.body` gets the class `os-dragging`, which disables
|
|
pointer events inside window content — otherwise dragging across a window would select
|
|
text or trigger hovers.
|
|
|
|
**Resize handles.** Eight of them (`RESIZE_HANDLES`): 6px along each edge, 12px in each
|
|
corner, each with the cursor from `HANDLE_CURSOR`. Dragging a north or west edge moves the
|
|
origin as well as the size; when the minimum size is reached the moving edge pins so the
|
|
window stops sliding instead of drifting.
|
|
|
|
**Snapping.** Within 12px (`SNAP_THRESHOLD`) of an edge the drag reports a `SnapZone`
|
|
(`'left' | 'right' | 'maximize'`). `WindowLayer` draws a ghost rectangle for it, and on
|
|
release the window takes that geometry. A maximized window cannot be dragged at all —
|
|
un-maximize it first.
|
|
|
|
## Keyboard (`useOsKeyboard.ts`)
|
|
|
|
| Shortcut | Action |
|
|
|---|---|
|
|
| `⌘/Ctrl + K` | Command palette |
|
|
| `⌘/Ctrl + ,` | Settings |
|
|
| `⌘/Ctrl + \`` | Cycle focus through visible windows in z-order |
|
|
| `⌘/Ctrl + W` | Close focused window |
|
|
| `⌘/Ctrl + M` | Minimize focused window |
|
|
|
|
`⌘W` and `⌘M` are suppressed while the target is an input, textarea, select or
|
|
`contenteditable`, so typing never destroys a window.
|
|
|
|
## Persistence (`persistence.ts`)
|
|
|
|
Stored in `localStorage` under `nostr:os-session`, versioned (`version: 1`), debounced by
|
|
300ms so dragging does not thrash storage. On load:
|
|
|
|
- The payload is validated with a Zod schema; anything malformed or from another version
|
|
is discarded and the desktop boots empty.
|
|
- Windows whose `appId` is no longer in the registry are dropped.
|
|
- Every window is re-clamped against the *current* viewport, so a session saved on a large
|
|
screen still opens correctly on a small one.
|
|
- Every read and write is wrapped in `try`/`catch`: private mode or a full quota costs you
|
|
the restore, never the app.
|
|
|
|
An empty window list removes the key rather than storing `[]`.
|
|
|
|
## Routing and deep links
|
|
|
|
The router itself is untouched: `/`, `/:nip19` and the catch-all still exist. The OS state
|
|
lives in the query string.
|
|
|
|
- **`/?app=feed`** — `OsShell` reads `?app=` once on mount and opens that window. Every
|
|
other query parameter becomes an app param.
|
|
- **Focus sync** — with `syncUrl` (the `/` route only), the URL is rewritten via
|
|
`replaceState` whenever focus changes, so the address bar always describes the window
|
|
you are looking at. Moving and resizing never touch the URL; there is no history spam.
|
|
- **`/npub1…`, `/note1…`, `/nevent1…`, `/naddr1…`** — `NIP19Page` decodes the identifier,
|
|
boots the desktop and opens the matching app. It does **not** sync the URL, because the
|
|
path is already the deep link.
|
|
- **Relay hints** carried by `nprofile`, `nevent` and `naddr` are passed through as a
|
|
`relays` param and used in the query. See [`nostr.md`](./nostr.md) — deep links fail
|
|
surprisingly often without them.
|
|
|
|
Booting is guarded by a ref so it happens exactly once: a later render must never reopen a
|
|
window the user has closed.
|
|
|
|
## Mobile
|
|
|
|
Under 768px (`useIsMobile`) `OsShell` renders `MobileAppShell` instead of the desktop:
|
|
the same registry and the same window state, presented as a home screen with one
|
|
full-screen app at a time plus an app switcher. There is no dragging, no resizing and no
|
|
geometry to persist.
|