babavoss 0.0.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { Component, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { select, type Entity } from "../ecs/state.ts";
|
|
3
|
+
import { useColumn, useHand, useResource } from "../web/hooks.ts";
|
|
4
|
+
import { useScope } from "../web/core.tsx";
|
|
5
|
+
import { useBaba } from "../web/state.tsx";
|
|
6
|
+
import type { Hand } from "../web/app.tsx";
|
|
7
|
+
import { Bob } from "./bob.ts";
|
|
8
|
+
import type { IconShape } from "./icons.ts";
|
|
9
|
+
import { canonicalPath, matchRoute } from "./routes.ts";
|
|
10
|
+
import { searchApps, type DesktopSystem, type DesktopApp, type DesktopWindow, type Theme, type Session } from "./index.ts";
|
|
11
|
+
|
|
12
|
+
/** What an app receives from the shell: where it is, what its window remembers, and the shell's verbs, so it never names an action. */
|
|
13
|
+
export interface AppProps {
|
|
14
|
+
session: string;
|
|
15
|
+
/** The window this instance draws in. */
|
|
16
|
+
window: Entity;
|
|
17
|
+
path: string;
|
|
18
|
+
/** The route that took `path`, as the app declares it (`/pr/:n`); null for an app that declares no routes. */
|
|
19
|
+
route: string | null;
|
|
20
|
+
/** The route's parameters, decoded: `{ n: "86" }`; the rest of a `*` route under `*`. */
|
|
21
|
+
params: Record<string, string>;
|
|
22
|
+
active: boolean;
|
|
23
|
+
/** What this window remembers for its app, by key; see `remember`. */
|
|
24
|
+
memory: Record<string, unknown>;
|
|
25
|
+
/** Moves this window to a path of its own app. */
|
|
26
|
+
navigate(path: string): void;
|
|
27
|
+
/** Opens any app at a path through the desktop's one rule. */
|
|
28
|
+
open(app: string, path?: string): void;
|
|
29
|
+
/** Names this window after what it shows. */
|
|
30
|
+
setTitle(title: string): void;
|
|
31
|
+
/** Keeps a value on this window, in the state: a filter, a selection, a fold; `null` forgets it. */
|
|
32
|
+
remember(key: string, value: unknown): void;
|
|
33
|
+
}
|
|
34
|
+
/** The old name. */
|
|
35
|
+
export type SurfaceProps = AppProps;
|
|
36
|
+
export interface DesktopProps {
|
|
37
|
+
system: DesktopSystem;
|
|
38
|
+
/** The apps by key, as components. */
|
|
39
|
+
apps?: Record<string, ComponentType<AppProps>>;
|
|
40
|
+
/** The old name of `apps`. */
|
|
41
|
+
surfaces?: Record<string, ComponentType<AppProps>>;
|
|
42
|
+
/** A fixed session, for a desktop embedded in another page. Without one the tab owns a session: the address's `?session=`, else the one the tab had, else a new one. */
|
|
43
|
+
session?: string;
|
|
44
|
+
title?: string;
|
|
45
|
+
/** The page's address is an adapter to the state: `BASE/APP/PATH` is the window in front, `BASE/` the launcher. Disable for embedded desktops. */
|
|
46
|
+
address?: boolean;
|
|
47
|
+
/** Where the address begins: the baba's own, `/NAME`, unless given. */
|
|
48
|
+
base?: string;
|
|
49
|
+
/** Where this baba was opened from, voss's picker: a link before the title on the bar, and the launcher's last row, Leave. A page inside a baba goes nowhere else. */
|
|
50
|
+
root?: Root;
|
|
51
|
+
}
|
|
52
|
+
/** The way out of a baba: the picker's name and address. */
|
|
53
|
+
export interface Root { label: string; href: string }
|
|
54
|
+
type Row<T> = T & { entity: Entity };
|
|
55
|
+
/** The plus of a new session in the launcher. */
|
|
56
|
+
const plusIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M5 12h14" } }, { tag: "path", attrs: { d: "M12 5v14" } }];
|
|
57
|
+
/** The way out, in the launcher: an arrow leaving a box. */
|
|
58
|
+
const leaveIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" } }, { tag: "polyline", attrs: { points: "16 17 21 12 16 7" } }, { tag: "line", attrs: { x1: "21", x2: "9", y1: "12", y2: "12" } }];
|
|
59
|
+
/** A session's icon in the launcher: a window with its bar. */
|
|
60
|
+
const sessionIcon: readonly IconShape[] = [{ tag: "rect", attrs: { x: "3", y: "5", width: "18", height: "14", rx: "2" } }, { tag: "path", attrs: { d: "M3 9.5h18" } }];
|
|
61
|
+
/** In an old hash, the tab's own session rather than a named one. */
|
|
62
|
+
const OWN = "~";
|
|
63
|
+
/** The tab's bookmark into its session: the browser's own per-tab storage, which a reload keeps, a closed tab drops and a duplicated tab copies. One per baba, since a tab moves between them. */
|
|
64
|
+
const sessionKey = (scope: string) => `voss.session.${scope}`;
|
|
65
|
+
const rememberedSession = (scope: string): string | null => { try { return sessionStorage.getItem(sessionKey(scope)); } catch { return null; } };
|
|
66
|
+
const rememberSession = (scope: string, key: string) => { try { sessionStorage.setItem(sessionKey(scope), key); } catch { /* the hash still says */ } };
|
|
67
|
+
/** An app as the frame draws it: with its icon's shapes once the system has read them, null until then. */
|
|
68
|
+
export type AppRow = Row<DesktopApp> & { drawing: readonly IconShape[] | null };
|
|
69
|
+
/** Another session, as the launcher lists it: what is in front there, how much is open, when it was last touched. */
|
|
70
|
+
export interface SessionRow { entity: Entity; key: string; touched: number; windows: number; front: string | null }
|
|
71
|
+
export interface Picture {
|
|
72
|
+
session: Row<Session>;
|
|
73
|
+
apps: AppRow[];
|
|
74
|
+
windows: Row<DesktopWindow>[];
|
|
75
|
+
/** The other sessions of this baba, last touched first; none for a picture that has no sessions, a state of one session. */
|
|
76
|
+
sessions?: SessionRow[];
|
|
77
|
+
/** The baba's theme; the session's old field when a picture has none. */
|
|
78
|
+
theme?: Theme;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The address of a window: the baba's base, the app, its path, which is left off at the app's home; the base alone is the launcher. */
|
|
82
|
+
export function desktopAddress(base: string, app?: string, path = "/") {
|
|
83
|
+
return app ? `${base}/${encodeURIComponent(app)}${path === "/" ? "" : path}` : `${base}/`;
|
|
84
|
+
}
|
|
85
|
+
/** What an address names: an app at a path, or the launcher, with no app; null when it is not under the base. */
|
|
86
|
+
export function parseDesktopAddress(base: string, pathname: string): { app?: string; path: string } | null {
|
|
87
|
+
if (pathname !== base && !pathname.startsWith(`${base}/`)) return null;
|
|
88
|
+
const rest = pathname.slice(base.length);
|
|
89
|
+
if (rest === "" || rest === "/") return { path: "/" };
|
|
90
|
+
const m = /^\/([^/]+)(\/.*)?$/.exec(rest);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
try { return { app: decodeURIComponent(m[1]!), path: m[2] ?? "/" }; } catch { return null; }
|
|
93
|
+
}
|
|
94
|
+
/** The route a path takes among an app's routes, and its parameters; no route for an app that declares none, or a path none takes. */
|
|
95
|
+
export function routeOf(routes: readonly string[] | undefined, path: string): { route: string | null; params: Record<string, string> } {
|
|
96
|
+
const m = routes?.length ? matchRoute(routes, path) : null;
|
|
97
|
+
return m ?? { route: null, params: {} };
|
|
98
|
+
}
|
|
99
|
+
/** An address before the path was: `#desktop/SESSION/APP/PATH`, read once so an old link or bookmark still opens. */
|
|
100
|
+
export function parseDesktopHash(hash: string): { session: string; app?: string; path?: string } | null {
|
|
101
|
+
const match = /^#desktop\/([^/]+)\/([^/]+)(\/.*)?$/.exec(hash);
|
|
102
|
+
if (!match) return null;
|
|
103
|
+
try {
|
|
104
|
+
const session = decodeURIComponent(match[1]!);
|
|
105
|
+
if (!session.trim()) return null;
|
|
106
|
+
return match[2] === "~" ? { session } : { session, app: decodeURIComponent(match[2]!), path: match[3] ?? "/" };
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The session an address asks the tab to join: `?session=KEY`, else an old hash's; none for the tab's own. */
|
|
111
|
+
function addressedSession(): string | undefined {
|
|
112
|
+
const q = new URLSearchParams(location.search).get("session")?.trim();
|
|
113
|
+
if (q) return q;
|
|
114
|
+
const old = parseDesktopHash(location.hash)?.session;
|
|
115
|
+
return old === OWN ? undefined : old;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Only the desktop columns are subscribed here; a busy pond does not redraw the bar. */
|
|
119
|
+
export function Desktop({ system, apps, surfaces, session: fixed, title = "Desktop", address = true, base: givenBase, root }: DesktopProps) {
|
|
120
|
+
const renderers = apps ?? surfaces ?? {};
|
|
121
|
+
const scope = useScope();
|
|
122
|
+
const base = givenBase ?? `/${encodeURIComponent(scope)}`;
|
|
123
|
+
const selectors = useMemo(() => ({ apps: select(system.desktopApp), icons: select(system.desktopIcon), sessions: select(system.desktopSession), windows: select(system.desktopWindow) }), [system]);
|
|
124
|
+
const appRows = useColumn(selectors.apps, system.desktopApp);
|
|
125
|
+
const iconRows = useColumn(selectors.icons, system.desktopIcon);
|
|
126
|
+
const sessions = useColumn(selectors.sessions, system.desktopSession);
|
|
127
|
+
const windows = useColumn(selectors.windows, system.desktopWindow);
|
|
128
|
+
const theme = useResource(system.desktopSettings)?.theme ?? "system"; // undefined before the first frame
|
|
129
|
+
const { fire, ready, connected } = useBaba<DesktopSystem>();
|
|
130
|
+
const [error, setError] = useState<string | null>(null);
|
|
131
|
+
// The tab's session: the one fixed by the page, else the address's, else the one this tab had, else none yet, and the tab enters a new one.
|
|
132
|
+
const [key, setKey] = useState<string | null>(() => fixed ?? (address ? addressedSession() : undefined) ?? rememberedSession(scope));
|
|
133
|
+
useEffect(() => { if (key !== null && !fixed) rememberSession(scope, key); }, [key, fixed, scope]);
|
|
134
|
+
// The session asked for is the tab's now, kept in its storage: the address drops `?session=`, and an old hash becomes the path it named.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!address) return;
|
|
137
|
+
const url = new URL(location.href), old = parseDesktopHash(url.hash);
|
|
138
|
+
url.searchParams.delete("session");
|
|
139
|
+
if (old) { url.pathname = desktopAddress(base, old.app, old.path); url.hash = ""; }
|
|
140
|
+
if (url.href !== location.href) history.replaceState(history.state, "", url);
|
|
141
|
+
}, [address, base]);
|
|
142
|
+
const session = key === null ? undefined : [...sessions].find(([, v]) => v.key === key);
|
|
143
|
+
const picture: Picture | null = session ? {
|
|
144
|
+
session: { entity: session[0], ...session[1] },
|
|
145
|
+
apps: [...appRows].map(([entity, value]) => ({ entity, ...value, drawing: drawn(value, iconRows.get(entity)) })).sort((a, b) => a.order - b.order),
|
|
146
|
+
windows: [...windows].filter(([, value]) => value.session === session[0]).map(([entity, value]) => ({ entity, ...value })),
|
|
147
|
+
sessions: [...sessions].filter(([entity]) => entity !== session[0]).map(([entity, value]) => {
|
|
148
|
+
const wins = [...windows].filter(([, o]) => o.session === entity);
|
|
149
|
+
const front = wins.find(([e]) => e === value.selected)?.[1];
|
|
150
|
+
const s = front && appRows.get(front.app);
|
|
151
|
+
return { entity, key: value.key, touched: value.touched ?? 0, windows: wins.length, front: front ? front.title || (front.path === "/" ? s?.title ?? null : front.path) : null };
|
|
152
|
+
}).sort((a, b) => b.touched - a.touched || b.entity - a.entity),
|
|
153
|
+
theme,
|
|
154
|
+
} : null;
|
|
155
|
+
const hand: Hand = { fire: (name, args) => {
|
|
156
|
+
setError(null);
|
|
157
|
+
void (fire as (n: string, a: unknown) => Promise<unknown>)(name, args).catch((e) => setError(String(e)));
|
|
158
|
+
} };
|
|
159
|
+
// Entering is explicit, idempotent navigation: a named session is made if missing; without one the tab asks for a new session and takes the name the state gives it. One ask at a time, so two renders do not make two.
|
|
160
|
+
const entering = useRef(false);
|
|
161
|
+
const [entered, setEntered] = useState<Entity | null>(null);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!ready || session || entering.current || entered !== null) return;
|
|
164
|
+
entering.current = true;
|
|
165
|
+
void fire("desktop-enter", key === null ? {} : { session: key }).then((e) => { if (key === null) setEntered(e); })
|
|
166
|
+
.catch((e) => setError(String(e))).finally(() => { entering.current = false; });
|
|
167
|
+
}, [ready, !!session, key, entered, fire]);
|
|
168
|
+
// The session entered without a name: once the state shows it, whether its frame lands before or after the answer, the tab takes its name.
|
|
169
|
+
const enteredKey = entered === null ? undefined : sessions.get(entered)?.key;
|
|
170
|
+
useEffect(() => { if (key === null && enteredKey) { setEntered(null); setKey(enteredKey); } }, [key, enteredKey]);
|
|
171
|
+
// A tab that comes to the front touches its session, so a call that names none reaches the tab last used.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!ready || !session || fixed) return;
|
|
174
|
+
const touch = () => { if (document.visibilityState === "visible") void fire("desktop-enter", { session: session[1].key }).catch(() => {}); };
|
|
175
|
+
addEventListener("focus", touch); document.addEventListener("visibilitychange", touch);
|
|
176
|
+
return () => { removeEventListener("focus", touch); document.removeEventListener("visibilitychange", touch); };
|
|
177
|
+
}, [ready, session?.[1].key, fixed, fire]);
|
|
178
|
+
|
|
179
|
+
const selectedWindow = picture?.windows.find((o) => o.entity === picture.session.selected);
|
|
180
|
+
const selected = picture?.apps.find((s) => s.entity === selectedWindow?.app);
|
|
181
|
+
const here = picture ? desktopAddress(base, selected?.key, selectedWindow?.path ?? "/") : null;
|
|
182
|
+
const current = useRef(here); current.current = here;
|
|
183
|
+
/** The address the page last agreed with the state on: written only when what is in front changes, so the launcher's address stays while it is open. */
|
|
184
|
+
const agreed = useRef<string | null>(null);
|
|
185
|
+
const pending = useRef<string | null>(null);
|
|
186
|
+
const initialized = useRef(false);
|
|
187
|
+
// Asked by the address for the launcher, or for an app, which closes it: counts, so every arrival asks again.
|
|
188
|
+
const [launch, setLaunch] = useState(0);
|
|
189
|
+
const [dismiss, setDismiss] = useState(0);
|
|
190
|
+
/** Goes to a session: another one, or a new one. The address is written first, to what is in front there, so the adapter agrees with the key rather than pulling the tab back; it replaces the entry, since the address names no session and Back could not return to this one. */
|
|
191
|
+
const go = (next: string | null) => {
|
|
192
|
+
setError(null);
|
|
193
|
+
if (address) {
|
|
194
|
+
const row = next === null ? undefined : [...sessions].find(([, v]) => v.key === next);
|
|
195
|
+
const front = row && [...windows].find(([e]) => e === row[1].selected)?.[1];
|
|
196
|
+
const to = desktopAddress(base, front && appRows.get(front.app)?.key, front?.path);
|
|
197
|
+
agreed.current = to;
|
|
198
|
+
history.replaceState(null, "", to);
|
|
199
|
+
}
|
|
200
|
+
setEntered(null); setKey(next);
|
|
201
|
+
};
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (!address || !ready || key === null || !picture) return;
|
|
204
|
+
let live = true;
|
|
205
|
+
const readAddress = () => {
|
|
206
|
+
const route = parseDesktopAddress(base, location.pathname);
|
|
207
|
+
if (!route) return;
|
|
208
|
+
// The base alone is the launcher: it opens over whatever is in front, and the address stays until something is chosen.
|
|
209
|
+
if (!route.app) { agreed.current = current.current; setLaunch((n) => n + 1); return; }
|
|
210
|
+
setDismiss((n) => n + 1);
|
|
211
|
+
let path: string;
|
|
212
|
+
try { path = canonicalPath(route.path); } catch (e) { setError(String(e)); if (current.current) history.replaceState(null, "", current.current); return; }
|
|
213
|
+
const expected = desktopAddress(base, route.app, path);
|
|
214
|
+
if (expected === current.current) { if (location.pathname !== expected) history.replaceState(null, "", expected); return; }
|
|
215
|
+
pending.current = expected;
|
|
216
|
+
void fire("desktop-open", { session: key, app: route.app, path }).catch((e) => {
|
|
217
|
+
if (!live) return;
|
|
218
|
+
pending.current = null;
|
|
219
|
+
setError(String(e));
|
|
220
|
+
if (current.current) history.replaceState(null, "", current.current);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
initialized.current = false;
|
|
224
|
+
readAddress();
|
|
225
|
+
initialized.current = true;
|
|
226
|
+
addEventListener("popstate", readAddress);
|
|
227
|
+
return () => { live = false; initialized.current = false; pending.current = null; removeEventListener("popstate", readAddress); };
|
|
228
|
+
}, [address, ready, key, !!picture, base, fire]);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!address || !initialized.current || !here) return;
|
|
231
|
+
// What the address asked for has come, or something else came first: either way the ask is over. Arriving where the address asked rewrites its spelling, no new entry.
|
|
232
|
+
const asked = pending.current === here;
|
|
233
|
+
pending.current = null;
|
|
234
|
+
if (here === agreed.current) return;
|
|
235
|
+
agreed.current = here;
|
|
236
|
+
if (location.pathname !== here) {
|
|
237
|
+
if (asked || !parseDesktopAddress(base, location.pathname)) history.replaceState(null, "", here);
|
|
238
|
+
else history.pushState(null, "", here);
|
|
239
|
+
}
|
|
240
|
+
}, [address, here, base]);
|
|
241
|
+
/** The launcher closed with nothing new chosen: the address says again what is in front. A choice is left to the writer above, which pushes it, so Back returns to the launcher. */
|
|
242
|
+
const launcherShown = (open: boolean) => {
|
|
243
|
+
if (open || !address || !current.current || current.current !== agreed.current) return;
|
|
244
|
+
if (location.pathname === `${base}/` && current.current !== `${base}/`) history.replaceState(null, "", current.current);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return <>
|
|
248
|
+
{error && <div className="desktop-error" role="alert">{error}<button onClick={() => setError(null)}>Dismiss</button></div>}
|
|
249
|
+
{!connected && ready && <p className="desktop-error" role="status">Reconnecting to the desktop…</p>}
|
|
250
|
+
{picture ? <DesktopFrame picture={picture} title={title} hand={hand} root={root} go={fixed ? undefined : go} launch={launch} dismiss={dismiss} onLauncher={launcherShown} render={(app, props) => {
|
|
251
|
+
const View = renderers[app.key];
|
|
252
|
+
return View ? <View {...props} /> : <p className="desktop-empty">No component is composed for the app {app.title}.</p>;
|
|
253
|
+
}} /> : <p className="desktop-empty" role="status">Opening desktop…</p>}
|
|
254
|
+
</>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The two eyes, the mark of Baba Voss, drawn in the current color. Without `large`, the larger eye is away, it is the launcher's dot then, and the smaller one moves to the middle. */
|
|
258
|
+
export function Eyes({ size = 16, large = true, settle = false }: { size?: number; large?: boolean; settle?: boolean }) {
|
|
259
|
+
return <svg className={`desktop-eyes${large ? "" : " is-alone"}${settle ? " is-settling" : ""}`} viewBox="0 0 100 100" width={size} height={size} aria-hidden="true" fill="currentColor"><circle className="desktop-eye-large" cx="33.772" cy="50" r="18" /><circle className="desktop-eye-small" cx="71.5" cy="50" r="12.728" /></svg>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A pill's text: clipped to the pill, faded at its end; when it does not fit, pointing at the pill scrolls it in a loop after a moment.
|
|
264
|
+
* The loop is linear so it is seamless; the track it rides on eases a little to the right as the loop begins, so the text starts from rest.
|
|
265
|
+
*/
|
|
266
|
+
function PillText({ text }: { text: string }) {
|
|
267
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
268
|
+
const [shift, setShift] = useState(0);
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
const el = ref.current;
|
|
271
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
272
|
+
const measure = () => { const first = el.firstElementChild?.firstElementChild as HTMLElement | null; const over = first ? first.offsetWidth - el.clientWidth : 0; setShift(over > 0 ? first!.offsetWidth + 24 : 0); };
|
|
273
|
+
measure();
|
|
274
|
+
const ro = new ResizeObserver(measure); ro.observe(el);
|
|
275
|
+
return () => ro.disconnect();
|
|
276
|
+
}, [text]);
|
|
277
|
+
// The start eases over .6s on a curve whose initial slope is 2, so an ease of v * .6 / 2 cancels the loop's speed v at the first instant.
|
|
278
|
+
const seconds = Math.max(2, shift / 40), ease = (shift / seconds) * 0.3;
|
|
279
|
+
return <span ref={ref} className={`desktop-pill-text${shift ? " is-overflowing" : ""}`} style={shift ? { "--shift": `-${shift}px`, "--scroll": `${seconds}s`, "--ease": `${ease.toFixed(1)}px` } as CSSProperties : undefined}>
|
|
280
|
+
<span className="desktop-pill-track"><span>{text}</span>{shift > 0 && <span aria-hidden="true">{text}</span>}</span>
|
|
281
|
+
</span>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One open window as a pill, on the bar and in the launcher alike: its icon at the left, which pointing at the pill turns into its ×; its label scrolls when long. */
|
|
285
|
+
/** The × that takes an icon's place: drawn in a 16px square centred in the icon's box, so its centre is the box's centre. */
|
|
286
|
+
const Cross = () => <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>;
|
|
287
|
+
|
|
288
|
+
/** A middle click closes a window, as it closes a tab; its press is held back so the browser does not start scrolling. */
|
|
289
|
+
const middleCloses = (close: (() => void) | undefined) => close ? {
|
|
290
|
+
onMouseDown: (e: { button: number; preventDefault(): void }) => { if (e.button === 1) e.preventDefault(); },
|
|
291
|
+
onAuxClick: (e: { button: number; preventDefault(): void }) => { if (e.button === 1) { e.preventDefault(); close(); } },
|
|
292
|
+
} : {};
|
|
293
|
+
|
|
294
|
+
function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onClose }: { label: string; active: boolean; hand?: Hand; icon: ReactNode; shortcut?: boolean; enter?: boolean; onSelect(): void; onClose(): void }) {
|
|
295
|
+
const pill = useRef<HTMLSpanElement>(null);
|
|
296
|
+
// Arriving, a pill drops into its place from above the strip, which clips it, and lands with a small bounce; it takes its place at once, so no other pill moves.
|
|
297
|
+
useLayoutEffect(() => {
|
|
298
|
+
const el = pill.current;
|
|
299
|
+
if (!enter || !el) return;
|
|
300
|
+
el.scrollIntoView?.({ inline: "nearest", block: "nearest" });
|
|
301
|
+
if (typeof el.animate !== "function" || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
302
|
+
el.animate([{ transform: "translateY(-100%)" }, { transform: "translateY(0)" }], { duration: 160, easing: "cubic-bezier(.3, 1.5, .4, 1)" });
|
|
303
|
+
}, []);
|
|
304
|
+
return <span ref={pill} className={`desktop-pill${active ? " is-active" : ""}`} role="tab" aria-selected={active} title={label} {...middleCloses(hand && onClose)}>
|
|
305
|
+
<button type="button" className="desktop-pill-label" disabled={!hand} onClick={onSelect}><span className="desktop-pill-icon">{icon}</span><PillText text={label} /></button>
|
|
306
|
+
{hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label}`} title={`Close ${label}${shortcut ? " (⌘/Ctrl W)" : ""}`} onClick={onClose}><Cross /></button>}
|
|
307
|
+
</span>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The strip of pills: it scrolls sideways, and fades into whatever is behind it at an edge with more beyond it. */
|
|
311
|
+
function Pills({ children, label }: { children: ReactNode; label: string }) {
|
|
312
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
313
|
+
const [fade, setFade] = useState("");
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const el = ref.current;
|
|
316
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
317
|
+
const measure = () => setFade(`${el.scrollLeft > 1 ? " is-faded-left" : ""}${el.scrollLeft + el.clientWidth < el.scrollWidth - 1 ? " is-faded-right" : ""}`);
|
|
318
|
+
measure();
|
|
319
|
+
const ro = new ResizeObserver(measure); ro.observe(el); for (const child of el.children) ro.observe(child);
|
|
320
|
+
el.addEventListener("scroll", measure, { passive: true });
|
|
321
|
+
return () => { ro.disconnect(); el.removeEventListener("scroll", measure); };
|
|
322
|
+
});
|
|
323
|
+
return <div ref={ref} className={`desktop-pills${fade}`} role="tablist" aria-label={label} onWheel={(e) => { const el = ref.current; if (el && !e.deltaX && e.deltaY && el.scrollWidth > el.clientWidth) { el.scrollLeft += e.deltaY; } }}>{children}</div>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The drawing of an app's icon, when the system has read the one it names now. */
|
|
327
|
+
const drawn = (app: DesktopApp, icon: { name: string; shapes: readonly IconShape[] } | undefined) => icon && icon.name === app.icon ? icon.shapes : null;
|
|
328
|
+
|
|
329
|
+
/** An app's icon: its Lucide shapes, stroked as Lucide strokes them, in a 24-unit square drawn at `size`; an app without one, or not read yet, shows a small dot. */
|
|
330
|
+
export function AppIcon({ drawing, size = 14 }: { drawing: readonly IconShape[] | null; size?: number }) {
|
|
331
|
+
return <svg className="desktop-icon" viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
332
|
+
{drawing?.length ? drawing.map(({ tag: Tag, attrs }, i) => <Tag key={i} {...attrs} />) : <circle cx="12" cy="12" r="2.5" fill="currentColor" stroke="none" opacity={.5} />}
|
|
333
|
+
</svg>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** How well texts answer the words typed: 0 for a word at the start of a text, 1 at the start of a word inside it, 2 anywhere, none when a word is missing. Lower comes first. */
|
|
337
|
+
function rank(texts: string[], words: string[]): number | null {
|
|
338
|
+
let total = 0;
|
|
339
|
+
for (const word of words) {
|
|
340
|
+
let best = Infinity;
|
|
341
|
+
for (const t of texts.map((x) => x.toLowerCase())) {
|
|
342
|
+
const at = t.indexOf(word);
|
|
343
|
+
if (at < 0) continue;
|
|
344
|
+
best = Math.min(best, at === 0 ? 0 : /\s|[-_/]/.test(t[at - 1]!) ? 1 : 2);
|
|
345
|
+
}
|
|
346
|
+
if (best === Infinity) return null;
|
|
347
|
+
total += best;
|
|
348
|
+
}
|
|
349
|
+
return total;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The same drawing is used live and for scenario frames. It never fetches state. */
|
|
353
|
+
export function DesktopFrame({ picture, title, hand, render, root, go, launch, dismiss, onLauncher }: {
|
|
354
|
+
picture: Picture; title: string; hand?: Hand; render(app: Row<DesktopApp>, props: AppProps): ReactNode;
|
|
355
|
+
/** The picker this baba was opened from: a link on the bar, and Leave, the launcher's last row. */
|
|
356
|
+
root?: Root;
|
|
357
|
+
/** Takes the tab to another session by key, or to a new one with null; without it the launcher lists no sessions. */
|
|
358
|
+
go?: (key: string | null) => void;
|
|
359
|
+
/** Opens the launcher each time it changes: the page's address asked for it. */
|
|
360
|
+
launch?: number;
|
|
361
|
+
/** Closes the launcher each time it changes: the page's address named an app. */
|
|
362
|
+
dismiss?: number;
|
|
363
|
+
/** Told whether the launcher shows, each time that changes after the first drawing. */
|
|
364
|
+
onLauncher?: (open: boolean) => void;
|
|
365
|
+
}) {
|
|
366
|
+
const { session: w, apps, windows } = picture;
|
|
367
|
+
const sessions = go ? picture.sessions ?? [] : [];
|
|
368
|
+
const frame = useRef<HTMLDivElement>(null), input = useRef<HTMLInputElement>(null);
|
|
369
|
+
const resultsId = useId();
|
|
370
|
+
const fire = (name: string, args: Record<string, unknown>) => hand?.fire(name, { ...args, session: w.key });
|
|
371
|
+
const open = (app: string, path?: string) => fire("desktop-open", { app, ...(path === undefined ? {} : { path }) });
|
|
372
|
+
// The launcher is the page's: it shows while its search has focus, or while nothing is in front. The mark and ⌘K give the search focus; Escape and a click elsewhere take it.
|
|
373
|
+
const [focused, setFocused] = useState(false);
|
|
374
|
+
// A choice holds the launcher over the page until the state shows what was chosen, so the app behind never flashes between; a moment at most.
|
|
375
|
+
const [awaiting, setAwaiting] = useState<{ from: Entity | null } | null>(null);
|
|
376
|
+
useEffect(() => { if (awaiting && w.selected !== awaiting.from) setAwaiting(null); }, [w.selected, awaiting]);
|
|
377
|
+
useEffect(() => { if (!awaiting) return; const t = setTimeout(() => setAwaiting(null), 800); return () => clearTimeout(t); }, [awaiting]);
|
|
378
|
+
const launcher = focused || w.selected === null || awaiting !== null;
|
|
379
|
+
useEffect(() => { if (launch) setFocused(true); }, [launch]);
|
|
380
|
+
useEffect(() => { if (dismiss) { setFocused(false); input.current?.blur(); } }, [dismiss]);
|
|
381
|
+
// Told only when it changes: as the frame first draws, the address has not been read yet.
|
|
382
|
+
const told = useRef(onLauncher); told.current = onLauncher;
|
|
383
|
+
const shown = useRef(launcher);
|
|
384
|
+
useEffect(() => { if (shown.current !== launcher) { shown.current = launcher; told.current?.(launcher); } }, [launcher]);
|
|
385
|
+
// Each time the launcher closes the mark's eyes settle back into place, so the mark is keyed by how many times it has closed.
|
|
386
|
+
const [settles, setSettles] = useState(0);
|
|
387
|
+
const [was, setWas] = useState(launcher);
|
|
388
|
+
if (was !== launcher) { setWas(launcher); if (!launcher) setSettles(settles + 1); }
|
|
389
|
+
// Pills that arrive after the first drawing grow into the strip; those already open when the page opens are simply there.
|
|
390
|
+
const firstDrawn = useRef(false);
|
|
391
|
+
useEffect(() => { firstDrawn.current = true; }, []);
|
|
392
|
+
const toggle = () => { if (focused) { setFocused(false); input.current?.blur(); } else setFocused(true); };
|
|
393
|
+
const selectedWindow = windows.find((o) => o.entity === w.selected);
|
|
394
|
+
const selectedApp = apps.find((s) => s.entity === selectedWindow?.app);
|
|
395
|
+
// What is typed is the page's: it clears when the launcher closes, and never waits on the state.
|
|
396
|
+
const [text, setText] = useState("");
|
|
397
|
+
const [at, setAt] = useState(0);
|
|
398
|
+
const search = (next: string, cursor = 0) => { setText(next); setAt(cursor); };
|
|
399
|
+
useEffect(() => { if (!launcher) search(""); }, [launcher]);
|
|
400
|
+
const label = (o: Row<DesktopWindow>, s: Row<DesktopApp>) => o.title || (o.path === "/" ? s.title : o.path);
|
|
401
|
+
// The palette: what is open first, then what can be opened; both answer the words typed, best first, and the keyboard runs down the whole list.
|
|
402
|
+
const words = text.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
403
|
+
const ranked = <T,>(items: T[], texts: (item: T) => string[]) => items.map((item, order) => ({ item, order, rank: rank(texts(item), words) })).filter((r): r is { item: T; order: number; rank: number } => r.rank !== null).sort((a, b) => a.rank - b.rank || a.order - b.order).map((r) => r.item);
|
|
404
|
+
const openRows = ranked(windows.flatMap((o) => { const s = apps.find((s) => s.entity === o.app); return s ? [{ o, s }] : []; }), ({ o, s }) => [label(o, s), s.title, s.group]);
|
|
405
|
+
const found = ranked(apps, (s) => [s.title, s.key, s.group]);
|
|
406
|
+
// What is open first, then what can be opened; the other sessions after, and a new one; last, the way out of the baba.
|
|
407
|
+
const sessionRows = ranked(sessions, (x) => [x.key, x.front ?? ""]);
|
|
408
|
+
const newSession = go && rank(["new session"], words) !== null;
|
|
409
|
+
const leaving = root && rank([`leave ${title}`, root.label], words) !== null;
|
|
410
|
+
const S0 = openRows.length + found.length;
|
|
411
|
+
const L = S0 + sessionRows.length + (newSession ? 1 : 0);
|
|
412
|
+
const count = L + (leaving ? 1 : 0);
|
|
413
|
+
const cursor = Math.min(at, Math.max(0, count - 1));
|
|
414
|
+
const choose = (i: number) => {
|
|
415
|
+
if (i >= L) { location.assign(root!.href); return; }
|
|
416
|
+
if (i >= S0) { const x = sessionRows[i - S0]; go?.(x ? x.key : null); setFocused(false); input.current?.blur(); return; }
|
|
417
|
+
const row = openRows[i], s = row ? undefined : found[i - openRows.length];
|
|
418
|
+
// What will be in front: the window chosen, or the app's own one where it allows only one; a fresh window is not known until it is made.
|
|
419
|
+
const target = row ? row.o.entity : s && !s.many ? windows.filter((o) => o.app === s.entity).at(-1)?.entity : undefined;
|
|
420
|
+
if (row) fire("desktop-select", { window: row.o.entity }); else if (s) fire("desktop-open", { app: s.key, fresh: true });
|
|
421
|
+
if ((row || s) && target !== w.selected) setAwaiting({ from: w.selected });
|
|
422
|
+
setFocused(false); input.current?.blur();
|
|
423
|
+
};
|
|
424
|
+
useEffect(() => { if (focused && hand) input.current?.focus(); }, [focused, !!hand]);
|
|
425
|
+
useEffect(() => { frame.current?.querySelector(`[data-result="${cursor}"]`)?.scrollIntoView({ block: "nearest" }); }, [cursor]);
|
|
426
|
+
// One dot says which row is chosen: the mark's larger eye, which drops out of the mark on a string when the launcher opens and is pulled from row to row. The bob moves it; this only says where.
|
|
427
|
+
const dot = useRef<HTMLSpanElement>(null), string = useRef<HTMLSpanElement>(null), overlay = useRef<HTMLDivElement>(null);
|
|
428
|
+
/** Where the string is tied, the middle of the mark's lower edge, and the dot's radius, measured when the launcher opens. */
|
|
429
|
+
const tie = useRef({ x: 0, y: 0, r: 0 });
|
|
430
|
+
const bob = useRef<Bob | null>(null);
|
|
431
|
+
bob.current ??= new Bob((at) => {
|
|
432
|
+
const el = dot.current, line = string.current, t = tie.current;
|
|
433
|
+
if (!el || !line) return;
|
|
434
|
+
el.style.transform = `translate(${at.x}px, ${at.y}px)`;
|
|
435
|
+
// The string runs from the tie to the dot's edge, at whatever angle the dot hangs.
|
|
436
|
+
const dx = at.x - t.x, dy = at.y - t.y, reach = Math.hypot(dx, dy), length = Math.max(0, reach - t.r);
|
|
437
|
+
line.style.opacity = length > 1 ? "1" : "0";
|
|
438
|
+
line.style.transform = `translate(${t.x}px, ${t.y}px) rotate(${reach ? -Math.asin(dx / reach) : 0}rad) scaleY(${length})`;
|
|
439
|
+
}, () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
440
|
+
useEffect(() => () => bob.current?.stop(), []);
|
|
441
|
+
/** Aims the dot at the chosen row's middle, on the mark's vertical; a scroll moves it with the list at once. */
|
|
442
|
+
const place = (now = false) => {
|
|
443
|
+
const o = overlay.current, el = dot.current;
|
|
444
|
+
const row = o?.querySelector<HTMLElement>(`[data-result="${cursor}"]`)?.closest<HTMLElement>(".desktop-row");
|
|
445
|
+
if (!el) return;
|
|
446
|
+
el.style.opacity = row ? "1" : "0";
|
|
447
|
+
if (!row) { if (string.current) string.current.style.opacity = "0"; return; }
|
|
448
|
+
// Layout offsets, not rects: a row still arriving is transformed, and the dot aims at where it will rest.
|
|
449
|
+
const body = row.closest<HTMLElement>(".desktop-launcher-body");
|
|
450
|
+
bob.current!.to({ x: tie.current.x, y: row.offsetTop - (body?.scrollTop ?? 0) + row.offsetHeight / 2 }, now);
|
|
451
|
+
};
|
|
452
|
+
useLayoutEffect(() => {
|
|
453
|
+
if (!launcher) { bob.current!.stop(); return; }
|
|
454
|
+
const o = overlay.current, mark = o?.querySelector<HTMLElement>(".desktop-logo"), eye = o?.querySelector<SVGCircleElement>(".desktop-eye-large"), el = dot.current;
|
|
455
|
+
if (!o || !mark || !eye || !el) return;
|
|
456
|
+
const O = o.getBoundingClientRect(), M = mark.getBoundingClientRect(), E = eye.getBoundingClientRect();
|
|
457
|
+
tie.current = { x: M.left + M.width / 2 - O.left, y: M.bottom - O.top, r: E.width / 2 };
|
|
458
|
+
// Born where the larger eye is, at the eye's own size, which it keeps; then it falls.
|
|
459
|
+
el.style.width = el.style.height = `${E.width}px`; el.style.margin = `${-E.width / 2}px 0 0 ${-E.width / 2}px`;
|
|
460
|
+
bob.current!.drop({ x: E.left + E.width / 2 - O.left, y: E.top + E.height / 2 - O.top });
|
|
461
|
+
}, [launcher]);
|
|
462
|
+
useEffect(() => { if (launcher) place(); });
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
if (!hand) return;
|
|
465
|
+
const key = (e: KeyboardEvent) => {
|
|
466
|
+
// An embedded picture only handles keys while it contains focus.
|
|
467
|
+
if (document.activeElement !== document.body && !frame.current?.contains(document.activeElement)) return;
|
|
468
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.altKey) { e.preventDefault(); if (!focused) setFocused(true); else if (w.selected !== null) toggle(); }
|
|
469
|
+
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "w" && !e.altKey && !launcher && w.selected !== null) { e.preventDefault(); fire("desktop-close", {}); }
|
|
470
|
+
else if (launcher && e.key === "Escape") {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
if (text) search(""); else if (w.selected !== null) toggle();
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
addEventListener("keydown", key); return () => removeEventListener("keydown", key);
|
|
476
|
+
});
|
|
477
|
+
// Without words the apps keep their groups; with words they are one list, best first.
|
|
478
|
+
const groups = words.length ? [""] : [...new Set(found.map((s) => s.group))];
|
|
479
|
+
const appsOf = (group: string) => words.length ? found : found.filter((s) => s.group === group);
|
|
480
|
+
const pills = windows.map((o) => {
|
|
481
|
+
const s = apps.find((s) => s.entity === o.app);
|
|
482
|
+
if (!s) return null;
|
|
483
|
+
const active = w.selected === o.entity;
|
|
484
|
+
return <WindowPill key={o.entity} label={label(o, s)} active={active} hand={hand} shortcut={active} icon={<AppIcon drawing={s.drawing} />} enter={firstDrawn.current} onSelect={() => fire("desktop-select", { window: o.entity })} onClose={() => fire("desktop-close", { window: o.entity })} />;
|
|
485
|
+
});
|
|
486
|
+
// The bar: the mark, which is the launcher and so the way to another app; a pill per open window, the one in front filled.
|
|
487
|
+
// The launcher covers the page: the bar's row becomes the search, the pills sit on the row below exactly as they are, and the apps, as pills under their groups, fill the rest.
|
|
488
|
+
return <div ref={frame} className="desktop" data-theme={picture.theme ?? w.theme}>
|
|
489
|
+
<header className="desktop-bar" aria-label="Bar">
|
|
490
|
+
<button type="button" className="desktop-logo" title={`${title}: launcher (⌘/Ctrl K)`} aria-label="Open launcher" aria-expanded={launcher} disabled={!hand} onClick={toggle}><Eyes key={settles} size={18} settle={settles > 0} /></button>
|
|
491
|
+
{windows.length === 0 && <span className="desktop-bar-title">{root && <><a className="desktop-bar-root" href={root.href} title={`Leave ${title}: back to ${root.label}`}>{root.label}</a><span className="desktop-muted"> / </span></>}{title}{go || w.key !== "main" ? <span className="desktop-muted"> · {w.key}</span> : ""}</span>}
|
|
492
|
+
<Pills label="Open apps">{pills}</Pills>
|
|
493
|
+
</header>
|
|
494
|
+
<main className="desktop-content">
|
|
495
|
+
{windows.map((o) => {
|
|
496
|
+
const s = apps.find((s) => s.entity === o.app);
|
|
497
|
+
if (!s) return null;
|
|
498
|
+
const active = w.selected === o.entity;
|
|
499
|
+
return <section key={o.entity} className="desktop-app" hidden={!active} aria-label={label(o, s)}>
|
|
500
|
+
<AppBoundary><>{render(s, {
|
|
501
|
+
session: w.key, window: o.entity, path: o.path, ...routeOf(s.routes, o.path), active, memory: (o.memory ?? {}) as Record<string, unknown>,
|
|
502
|
+
navigate: (path) => fire("desktop-open", { app: s.key, path }),
|
|
503
|
+
open,
|
|
504
|
+
setTitle: (title) => { if (title !== o.title) fire("desktop-title", { window: o.entity, title }); },
|
|
505
|
+
remember: (key, value) => { if (JSON.stringify((o.memory as Record<string, unknown>)?.[key]) !== JSON.stringify(value)) fire("desktop-remember", { window: o.entity, key, value }); },
|
|
506
|
+
})}</></AppBoundary>
|
|
507
|
+
</section>;
|
|
508
|
+
})}
|
|
509
|
+
</main>
|
|
510
|
+
{launcher && <div ref={overlay} className="desktop-launcher" aria-label="Launcher" onPointerDown={(e) => {
|
|
511
|
+
// Pressing anywhere in the launcher keeps the search's focus, empty space included; choosing a row, the mark, Escape or ⌘K are what close it.
|
|
512
|
+
if (!(e.target as HTMLElement).closest("input")) e.preventDefault();
|
|
513
|
+
}}>
|
|
514
|
+
<div className="desktop-bar desktop-launcher-search">
|
|
515
|
+
<button type="button" className="desktop-logo is-selected" title="Close launcher (Esc)" aria-label="Close launcher" aria-expanded="true" disabled={!hand} onClick={toggle}><Eyes size={18} large={false} /></button>
|
|
516
|
+
<input ref={input} className="desktop-search" autoFocus={focused} aria-label="Search" placeholder="Type an app" value={text} disabled={!hand} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onChange={(e) => search(e.target.value)} role="combobox" aria-expanded="true" aria-controls={resultsId} aria-activedescendant={count ? `${resultsId}-${cursor}` : undefined} onKeyDown={(e) => {
|
|
517
|
+
if (["ArrowDown", "ArrowUp"].includes(e.key) && count) { e.preventDefault(); setAt((cursor + (e.key === "ArrowDown" ? 1 : -1) + count) % count); }
|
|
518
|
+
else if (e.key === "Enter" && count) { e.preventDefault(); choose(cursor); }
|
|
519
|
+
}} />
|
|
520
|
+
</div>
|
|
521
|
+
<span ref={string} className="desktop-string" aria-hidden="true" />
|
|
522
|
+
<span ref={dot} className="desktop-dot" aria-hidden="true" />
|
|
523
|
+
<div id={resultsId} className="desktop-launcher-body" role="listbox" aria-label="Open windows and apps" onScroll={() => place(true)}>
|
|
524
|
+
{openRows.length > 0 && <div className="desktop-group">
|
|
525
|
+
<h2 className="desktop-group-title" style={{ "--i": 0 } as CSSProperties}>Open</h2>
|
|
526
|
+
{openRows.map(({ o, s }, k) => {
|
|
527
|
+
const i = k, front = w.selected === o.entity;
|
|
528
|
+
return <span key={o.entity} className={`desktop-row desktop-row-window${cursor === i ? " is-cursor" : ""}${front ? " is-active" : ""}`} style={{ "--i": i + 1 } as CSSProperties} {...middleCloses(hand && (() => fire("desktop-close", { window: o.entity })))}>
|
|
529
|
+
<button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={label(o, s)} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
530
|
+
<span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
|
|
531
|
+
<span className="desktop-row-label">{label(o, s)}</span>
|
|
532
|
+
{label(o, s) !== s.title && <span className="desktop-row-note">{s.title}</span>}
|
|
533
|
+
</button>
|
|
534
|
+
{hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label(o, s)}`} title={`Close ${label(o, s)}`} onClick={() => fire("desktop-close", { window: o.entity })}><Cross /></button>}
|
|
535
|
+
</span>;
|
|
536
|
+
})}
|
|
537
|
+
</div>}
|
|
538
|
+
{groups.map((group) => <div key={group} className="desktop-group">
|
|
539
|
+
{group && <h2 className="desktop-group-title" style={{ "--i": openRows.length + found.indexOf(appsOf(group)[0]!) + (openRows.length ? 1 : 0) } as CSSProperties}>{group}</h2>}
|
|
540
|
+
{appsOf(group).map((s) => {
|
|
541
|
+
const i = openRows.length + found.indexOf(s);
|
|
542
|
+
const open = windows.filter((o) => o.app === s.entity).length;
|
|
543
|
+
return <button type="button" key={s.key} id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + (openRows.length ? 2 : 1) } as CSSProperties} disabled={!hand} title={s.many ? `Open another ${s.title}` : open ? `Bring ${s.title} to the front` : `Open ${s.title}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
544
|
+
<span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
|
|
545
|
+
<span className="desktop-row-label">{s.title}</span>
|
|
546
|
+
<span className="desktop-row-note">{words.length && s.group ? s.group : ""}{open > 1 ? `${words.length && s.group ? " · " : ""}${open} open` : ""}</span>
|
|
547
|
+
</button>;
|
|
548
|
+
})}
|
|
549
|
+
</div>)}
|
|
550
|
+
{(sessionRows.length > 0 || newSession) && <div className="desktop-group desktop-sessions">
|
|
551
|
+
<h2 className="desktop-group-title" style={{ "--i": S0 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties}>Sessions</h2>
|
|
552
|
+
{sessionRows.map((x, k) => {
|
|
553
|
+
const i = S0 + k, note = `${x.windows ? `${x.windows} open` : "empty"}${x.front ? ` · ${x.front}` : ""}`;
|
|
554
|
+
return <span key={x.entity} className={`desktop-row desktop-row-window desktop-row-session${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + 1 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} {...middleCloses(hand && (() => hand.fire("desktop-leave", { session: x.key })))}>
|
|
555
|
+
<button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={`Go to the session ${x.key}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
556
|
+
<span className="desktop-pill-icon"><AppIcon drawing={sessionIcon} /></span>
|
|
557
|
+
<span className="desktop-row-label">{x.key}</span>
|
|
558
|
+
<span className="desktop-row-note">{note}</span>
|
|
559
|
+
</button>
|
|
560
|
+
{hand && <button type="button" className="desktop-pill-close" aria-label={`End the session ${x.key}`} title={`End the session ${x.key}`} onClick={() => hand.fire("desktop-leave", { session: x.key })}><Cross /></button>}
|
|
561
|
+
</span>;
|
|
562
|
+
})}
|
|
563
|
+
{newSession && (() => { const i = S0 + sessionRows.length; return <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} style={{ "--i": i + 1 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} disabled={!hand} title="Open a new session in this tab" onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
564
|
+
<span className="desktop-pill-icon"><AppIcon drawing={plusIcon} /></span>
|
|
565
|
+
<span className="desktop-row-label">New session</span>
|
|
566
|
+
<span className="desktop-row-note">this tab is on {w.key}</span>
|
|
567
|
+
</button>; })()}
|
|
568
|
+
</div>}
|
|
569
|
+
{leaving && <div className="desktop-group desktop-leave">
|
|
570
|
+
<button type="button" id={`${resultsId}-${L}`} data-result={L} role="option" aria-selected={cursor === L} className={`desktop-row desktop-row-app${cursor === L ? " is-cursor" : ""}`} style={{ "--i": L + 2 + (openRows.length ? 1 : 0) + (found.length ? 1 : 0) } as CSSProperties} title={`Leave ${title}: back to ${root!.label}`} onMouseEnter={() => setAt(L)} onClick={() => choose(L)}>
|
|
571
|
+
<span className="desktop-pill-icon"><AppIcon drawing={leaveIcon} /></span>
|
|
572
|
+
<span className="desktop-row-label">Leave {title}</span>
|
|
573
|
+
<span className="desktop-row-note">{root!.label}</span>
|
|
574
|
+
</button>
|
|
575
|
+
</div>}
|
|
576
|
+
{!count && <p className="desktop-muted">{text ? "Nothing matches." : "Nothing here yet."}</p>}
|
|
577
|
+
</div>
|
|
578
|
+
<footer className="desktop-launcher-legend" aria-label="Shortcuts">
|
|
579
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> move</span>
|
|
580
|
+
<span><kbd>↵</kbd> open</span>
|
|
581
|
+
<span><kbd>esc</kbd> {text ? "clear" : "close"}</span>
|
|
582
|
+
<span><kbd>⌘K</kbd> launcher</span>
|
|
583
|
+
<span><kbd>⌘W</kbd> close window</span>
|
|
584
|
+
</footer>
|
|
585
|
+
</div>}
|
|
586
|
+
</div>;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
class AppBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
|
590
|
+
override state = { error: null as string | null };
|
|
591
|
+
static getDerivedStateFromError(error: unknown) { return { error: String(error) }; }
|
|
592
|
+
override render() { return this.state.error ? <div className="desktop-empty" role="alert"><p>{this.state.error}</p><button onClick={() => this.setState({ error: null })}>Try again</button></div> : this.props.children; }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** What a window shows when no component fits its app here. */
|
|
596
|
+
const bareWindow = (title: string) => <div className="desktop-empty"><h2>{title}</h2><p className="desktop-muted">This window is open.</p></div>;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The desktop of a state that holds the desktop system, read through the hooks: the session last touched, as a tab on that
|
|
600
|
+
* state would show it, its windows and the app in front, each drawn by its component in `apps`. It acts with useHand's
|
|
601
|
+
* hand, into whatever state it is shown over; a frame given no hand only shows.
|
|
602
|
+
*/
|
|
603
|
+
export function SpecDesktop({ system, apps = {}, title = "Desktop" }: { system: DesktopSystem; apps?: Record<string, ComponentType<AppProps>>; title?: string }) {
|
|
604
|
+
const selectors = useMemo(() => ({ apps: select(system.desktopApp), icons: select(system.desktopIcon), sessions: select(system.desktopSession), windows: select(system.desktopWindow) }), [system]);
|
|
605
|
+
const appRows = useColumn(selectors.apps, system.desktopApp);
|
|
606
|
+
const iconRows = useColumn(selectors.icons, system.desktopIcon);
|
|
607
|
+
const sessions = useColumn(selectors.sessions, system.desktopSession);
|
|
608
|
+
const windows = useColumn(selectors.windows, system.desktopWindow);
|
|
609
|
+
const theme = useResource(system.desktopSettings)?.theme;
|
|
610
|
+
const hand = useHand();
|
|
611
|
+
const session = [...sessions].sort(([a, x], [b, y]) => (y.touched ?? 0) - (x.touched ?? 0) || b - a)[0];
|
|
612
|
+
if (!session) return <p className="desktop-empty">The desktop opens on its first round.</p>;
|
|
613
|
+
const picture: Picture = {
|
|
614
|
+
session: { entity: session[0], ...session[1] },
|
|
615
|
+
apps: [...appRows].map(([entity, value]) => ({ entity, ...value, drawing: drawn(value, iconRows.get(entity)) })).sort((a, b) => a.order - b.order),
|
|
616
|
+
windows: [...windows].filter(([, value]) => value.session === session[0]).map(([entity, value]) => ({ entity, ...value })),
|
|
617
|
+
...(theme ? { theme } : {}),
|
|
618
|
+
};
|
|
619
|
+
return <DesktopFrame picture={picture} title={title} hand={hand} render={(s, props) => { const View = apps[s.key]; return View ? <View {...props} /> : bareWindow(s.title); }} />;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** An app a LocalDesktop offers: as the baba declares it. */
|
|
623
|
+
export interface LocalApp { key: string; title: string; group?: string; icon?: string; many?: boolean; routes?: readonly string[] }
|
|
624
|
+
interface LocalWindow { entity: Entity; app: Entity; path: string; title: string; memory: Record<string, unknown> }
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The desktop's chrome over a state without the desktop system: its windows are this component's own, not the state's, so
|
|
628
|
+
* opening, closing and navigating move nothing in the state drawn. The apps draw inside it with the hooks reading the state
|
|
629
|
+
* it is shown over and their own hands acting on it. The Maker shows the whole baba this way; `open` names the app in front at first.
|
|
630
|
+
*/
|
|
631
|
+
export function LocalDesktop({ apps, components, title = "Desktop", open: first }: { apps: readonly LocalApp[]; components: Record<string, ComponentType<AppProps>>; title?: string; open?: string }) {
|
|
632
|
+
const rows = useMemo<AppRow[]>(() => apps.map((sc, order) => ({ entity: order + 1, key: sc.key, title: sc.title, group: sc.group ?? "", icon: sc.icon ?? "", many: sc.many ?? false, order, routes: [...(sc.routes ?? [])], drawing: null })), [apps]);
|
|
633
|
+
const next = useRef(1000);
|
|
634
|
+
const [state, setState] = useState<{ windows: LocalWindow[]; selected: Entity | null }>(() => {
|
|
635
|
+
const at = rows.find((r) => r.key === first);
|
|
636
|
+
if (!at) return { windows: [], selected: null };
|
|
637
|
+
const e = next.current++;
|
|
638
|
+
return { windows: [{ entity: e, app: at.entity, path: "/", title: "", memory: {} }], selected: e };
|
|
639
|
+
});
|
|
640
|
+
const hand: Hand = { fire: (name, args) => {
|
|
641
|
+
const a = (args ?? {}) as { app?: string; path?: string; fresh?: boolean; window?: Entity; title?: string; key?: string; value?: unknown };
|
|
642
|
+
setState((st) => {
|
|
643
|
+
const target = a.window ?? st.selected;
|
|
644
|
+
switch (name) {
|
|
645
|
+
case "desktop-open": {
|
|
646
|
+
const sc = rows.find((r) => r.key === a.app);
|
|
647
|
+
if (!sc || (a.path !== undefined && sc.routes?.length && !matchRoute(sc.routes, a.path))) return st;
|
|
648
|
+
const have = st.windows.filter((o) => o.app === sc.entity).at(-1);
|
|
649
|
+
if (have && !(a.fresh && sc.many)) return { windows: st.windows.map((o) => (o === have && a.path !== undefined ? { ...o, path: a.path } : o)), selected: have.entity };
|
|
650
|
+
const made: LocalWindow = { entity: next.current++, app: sc.entity, path: a.path ?? "/", title: "", memory: {} };
|
|
651
|
+
return { windows: [...st.windows, made], selected: made.entity };
|
|
652
|
+
}
|
|
653
|
+
case "desktop-select": return st.windows.some((o) => o.entity === a.window) ? { ...st, selected: a.window! } : st;
|
|
654
|
+
case "desktop-close": {
|
|
655
|
+
const i = st.windows.findIndex((o) => o.entity === target);
|
|
656
|
+
if (i < 0) return st;
|
|
657
|
+
const windows = st.windows.filter((_, k) => k !== i);
|
|
658
|
+
return { windows, selected: st.selected !== target ? st.selected : (windows[i] ?? windows[i - 1])?.entity ?? null };
|
|
659
|
+
}
|
|
660
|
+
case "desktop-title": return { ...st, windows: st.windows.map((o) => (o.entity === target ? { ...o, title: a.title ?? "" } : o)) };
|
|
661
|
+
case "desktop-remember": return { ...st, windows: st.windows.map((o) => (o.entity === target && a.key !== undefined ? { ...o, memory: a.value === null ? Object.fromEntries(Object.entries(o.memory).filter(([k]) => k !== a.key)) : { ...o.memory, [a.key]: a.value } } : o)) };
|
|
662
|
+
default: return st;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
} };
|
|
666
|
+
const picture: Picture = {
|
|
667
|
+
session: { entity: 0, key: "local", selected: state.selected, theme: "system" },
|
|
668
|
+
apps: rows,
|
|
669
|
+
windows: state.windows.map((o) => ({ ...o, session: 0 })),
|
|
670
|
+
};
|
|
671
|
+
return <DesktopFrame picture={picture} title={title} hand={hand} render={(s, props) => { const View = components[s.key]; return View ? <View {...props} /> : bareWindow(s.title); }} />;
|
|
672
|
+
}
|
|
673
|
+
|