lecodes-sdk 2.2.3 → 2.2.5
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/dist/global.d.ts +3 -1
- package/dist/types/animate/tween/spec.d.ts +4 -5
- package/dist/types/g2/Scene2D.d.ts +5 -4
- package/dist/types/gl/Scene.d.ts +5 -4
- package/dist/types/inject.d.ts +1 -1
- package/dist/types/server/db/fields.d.ts +6 -3
- package/dist/types/server/db/marci/query.d.ts +21 -2
- package/dist/types/server/db/types.d.ts +26 -12
- package/dist/types/server/errors.d.ts +4 -1
- package/dist/types/server/files/models.d.ts +4 -0
- package/dist/types/server/inject.d.ts +6 -0
- package/dist/types/ui/NativeView.d.ts +3 -3
- package/dist/types/ui/UI.d.ts +1 -1
- package/dist/types/ui/UILayer.d.ts +25 -18
- package/dist/types/ui/UINode.d.ts +8 -1
- package/dist/types/ui/UIScreen.d.ts +11 -1
- package/dist/types/ui/UIVideo.d.ts +3 -3
- package/dist/types/ui/UIVirtualizedList.d.ts +64 -18
- package/dist/types/ui/UIWidget.d.ts +24 -15
- package/dist/types/ui/presentable.d.ts +7 -7
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/prompts/3d-scene-files.md +2 -2
- package/prompts/3d-scene.md +1 -1
- package/prompts/dist/2d-game.md +13 -13
- package/prompts/dist/3d-app.md +21 -18
- package/prompts/dist/ar-app.md +13 -13
- package/prompts/dist/ui-app.md +13 -13
- package/prompts/ui.md +3 -1
- package/src/animate/tween/spec.ts +8 -13
- package/src/chisel.ts +3 -3
- package/src/compile/serverSplit.ts +5 -3
- package/src/g2/Scene2D.ts +9 -7
- package/src/gl/Scene.ts +9 -7
- package/src/inject.ts +1 -1
- package/src/server/db/defineDb.ts +17 -21
- package/src/server/db/fields.ts +7 -4
- package/src/server/db/httpTransport.ts +19 -3
- package/src/server/db/marci/query.ts +34 -8
- package/src/server/db/types.ts +33 -14
- package/src/server/errors.ts +4 -1
- package/src/server/files/db.ts +26 -7
- package/src/server/files/host.ts +45 -17
- package/src/server/files/models.ts +4 -0
- package/src/server/host.ts +3 -3
- package/src/server/inject.ts +3 -0
- package/src/server/runtime.ts +65 -4
- package/src/ui/NativeView.ts +11 -9
- package/src/ui/UI.ts +1 -1
- package/src/ui/UILayer.ts +90 -42
- package/src/ui/UINode.ts +33 -11
- package/src/ui/UIPopover.ts +3 -3
- package/src/ui/UIScreen.ts +50 -2
- package/src/ui/UIVideo.ts +11 -9
- package/src/ui/UIVirtualizedList.ts +159 -31
- package/src/ui/UIWidget.ts +41 -18
- package/src/ui/presentable.ts +8 -8
- package/src/ui/transitions.ts +1 -1
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +6 -1
package/src/server/runtime.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
|
|
|
14
14
|
import type { ServerManifest } from "../compile/serverSplit"
|
|
15
15
|
import type { AuthHost } from "./auth/host"
|
|
16
16
|
import type { AuthState } from "./auth/types"
|
|
17
|
-
import type { FilesHost } from "./files/host"
|
|
17
|
+
import type { FilesHost, ResourceReader } from "./files/host"
|
|
18
18
|
import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
|
|
19
19
|
import { setRequestProvider, type RequestContext } from "./context"
|
|
20
20
|
import type { Db } from "./db/types"
|
|
@@ -55,7 +55,7 @@ export const loadServerModules = (
|
|
|
55
55
|
// One database per project: the host syncs one schema, so the models of a second `defineDb` would not exist.
|
|
56
56
|
if (out.dbs.length > 1) {
|
|
57
57
|
// named by their collections (own enumerable keys): reading `$models` would seal a db that is being refused
|
|
58
|
-
const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth").map(k => `db.${k}`).join(", ")).join(" | ")
|
|
58
|
+
const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth" && k !== "seed").map(k => `db.${k}`).join(", ")).join(" | ")
|
|
59
59
|
throw new Error(`a project has one database, and defineDb is called ${out.dbs.length} times (${each}) — put every model in one defineDb({...}) and import that db where it is needed`)
|
|
60
60
|
}
|
|
61
61
|
return out
|
|
@@ -83,6 +83,19 @@ const apiStatus = (e: unknown): number | null =>
|
|
|
83
83
|
e && typeof e === "object" && (e as { name?: string }).name === "ApiError" && typeof (e as { status?: unknown }).status === "number"
|
|
84
84
|
? (e as { status: number }).status : null
|
|
85
85
|
|
|
86
|
+
/** What the app is told when the database refused a write a person could have caused: the engine's own
|
|
87
|
+
* words — `Like.user, Like.post: the value is already taken (by the row with id 7)` — with the status
|
|
88
|
+
* its kind means. A taken unique value, a pointer at no row, a delete other rows forbid: 409; a row that
|
|
89
|
+
* is gone: 404. Anything else the database says (a bad payload, a fault) stays a 500: a mistake of the
|
|
90
|
+
* code, not of the person. Both transports' errors carry `kind` (marcidb-embedded's, the HTTP one's). */
|
|
91
|
+
const DB_ERROR_STATUS: Record<string, number> = { unique: 409, foreign_key: 409, restrict: 409, duplicate_id: 409, not_found: 404 }
|
|
92
|
+
const dbStatus = (e: unknown): number | null => {
|
|
93
|
+
if (!e || typeof e !== "object") return null
|
|
94
|
+
const { name, kind } = e as { name?: unknown, kind?: unknown }
|
|
95
|
+
if ((name !== "MarciEmbeddedError" && name !== "MarciHttpError") || typeof kind !== "string") return null
|
|
96
|
+
return DB_ERROR_STATUS[kind] ?? null
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
/**
|
|
87
100
|
* Build the invoker: `invoke(id, args, ctx)` runs endpoint `id` inside an AsyncLocalStorage-scoped
|
|
88
101
|
* request context (what the `request` global and `db.auth` read) and returns the wire envelope.
|
|
@@ -119,7 +132,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
|
|
|
119
132
|
const result = await als.run(rc, () => fn(...args))
|
|
120
133
|
return withSession({ ok: true, result: result === undefined ? null : result }, rc)
|
|
121
134
|
} catch (e) {
|
|
122
|
-
const status = apiStatus(e)
|
|
135
|
+
const status = apiStatus(e) ?? dbStatus(e)
|
|
123
136
|
if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
|
|
124
137
|
if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
|
|
125
138
|
onError(id, e)
|
|
@@ -161,6 +174,54 @@ export const createFileWriter = (server: LoadedServer, opts: { files?: FilesHost
|
|
|
161
174
|
}
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
/** What stops a seed — the project's own doing, in words the project's author can act on. */
|
|
178
|
+
export class SeedError extends Error {
|
|
179
|
+
constructor(message: string) { super(message); this.name = "SeedError" }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type SeedOutcome =
|
|
183
|
+
/** the definition has no seed */
|
|
184
|
+
| { status: "none" }
|
|
185
|
+
/** the database has rows already: the seed did not run */
|
|
186
|
+
| { status: "skipped" }
|
|
187
|
+
/** the seed ran: the rows of every table of the project */
|
|
188
|
+
| { status: "seeded", rows: Record<string, number> }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The first rows of the project's database: `defineDb(...).seed(fn)` run ONCE, after the host has
|
|
192
|
+
* brought an EMPTY database to the bundle's schema — a check's, a dev backend's, a deploy's first.
|
|
193
|
+
* A database with any row of the project's own tables is left as it is (the seed is the start, not a
|
|
194
|
+
* migration). Inside the seed a file field takes a resource of the project by its path (what a path
|
|
195
|
+
* literal of a shared module is in the server bundle), read through `read` and stored as an upload
|
|
196
|
+
* would be. Throws a `SeedError` with the seed's own words when it fails — the host reports it, and the
|
|
197
|
+
* database is whatever the seed wrote before (the host that wants an all-or-nothing start starts over).
|
|
198
|
+
*/
|
|
199
|
+
export const runSeed = async (server: LoadedServer, opts: { files?: FilesHost | null, read?: ResourceReader } = {}): Promise<SeedOutcome> => {
|
|
200
|
+
installProvider()
|
|
201
|
+
const db = server.dbs[0]
|
|
202
|
+
const fn = db?.$seed
|
|
203
|
+
if (!db || !fn) return { status: "none" }
|
|
204
|
+
const names = Object.keys(db.$given)
|
|
205
|
+
const collection = (name: string) => (db as any)[name.charAt(0).toLowerCase() + name.slice(1)]
|
|
206
|
+
for (const name of names) if ((await collection(name).count()) > 0) return { status: "skipped" }
|
|
207
|
+
const rc: RequestContext = { id: "$seed", headers: {} }
|
|
208
|
+
const files = opts.files ?? null
|
|
209
|
+
const read: ResourceReader = opts.read ?? (async () => null)
|
|
210
|
+
const done = files?.enabled ? files.seeding(rc, read) : () => {}
|
|
211
|
+
try {
|
|
212
|
+
await als.run(rc, () => fn(db as any))
|
|
213
|
+
} catch (e) {
|
|
214
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
215
|
+
throw new SeedError(`seed: ${message}`)
|
|
216
|
+
} finally {
|
|
217
|
+
done()
|
|
218
|
+
if (files?.enabled) await als.run(rc, () => files.finish(rc))
|
|
219
|
+
}
|
|
220
|
+
const rows: Record<string, number> = {}
|
|
221
|
+
for (const name of names) rows[name] = await collection(name).count()
|
|
222
|
+
return { status: "seeded", rows }
|
|
223
|
+
}
|
|
224
|
+
|
|
164
225
|
/**
|
|
165
226
|
* A subscription to a channel: run its hooks (`authorize`, then `groupBy`) in the subscriber's
|
|
166
227
|
* request scope. The envelope's `result` is the group the subscriber is in — `null` for a channel
|
|
@@ -187,7 +248,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
|
|
|
187
248
|
if (group !== null && !isChannelGroup(group)) throw new Error(`groupBy answered ${group === undefined ? "nothing" : typeof group} — a group is a string or a number`)
|
|
188
249
|
return withSession({ ok: true, result: group }, rc)
|
|
189
250
|
} catch (e) {
|
|
190
|
-
const status = apiStatus(e)
|
|
251
|
+
const status = apiStatus(e) ?? dbStatus(e)
|
|
191
252
|
if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
|
|
192
253
|
if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
|
|
193
254
|
onError(channelId, e)
|
package/src/ui/NativeView.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// wrapper convention.
|
|
11
11
|
|
|
12
12
|
import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
|
|
13
|
-
import {
|
|
13
|
+
import { _surfaceWidgets, type LayerElement, type WidgetSet } from "./UILayer"
|
|
14
14
|
import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
|
|
15
15
|
import { _transitionId, TRANSITION_NONE } from "./transitions"
|
|
16
16
|
import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
|
|
@@ -46,8 +46,8 @@ export interface NativeView extends UIElementBase<UINativeViewStyle, DrawableSty
|
|
|
46
46
|
/** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
|
|
47
47
|
on(event: string, callback: (data?: any) => void): this
|
|
48
48
|
off(event: string, callback: (data?: any) => void): this
|
|
49
|
-
/** The
|
|
50
|
-
|
|
49
|
+
/** The widgets over the view when it is presented (`Presentable.widgets`): `map.widgets(header, card)`. */
|
|
50
|
+
readonly widgets: WidgetSet<this>
|
|
51
51
|
/** @internal The destination tuple (Presentable). */
|
|
52
52
|
_dest(): DestTuple
|
|
53
53
|
}
|
|
@@ -88,13 +88,15 @@ export class NativeViewElement extends Element<"native"> implements Presentable
|
|
|
88
88
|
|
|
89
89
|
/** @internal */
|
|
90
90
|
_dest(): DestTuple { return [TREE_DEST_NATIVE, this._viewId, this.viewName, this._paramsJson] }
|
|
91
|
-
/** @internal The view's
|
|
91
|
+
/** @internal The view's widget layer (ui/UILayer.ts), made on the first use of `widgets`. */
|
|
92
92
|
_contentLayer?: LayerElement
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
/** @internal */
|
|
94
|
+
_widgetSet?: WidgetSet<any>
|
|
95
|
+
/** The widgets over the view while it is presented — a HUD, a dialog, a sheet: `map.widgets(hud)`
|
|
96
|
+
* sets them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Laid out
|
|
97
|
+
* over it; touches outside a widget's box reach the view. */
|
|
98
|
+
get widgets(): WidgetSet<this> {
|
|
99
|
+
return _surfaceWidgets(this)
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
open(options?: PresentOptions): void {
|
package/src/ui/UI.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { UIVideo, type UIVideoStyle } from './UIVideo'
|
|
|
10
10
|
export { UIInput, UITextArea, type UIInputStyle } from './UIInput'
|
|
11
11
|
|
|
12
12
|
export { UIWidget, type UIWidgetStyle } from './UIWidget'
|
|
13
|
-
export {
|
|
13
|
+
export { type WidgetSet, type UIWidgetContent, type UIWidgetChild } from './UILayer'
|
|
14
14
|
export { UIModal, type UIModalStyle } from './UIModal'
|
|
15
15
|
export { UIBottomSheet, type UIBottomSheetStyle } from './UIBottomSheet'
|
|
16
16
|
export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
|
package/src/ui/UILayer.ts
CHANGED
|
@@ -1,37 +1,106 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The WIDGETS of a destination (docs/plans/widgets-plan.md): one face on every destination —
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// screen.widgets(dialog, sheet) scene.widgets(hud) map.widgets(header, card)
|
|
4
|
+
// x.widgets.append(w) / x.widgets.remove(w) / x.widgets.list
|
|
5
|
+
//
|
|
6
|
+
// A screen keeps its widgets in the tree as the TAIL of its children (the runtime's portal link:
|
|
7
|
+
// never a yoga child, hoisted to the outermost presented root); `children` of a screen reads its
|
|
8
|
+
// content only, `widgets.list` the tail. A surface — a `Scene`, a `Scene2D`, a `NativeView`, a
|
|
9
|
+
// `UIVideo` — cannot take children, so its widgets live in a LAYER root of their own, bound to the
|
|
10
|
+
// destination (`_creatorTree.setLayer`) and shown over it while it is presented. The APP layer is
|
|
11
|
+
// the same kind of root above every destination, always shown: where a widget with no place lands
|
|
12
|
+
// on `show()` (a global loader, a dialog opened from anywhere) — it has no name of its own.
|
|
9
13
|
//
|
|
10
14
|
// A layer is a "layer" node of the tree: a pass-through root the runtime lays out at the frame and
|
|
11
|
-
// the host paints after the destination; its own box is never hit, only its widgets are.
|
|
12
|
-
// runtime learns which destination a layer belongs to through `_creatorTree.setLayer`.
|
|
15
|
+
// the host paints after the destination; its own box is never hit, only its widgets are.
|
|
13
16
|
import { ContainerElement, type UINodeChild, type ChildrenFn } from "./UINode"
|
|
14
17
|
import type { UIWidget } from "./UIWidget"
|
|
15
18
|
import type { DestTuple } from "./presentable"
|
|
16
19
|
import { TREE_DEST_NONE, pin, tree } from "./tree"
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
/** A widget slot: a widget, or a falsy value (skipped). */
|
|
22
|
+
export type UIWidgetChild = UIWidget | null | undefined | false
|
|
23
|
+
/** The widgets of a destination as `widgets()` takes them: a list, or a function for reactive
|
|
24
|
+
* content (re-run when a signal read inside it changes, reconciled against the shown ones). */
|
|
25
|
+
export type UIWidgetContent = UIWidgetChild[] | (() => UIWidgetChild[])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The widgets of a destination — a dialog, a sheet, a HUD: what floats over it, laid out and
|
|
29
|
+
* painted in its box above everything else. Call it to set them (`screen.widgets(dialog)`, same
|
|
30
|
+
* argument forms as `UIColumn`: widgets, arrays of them, or ONE function for reactive content);
|
|
31
|
+
* `append` / `remove` manage them one at a time; `list` reads them. A widget shows with its
|
|
32
|
+
* destination unless its style says `display: "none"`; `show()` / `hide()` flip that, and
|
|
33
|
+
* `show()` puts it on top of the others.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const home = UIScreen(content).widgets(dialog, sheet)
|
|
37
|
+
* scene.widgets(hud)
|
|
38
|
+
* scene.widgets(() => [hud, paused() && pauseMenu])
|
|
39
|
+
* home.widgets.append(toast); home.widgets.remove(toast)
|
|
40
|
+
* home.widgets.list // [dialog, sheet] — never in `children`
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export interface WidgetSet<Owner> {
|
|
44
|
+
(...widgets: (UIWidgetChild | UIWidgetChild[])[]): Owner
|
|
45
|
+
(widgets: UIWidgetContent): Owner
|
|
46
|
+
/** Add widgets at the end (on top). */
|
|
47
|
+
append(...widgets: UIWidgetChild[]): void
|
|
48
|
+
/** Take widgets out (they stay valid while you hold them; a dropped one is freed). */
|
|
49
|
+
remove(...widgets: UIWidgetChild[]): void
|
|
50
|
+
/** The widgets, bottom to top — a snapshot read from the runtime. */
|
|
51
|
+
readonly list: UIWidget[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal What a widget set drives: a screen (its widget tail) or a surface's layer. */
|
|
55
|
+
export interface WidgetHost {
|
|
56
|
+
_setWidgets(content: UIWidgetContent): void
|
|
57
|
+
_appendWidgets(widgets: UINodeChild[]): void
|
|
58
|
+
_removeWidgets(widgets: UINodeChild[]): void
|
|
59
|
+
_widgetList(): UIWidget[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @internal `widgets(...)`'s arguments as content: one function → reactive, one array → the
|
|
63
|
+
* list, otherwise the rest arguments with arrays among them flattened (as the UI factories). */
|
|
64
|
+
export const _widgetContent = (args: any[]): UIWidgetContent => {
|
|
65
|
+
if (args.length === 1 && (typeof args[0] === "function" || Array.isArray(args[0]))) return args[0]
|
|
66
|
+
const flat: UIWidgetChild[] = []
|
|
67
|
+
for (const a of args) {
|
|
68
|
+
if (Array.isArray(a)) for (const w of a) flat.push(w)
|
|
69
|
+
else flat.push(a)
|
|
70
|
+
}
|
|
71
|
+
return flat
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @internal The widget set of `owner` over `host()` (resolved at every call: a surface's layer
|
|
75
|
+
* is made on first use). */
|
|
76
|
+
export const _makeWidgetSet = <O>(owner: O, host: () => WidgetHost): WidgetSet<O> => {
|
|
77
|
+
const set = ((...args: any[]) => { host()._setWidgets(_widgetContent(args)); return owner }) as WidgetSet<O>
|
|
78
|
+
set.append = (...widgets) => { host()._appendWidgets(widgets as UINodeChild[]) }
|
|
79
|
+
set.remove = (...widgets) => { host()._removeWidgets(widgets as UINodeChild[]) }
|
|
80
|
+
Object.defineProperty(set, "list", { get: () => host()._widgetList() })
|
|
81
|
+
return set
|
|
82
|
+
}
|
|
19
83
|
|
|
20
84
|
/** @internal A layer root: a container whose children are widgets. */
|
|
21
|
-
export class LayerElement extends ContainerElement<"layer"> {
|
|
85
|
+
export class LayerElement extends ContainerElement<"layer"> implements WidgetHost {
|
|
22
86
|
constructor() {
|
|
23
87
|
super("layer", {}, [])
|
|
24
88
|
}
|
|
89
|
+
_setWidgets(content: UIWidgetContent): void { this.setContent(content as UINodeChild[] | ChildrenFn) }
|
|
90
|
+
_appendWidgets(widgets: UINodeChild[]): void { this.append(...widgets) }
|
|
91
|
+
_removeWidgets(widgets: UINodeChild[]): void { this.remove(...widgets) }
|
|
92
|
+
_widgetList(): UIWidget[] { return this.children as UIWidget[] }
|
|
25
93
|
}
|
|
26
94
|
|
|
27
|
-
/** @internal A surface destination that
|
|
95
|
+
/** @internal A surface destination that holds its widgets in a layer of its own. */
|
|
28
96
|
export interface LayerOwner {
|
|
29
97
|
_contentLayer?: LayerElement
|
|
98
|
+
_widgetSet?: WidgetSet<any>
|
|
30
99
|
_dest(): DestTuple
|
|
31
100
|
}
|
|
32
101
|
|
|
33
|
-
/** @internal The
|
|
34
|
-
*
|
|
102
|
+
/** @internal The layer of a surface destination, made on first use and bound to the destination
|
|
103
|
+
* (`setLayer(kind, id, layer)`); the runtime shows it while the surface is presented. */
|
|
35
104
|
export const _destLayer = (owner: LayerOwner): LayerElement => {
|
|
36
105
|
if (!owner._contentLayer) {
|
|
37
106
|
const layer = new LayerElement()
|
|
@@ -43,15 +112,18 @@ export const _destLayer = (owner: LayerOwner): LayerElement => {
|
|
|
43
112
|
return owner._contentLayer
|
|
44
113
|
}
|
|
45
114
|
|
|
46
|
-
/** @internal `
|
|
47
|
-
export const
|
|
48
|
-
|
|
115
|
+
/** @internal The `widgets` of a surface: one set per owner, over its layer. */
|
|
116
|
+
export const _surfaceWidgets = <O extends LayerOwner>(owner: O): WidgetSet<O> => {
|
|
117
|
+
if (!owner._widgetSet) owner._widgetSet = _makeWidgetSet(owner, () => _destLayer(owner))
|
|
118
|
+
return owner._widgetSet as WidgetSet<O>
|
|
49
119
|
}
|
|
50
120
|
|
|
51
121
|
// ---- the app layer ------------------------------------------------------------------------------------
|
|
52
122
|
let appLayer: LayerElement | null = null
|
|
53
123
|
|
|
54
|
-
|
|
124
|
+
/** @internal The app layer, made on first use: above every destination, always shown — where a
|
|
125
|
+
* widget with no parent lands on `show()` (UIWidget), where a popover with no anchor goes. */
|
|
126
|
+
export const _appLayer = (): LayerElement => {
|
|
55
127
|
if (!appLayer) {
|
|
56
128
|
appLayer = new LayerElement()
|
|
57
129
|
tree().setLayer(TREE_DEST_NONE, 0, appLayer._h.id)
|
|
@@ -62,27 +134,3 @@ const layer = (): LayerElement => {
|
|
|
62
134
|
|
|
63
135
|
/** @internal Tests: a new fake runtime has no app layer of the previous one. */
|
|
64
136
|
export const _resetOverlay = (): void => { appLayer = null }
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The app layer: the widgets above EVERY destination, always shown — a global loader, a mini-player
|
|
68
|
-
* that follows the user across screens, a toast with actions. Widgets that belong to a place go in
|
|
69
|
-
* that place instead: a dialog among its screen's children (`UIScreen(..., dialog)`), a HUD in
|
|
70
|
-
* `scene.setContent([hud])`. A widget shown with `show()` while it has no parent lands here.
|
|
71
|
-
*
|
|
72
|
-
* ```ts
|
|
73
|
-
* UIOverlay.append(miniPlayer)
|
|
74
|
-
* miniPlayer.hide() // display: none — still here, ready for show()
|
|
75
|
-
* ```
|
|
76
|
-
*/
|
|
77
|
-
export const UIOverlay = /*#__PURE__*/ Object.freeze({
|
|
78
|
-
/** Add widgets at the end (on top). */
|
|
79
|
-
append(...widgets: (UIWidget | null | undefined | false)[]): void { layer().append(...(widgets as UINodeChild[])) },
|
|
80
|
-
/** Insert widgets at `index` (0 = the bottom of the layer). */
|
|
81
|
-
insert(index: number, ...widgets: (UIWidget | null | undefined | false)[]): void { layer().insert(index, ...(widgets as UINodeChild[])) },
|
|
82
|
-
/** Take widgets out of the layer (they stay valid; a dropped one is freed). */
|
|
83
|
-
remove(...widgets: (UIWidget | null | undefined | false)[]): void { layer().remove(...(widgets as UINodeChild[])) },
|
|
84
|
-
/** Replace the layer's widgets — a list, or a function for reactive content. */
|
|
85
|
-
setContent(content: UIWidgetContent): void { layer().setContent(content as UINodeChild[] | ChildrenFn) },
|
|
86
|
-
/** The layer's widgets, bottom to top (a snapshot read from the runtime). */
|
|
87
|
-
get children(): UIWidget[] { return appLayer ? (appLayer.children as UIWidget[]) : [] },
|
|
88
|
-
})
|
package/src/ui/UINode.ts
CHANGED
|
@@ -41,6 +41,9 @@ export type Color = ColorInput | null
|
|
|
41
41
|
/** Style props every element accepts. */
|
|
42
42
|
export type BaseStyle = {
|
|
43
43
|
opacity?: number | `${number}`,
|
|
44
|
+
/** A CSS-style list of `translate` / `scale` / `rotate`. A `%` in a translate is a percent of the
|
|
45
|
+
* element's own box (`translate(-50%, -50%)` centres it on its corner), kept as a percent: it
|
|
46
|
+
* follows the box through every layout. */
|
|
44
47
|
transform?: string | null,
|
|
45
48
|
/** Pivot of `transform` (CSS `transform-origin` subset): one or two values, each a keyword
|
|
46
49
|
* (`left`/`center`/`right`, `top`/`center`/`bottom`), a percentage of the box, or a px length
|
|
@@ -795,11 +798,11 @@ export function buildUI<R>(args: any[], defaults: any, make: (style: any, childr
|
|
|
795
798
|
const acceptChild = (parent: { type: string }, node: any): boolean => {
|
|
796
799
|
if (!node) return false
|
|
797
800
|
if (node instanceof Element) {
|
|
798
|
-
// A widget
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
if (node.type === "widget" && parent.type !== "
|
|
802
|
-
throw new Error(`${parent.type}: a widget
|
|
801
|
+
// A widget is never a CHILD (docs/plans/widgets-plan.md decision 9): it is one of the
|
|
802
|
+
// `widgets` of a screen or a surface — a layer's children are widgets, and only a widget set
|
|
803
|
+
// puts one there (acceptWidget). This says what to write instead.
|
|
804
|
+
if (node.type === "widget" && parent.type !== "layer") {
|
|
805
|
+
throw new Error(`${parent.type}: a widget (UIWidget / UIModal / UIBottomSheet / UIPopover) is not a child. It belongs to the widgets of a screen or a surface: UIScreen(content).widgets(dialog), scene.widgets(hud). A floating element of a box is an absolute child: UIBox(...).style({ position: "absolute", ... }), not a widget.`)
|
|
803
806
|
}
|
|
804
807
|
return true
|
|
805
808
|
}
|
|
@@ -814,6 +817,16 @@ const acceptChild = (parent: { type: string }, node: any): boolean => {
|
|
|
814
817
|
return false
|
|
815
818
|
}
|
|
816
819
|
|
|
820
|
+
/** A widget slot of `widgets(...)` takes a widget (falsy = conditional, skipped silently);
|
|
821
|
+
* anything else is reported and skipped. */
|
|
822
|
+
export const acceptWidget = (owner: { type: string }, node: any): boolean => {
|
|
823
|
+
if (!node) return false
|
|
824
|
+
if (node instanceof Element && node.type === "widget") return true
|
|
825
|
+
const what = node instanceof Element ? `a ${node.type}` : typeof node === "object" ? (node.constructor?.name || "object") : typeof node
|
|
826
|
+
console.error(`${owner.type}.widgets: not a widget (${what}) — skipped. widgets() takes UIWidget / UIModal / UIBottomSheet / UIPopover; content goes among the children`)
|
|
827
|
+
return false
|
|
828
|
+
}
|
|
829
|
+
|
|
817
830
|
export class ContainerElement<T extends string> extends Element<T> {
|
|
818
831
|
constructor(type: T, style: any, children: any[] | ChildrenFn) {
|
|
819
832
|
super(type, style)
|
|
@@ -852,13 +865,19 @@ export class ContainerElement<T extends string> extends Element<T> {
|
|
|
852
865
|
_reconcile(next: UINodeChild[]) {
|
|
853
866
|
const target: any[] = []
|
|
854
867
|
for (const c of next) if (acceptChild(this, c)) target.push(c)
|
|
855
|
-
|
|
868
|
+
this._reconcileList(target, () => this.children, (i, node) => this._insertAt(i, node))
|
|
869
|
+
}
|
|
856
870
|
|
|
857
|
-
|
|
871
|
+
/** @internal The diff of `_reconcile` over any list of this node's children: `current` reads
|
|
872
|
+
* the mounted ones (a screen's content, or its widget tail), `insertAt` mounts one at an index
|
|
873
|
+
* of that list. */
|
|
874
|
+
_reconcileList(target: any[], current: () => any[], insertAt: (index: number, node: any) => void) {
|
|
875
|
+
if (this._h.id === 0) return
|
|
876
|
+
const mounted: any[] = current()
|
|
858
877
|
const targetSet = new Set(target)
|
|
859
|
-
const departed =
|
|
878
|
+
const departed = mounted.filter(c => !targetSet.has(c))
|
|
860
879
|
if (departed.length > 0) this.remove(...departed)
|
|
861
|
-
const cur: any[] = departed.length > 0 ?
|
|
880
|
+
const cur: any[] = departed.length > 0 ? current() : mounted
|
|
862
881
|
|
|
863
882
|
// `cur` mirrors the mounted order; walk the target and fix mismatches in place
|
|
864
883
|
for (let i = 0; i < target.length; i++) {
|
|
@@ -869,12 +888,15 @@ export class ContainerElement<T extends string> extends Element<T> {
|
|
|
869
888
|
this.remove(node) // moved forward — remount at its new position
|
|
870
889
|
cur.splice(j, 1)
|
|
871
890
|
}
|
|
872
|
-
|
|
891
|
+
insertAt(i, node)
|
|
873
892
|
cur.splice(i, 0, node)
|
|
874
893
|
}
|
|
875
894
|
}
|
|
876
895
|
|
|
877
|
-
|
|
896
|
+
/** @internal Mount `node` at `index` of the runtime's child list (a screen: its content, then
|
|
897
|
+
* its widgets — tree.cpp treeInsert clamps a content index into the content, a widget's into
|
|
898
|
+
* the tail). */
|
|
899
|
+
_insertAt(index: number, node: any): void {
|
|
878
900
|
if (this._h.id === 0 || !node || node._h.id === 0) return
|
|
879
901
|
tree().insert(this._h.id, node._h.id, index)
|
|
880
902
|
pin(node)
|
package/src/ui/UIPopover.ts
CHANGED
|
@@ -58,7 +58,7 @@ export class PopoverElement extends ModalElement {
|
|
|
58
58
|
}
|
|
59
59
|
// A menu belongs where it opened: the anchor's root — a screen, a page, a surface's layer.
|
|
60
60
|
const root = anchorRoot(anchor)
|
|
61
|
-
if (root && this.parent !== root) root.
|
|
61
|
+
if (root && this.parent !== root) root._appendWidgets([this])
|
|
62
62
|
} else {
|
|
63
63
|
this._anchorRect = undefined // unanchored show: the user's own style owns the position
|
|
64
64
|
}
|
|
@@ -92,13 +92,13 @@ export class PopoverElement extends ModalElement {
|
|
|
92
92
|
// The root an anchor element sits in, when it is a place a widget can belong to: a screen or a
|
|
93
93
|
// layer that is ATTACHED (presented, in the router, a pager's page, a shown layer — the pins find
|
|
94
94
|
// it by id); null for a point, a loose subtree, or a screen that is not on any stack.
|
|
95
|
-
const anchorRoot = (anchor: UIPopoverAnchor): {
|
|
95
|
+
const anchorRoot = (anchor: UIPopoverAnchor): { _appendWidgets(nodes: any[]): void, type: string } | null => {
|
|
96
96
|
const h = (anchor as any)._h
|
|
97
97
|
if (!h || typeof h.id !== "number" || h.id === 0) return null
|
|
98
98
|
let id = h.id as number
|
|
99
99
|
for (let up = tree().parent(id); up !== 0; up = tree().parent(id)) id = up
|
|
100
100
|
const el = byId(id) as any
|
|
101
|
-
return el && typeof el.
|
|
101
|
+
return el && typeof el._appendWidgets === "function" && (el.type === "screen" || el.type === "layer") ? el : null
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
const makePopover = (style: any, children: any) => new PopoverElement(style, children)
|
package/src/ui/UIScreen.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { TouchStartEvent } from "../runtime/touch"
|
|
2
2
|
import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
|
|
3
3
|
import { _transitionId, TRANSITION_NONE } from "./transitions"
|
|
4
|
-
import {
|
|
4
|
+
import { createBinding, disposeBinding } from "../core/signals"
|
|
5
|
+
import { ContainerElement, acceptWidget, buildUI, type BaseStyle, type ContainerStyle, type DrawableStyle, type PaddingStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, } from "./UINode"
|
|
6
|
+
import { _makeWidgetSet, type UIWidgetContent, type WidgetHost, type WidgetSet } from "./UILayer"
|
|
7
|
+
import type { UIWidget } from "./UIWidget"
|
|
5
8
|
import { TREE_DEST_SCREEN, TREE_FLAG_BACK, TREE_FLAG_INTERACTIVE, TREE_FLAG_KEEP_ALIVE, pin, tree } from "./tree"
|
|
6
9
|
import { autofocusIn } from "./autofocus"
|
|
7
10
|
|
|
@@ -57,16 +60,61 @@ export interface UIScreen extends UIContainerBase<UIScreenStyle, DrawableStyle &
|
|
|
57
60
|
|
|
58
61
|
/** Touch began on the screen; `ev.track(...)` takes over the rest of the gesture. */
|
|
59
62
|
onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
|
|
63
|
+
|
|
64
|
+
/** The screen's widgets — a dialog, a sheet, a toast: `UIScreen(content).widgets(dialog)` sets
|
|
65
|
+
* them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Never among
|
|
66
|
+
* `children`: that is the content. A widget is laid out and painted over the whole screen (on a
|
|
67
|
+
* pager page: over the pager too) and shows with it unless its style says `display: "none"`. */
|
|
68
|
+
readonly widgets: WidgetSet<this>,
|
|
60
69
|
/** @internal The destination tuple (Presentable). */
|
|
61
70
|
_dest(): DestTuple
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
export class ScreenElement extends ContainerElement<"screen"> {
|
|
73
|
+
export class ScreenElement extends ContainerElement<"screen"> implements WidgetHost {
|
|
65
74
|
// The per-type default: a screen's background is the theme's `bg` (no theme = none, the host's).
|
|
66
75
|
constructor(type: "screen", style: any, children: any) {
|
|
67
76
|
super(type, { bgColor: "var(--bg)", ...style }, children)
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
// ---- content vs widgets: ONE list in the runtime (the content, then the widget tail), two faces here
|
|
80
|
+
/** The content — the children read from the runtime without the widget tail. */
|
|
81
|
+
override get children(): any[] {
|
|
82
|
+
return super.children.filter((c: any) => c.type !== "widget")
|
|
83
|
+
}
|
|
84
|
+
private _widgetSet?: WidgetSet<this>
|
|
85
|
+
get widgets(): WidgetSet<this> {
|
|
86
|
+
if (!this._widgetSet) this._widgetSet = _makeWidgetSet(this, () => this)
|
|
87
|
+
return this._widgetSet
|
|
88
|
+
}
|
|
89
|
+
/** @internal */
|
|
90
|
+
_widgetList(): UIWidget[] {
|
|
91
|
+
return super.children.filter((c: any) => c.type === "widget") as UIWidget[]
|
|
92
|
+
}
|
|
93
|
+
/** @internal The widget tail; an index past every child lands at its end (tree.cpp). */
|
|
94
|
+
_appendWidgets(widgets: UINodeChild[]): void {
|
|
95
|
+
if (this._h.id === 0) return
|
|
96
|
+
for (const w of widgets) if (acceptWidget(this, w)) this._insertAt(tree().children(this._h.id).length, w)
|
|
97
|
+
}
|
|
98
|
+
/** @internal */
|
|
99
|
+
_removeWidgets(widgets: UINodeChild[]): void { this.remove(...widgets) }
|
|
100
|
+
/** @internal `widgets(...)`: the list replaced, or a reactive function bound as the content's is. */
|
|
101
|
+
_setWidgets(content: UIWidgetContent): void {
|
|
102
|
+
if (typeof content === "function") {
|
|
103
|
+
createBinding(this, "#widgets", () => {
|
|
104
|
+
const next = content()
|
|
105
|
+
const target: any[] = []
|
|
106
|
+
for (const w of (Array.isArray(next) ? next : [])) if (acceptWidget(this, w)) target.push(w)
|
|
107
|
+
// a widget's index is counted from the first widget: offset by the content (treeInsert)
|
|
108
|
+
this._reconcileList(target, () => this._widgetList(), (i, node) => this._insertAt(this.children.length + i, node))
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
disposeBinding(this, "#widgets")
|
|
113
|
+
const current = this._widgetList()
|
|
114
|
+
if (current.length > 0) this.remove(...current)
|
|
115
|
+
this._appendWidgets(content)
|
|
116
|
+
}
|
|
117
|
+
|
|
70
118
|
/** @internal */
|
|
71
119
|
_dest(): DestTuple { return [TREE_DEST_SCREEN, this._h.id, "", ""] }
|
|
72
120
|
|
package/src/ui/UIVideo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VideoPlayer } from "../runtime/media"
|
|
2
|
-
import {
|
|
2
|
+
import { _surfaceWidgets, type LayerElement, type WidgetSet } from "./UILayer"
|
|
3
3
|
import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
|
|
4
4
|
import { _transitionId, TRANSITION_NONE } from "./transitions"
|
|
5
5
|
import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
|
|
@@ -25,8 +25,8 @@ export interface UIVideo extends UIElementBase<UIVideoStyle, DrawableStyle & Bas
|
|
|
25
25
|
onClose(callback: () => void): this
|
|
26
26
|
/** Hardware/system back while current. */
|
|
27
27
|
onBack(callback: () => void): this
|
|
28
|
-
/** The
|
|
29
|
-
|
|
28
|
+
/** The widgets over the video when it is presented (`Presentable.widgets`): `video.widgets(controls)`. */
|
|
29
|
+
readonly widgets: WidgetSet<this>
|
|
30
30
|
/** @internal The destination tuple (Presentable). */
|
|
31
31
|
_dest(): DestTuple
|
|
32
32
|
}
|
|
@@ -50,13 +50,15 @@ class VideoElement extends Element<"video"> implements Presentable {
|
|
|
50
50
|
|
|
51
51
|
/** @internal */
|
|
52
52
|
_dest(): DestTuple { return [TREE_DEST_VIDEO, this._h.id, "", ""] }
|
|
53
|
-
/** @internal The video's
|
|
53
|
+
/** @internal The video's widget layer (ui/UILayer.ts), made on the first use of `widgets`. */
|
|
54
54
|
_contentLayer?: LayerElement
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
/** @internal */
|
|
56
|
+
_widgetSet?: WidgetSet<any>
|
|
57
|
+
/** The widgets over the video while it is presented — a HUD, a dialog, a sheet: `video.widgets(hud)`
|
|
58
|
+
* sets them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Laid out
|
|
59
|
+
* over it; touches outside a widget's box reach the video. */
|
|
60
|
+
get widgets(): WidgetSet<this> {
|
|
61
|
+
return _surfaceWidgets(this)
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
open(options?: PresentOptions): void {
|