lecodes-sdk 2.0.8 → 2.0.10
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 -0
- package/dist/host.d.ts +6 -2
- package/dist/types/g2/Scene2D.d.ts +4 -0
- package/dist/types/gl/Light.d.ts +31 -7
- package/dist/types/gl/Scene.d.ts +4 -0
- package/dist/types/inject.d.ts +1 -1
- package/dist/types/runtime/misc.d.ts +4 -0
- package/dist/types/ui/NativeView.d.ts +3 -0
- package/dist/types/ui/UI.d.ts +1 -0
- package/dist/types/ui/UILayer.d.ts +25 -0
- package/dist/types/ui/UIModal.d.ts +20 -16
- package/dist/types/ui/UIPopover.d.ts +5 -3
- package/dist/types/ui/UIVideo.d.ts +3 -0
- package/dist/types/ui/UIWidget.d.ts +25 -24
- package/dist/types/ui/presentable.d.ts +10 -0
- package/dist/types/ui/transitions.d.ts +2 -1
- package/dist/types/ui/tree.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/3d-scene-files.md +3 -3
- package/prompts/3d-scene.md +1 -1
- package/prompts/core.md +1 -1
- package/prompts/dist/3d-app.md +4 -4
- package/prompts/ui.md +3 -7
- package/src/bridges/gl.d.ts +5 -0
- package/src/bridges/tree.d.ts +24 -12
- package/src/chisel.ts +1 -1
- package/src/compile/assetMacro.ts +69 -4
- package/src/compile/bundler.ts +10 -3
- package/src/g2/Scene2D.ts +10 -0
- package/src/gl/Light.ts +257 -194
- package/src/gl/Material.ts +3 -0
- package/src/gl/Scene.ts +11 -1
- package/src/host.d.ts +6 -2
- package/src/inject.ts +1 -0
- package/src/runtime/misc.ts +4 -0
- package/src/ui/NativeView.ts +11 -0
- package/src/ui/UI.ts +1 -0
- package/src/ui/UIBottomSheet.ts +8 -7
- package/src/ui/UILayer.ts +88 -0
- package/src/ui/UIModal.ts +59 -43
- package/src/ui/UINode.ts +9 -1
- package/src/ui/UIPopover.ts +23 -9
- package/src/ui/UIVideo.ts +11 -0
- package/src/ui/UIWidget.ts +70 -44
- package/src/ui/presentable.ts +11 -1
- package/src/ui/transitions.ts +36 -7
- package/src/ui/tree.ts +4 -0
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +7 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The LAYER roots widgets live in when they belong to no screen (docs/plans/widgets-plan.md):
|
|
2
|
+
//
|
|
3
|
+
// · the APP layer (`UIOverlay`): above every destination, always shown — the home of what belongs
|
|
4
|
+
// to the app rather than to a place in it: a global loader, a mini-player, a toast with actions.
|
|
5
|
+
// `widget.show()` on a widget that has no parent appends it here first.
|
|
6
|
+
// · a SURFACE's content layer: a `Scene`, a `Scene2D`, a `NativeView` or a `UIVideo` cannot take
|
|
7
|
+
// children, so its widgets (a HUD, a card over a map) go in a layer of its own, shown over it
|
|
8
|
+
// while it is presented — `scene.setContent([hud])` fills it.
|
|
9
|
+
//
|
|
10
|
+
// 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. The
|
|
12
|
+
// runtime learns which destination a layer belongs to through `_creatorTree.setLayer`.
|
|
13
|
+
import { ContainerElement, type UINodeChild, type ChildrenFn } from "./UINode"
|
|
14
|
+
import type { UIWidget } from "./UIWidget"
|
|
15
|
+
import type { DestTuple } from "./presentable"
|
|
16
|
+
import { TREE_DEST_NONE, pin, tree } from "./tree"
|
|
17
|
+
|
|
18
|
+
export type UIWidgetContent = (UIWidget | null | undefined | false)[] | (() => (UIWidget | null | undefined | false)[])
|
|
19
|
+
|
|
20
|
+
/** @internal A layer root: a container whose children are widgets. */
|
|
21
|
+
export class LayerElement extends ContainerElement<"layer"> {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("layer", {}, [])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal A surface destination that can hold its content layer. */
|
|
28
|
+
export interface LayerOwner {
|
|
29
|
+
_contentLayer?: LayerElement
|
|
30
|
+
_dest(): DestTuple
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @internal The content layer of a surface destination, made on first use and bound to the
|
|
34
|
+
* destination (`setLayer(kind, id, layer)`); the runtime shows it while the surface is presented. */
|
|
35
|
+
export const _destLayer = (owner: LayerOwner): LayerElement => {
|
|
36
|
+
if (!owner._contentLayer) {
|
|
37
|
+
const layer = new LayerElement()
|
|
38
|
+
const [kind, id] = owner._dest()
|
|
39
|
+
tree().setLayer(kind, id, layer._h.id)
|
|
40
|
+
pin(layer) // the runtime holds it (its owner's content) — findable by id while it does
|
|
41
|
+
owner._contentLayer = layer
|
|
42
|
+
}
|
|
43
|
+
return owner._contentLayer
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @internal `setContent` of a surface: its widgets, as a list or a reactive function. */
|
|
47
|
+
export const _setSurfaceContent = (owner: LayerOwner, content: UIWidgetContent): void => {
|
|
48
|
+
_destLayer(owner).setContent(content as UINodeChild[] | ChildrenFn)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- the app layer ------------------------------------------------------------------------------------
|
|
52
|
+
let appLayer: LayerElement | null = null
|
|
53
|
+
|
|
54
|
+
const layer = (): LayerElement => {
|
|
55
|
+
if (!appLayer) {
|
|
56
|
+
appLayer = new LayerElement()
|
|
57
|
+
tree().setLayer(TREE_DEST_NONE, 0, appLayer._h.id)
|
|
58
|
+
pin(appLayer)
|
|
59
|
+
}
|
|
60
|
+
return appLayer
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @internal Tests: a new fake runtime has no app layer of the previous one. */
|
|
64
|
+
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/UIModal.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type UIWidget, type UIWidgetStyle, WidgetElement } from "./UIWidget"
|
|
2
2
|
import { buildUI, type BaseStyle, type ChildrenFn, type Color, type DrawableStyle, type UIChildArg, type UINodeChild } from "./UINode"
|
|
3
|
+
import type { TransitionPose } from "./presentable"
|
|
4
|
+
import { _widgetPoseId } from "./transitions"
|
|
5
|
+
import { tree } from "./tree"
|
|
3
6
|
|
|
4
7
|
export type UIModalStyle = UIWidgetStyle
|
|
5
8
|
|
|
@@ -16,17 +19,20 @@ const MODAL_ANIM_MS = 200
|
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
|
|
19
|
-
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()`
|
|
20
|
-
* (a 200 ms fade by default — swap the pose via
|
|
21
|
-
* `
|
|
22
|
-
* or the Android back button (disable via `dismissible(false)`). Everything else
|
|
23
|
-
* widget
|
|
24
|
-
*
|
|
22
|
+
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()` and on
|
|
23
|
+
* a state block flipping its `display` (a 200 ms fade by default — swap the pose via
|
|
24
|
+
* `transition()`; the runtime plays it as tracks that never touch the style), and closes itself
|
|
25
|
+
* on a scrim tap or the Android back button (disable via `dismissible(false)`). Everything else
|
|
26
|
+
* is a plain widget: declare it where it belongs — among its screen's children, hidden until a
|
|
27
|
+
* tap (`display: "none"`), or shown with `show()` from anywhere, which puts a parentless modal in
|
|
28
|
+
* the app layer. A dialog bound to data (its display from a binding) is declared
|
|
29
|
+
* `dismissible(false)` and routes `onOverlayTap` / `onBack` into the data's dismiss, so the data
|
|
30
|
+
* stays what the screen shows.
|
|
25
31
|
*/
|
|
26
32
|
export interface UIModal extends UIWidget {
|
|
27
|
-
/**
|
|
33
|
+
/** Show and play the entrance transition. No-op while already open. */
|
|
28
34
|
show(): void,
|
|
29
|
-
/** Play the exit transition, then
|
|
35
|
+
/** Play the exit transition, then hide. No-op while closed or already closing. */
|
|
30
36
|
hide(): void,
|
|
31
37
|
/** True from `show()` until `hide()` starts (already false during the exit animation). */
|
|
32
38
|
readonly isOpen: boolean,
|
|
@@ -43,73 +49,82 @@ export interface UIModal extends UIWidget {
|
|
|
43
49
|
*/
|
|
44
50
|
transition(hidden: UIModalTransition): this,
|
|
45
51
|
|
|
46
|
-
/** Called when `show()
|
|
52
|
+
/** Called when the modal opens — `show()`, or a state block flipping its display. */
|
|
47
53
|
onOpen(callback: () => void): this,
|
|
48
|
-
/** Called when the modal starts closing — scrim tap, back button,
|
|
54
|
+
/** Called when the modal starts closing — scrim tap, back button, a `hide()` call, or a state
|
|
55
|
+
* block flipping its display. */
|
|
49
56
|
onClose(callback: () => void): this,
|
|
50
57
|
/** `dismissible(false)` keeps scrim taps and the back button from closing the modal
|
|
51
|
-
* (forced-choice dialogs). Default `true`. */
|
|
58
|
+
* (forced-choice dialogs, a dialog whose display is bound to data). Default `true`. */
|
|
52
59
|
dismissible(enabled: boolean): this,
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export class ModalElement extends WidgetElement {
|
|
56
|
-
_open = false
|
|
57
63
|
_dismissible = true
|
|
58
64
|
_transition: UIModalTransition = { opacity: 0 }
|
|
59
|
-
_closeTimer?: ReturnType<typeof setTimeout>
|
|
60
65
|
readonly _openListeners: (() => void)[] = []
|
|
61
66
|
readonly _closeListeners: (() => void)[] = []
|
|
67
|
+
private _poseObj?: TransitionPose
|
|
68
|
+
private _poseKey = ""
|
|
62
69
|
|
|
63
70
|
constructor(style: UIModalStyle, children: UINodeChild[] | ChildrenFn) {
|
|
64
71
|
super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", ...style }, children)
|
|
65
72
|
this.onOverlayTap(() => { if (this._dismissible) this.hide() })
|
|
66
73
|
this.onBack(() => { if (this._dismissible) this.hide() })
|
|
74
|
+
this._syncPose()
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
// The
|
|
70
|
-
// modal actually has a scrim (overlayColor: null opts out;
|
|
71
|
-
// create the intercepting layer) and the pose doesn't drive the scrim itself.
|
|
72
|
-
private _pose(
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
// The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
|
|
78
|
+
// fade — only when the modal actually has a scrim (overlayColor: null opts out; a track on a
|
|
79
|
+
// null scrim would create the intercepting layer) and the pose doesn't drive the scrim itself.
|
|
80
|
+
private _pose(): TransitionPose {
|
|
81
|
+
const t = this._transition
|
|
82
|
+
const pose: TransitionPose = { duration: t.duration ?? MODAL_ANIM_MS }
|
|
83
|
+
if (t.transform !== undefined) pose.transform = t.transform as string
|
|
84
|
+
if (t.opacity !== undefined) pose.opacity = t.opacity as number
|
|
85
|
+
const scrim = t.overlayColor !== undefined ? t.overlayColor : (this._style as UIModalStyle).overlayColor
|
|
86
|
+
if (t.overlayColor !== undefined) { if (t.overlayColor !== null) pose.overlayColor = String(t.overlayColor) }
|
|
87
|
+
else if (scrim !== undefined && scrim !== null) pose.overlayColor = "transparent"
|
|
78
88
|
return pose
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
/** @internal Register the pose with the runtime when it changed (ctor, transition(), a show /
|
|
92
|
+
* hide — the scrim may have been restyled since). */
|
|
93
|
+
_syncPose(): void {
|
|
94
|
+
if (this._h.id === 0) return
|
|
95
|
+
const pose = this._pose()
|
|
96
|
+
const key = JSON.stringify(pose)
|
|
97
|
+
if (key === this._poseKey) return
|
|
98
|
+
this._poseKey = key
|
|
99
|
+
this._poseObj = pose
|
|
100
|
+
tree().setWidgetPose(this._h.id, _widgetPoseId(pose))
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
transition(hidden: UIModalTransition): this {
|
|
86
104
|
this._transition = hidden
|
|
105
|
+
this._syncPose()
|
|
87
106
|
return this
|
|
88
107
|
}
|
|
89
108
|
|
|
90
|
-
show(): void {
|
|
91
|
-
if (this.
|
|
92
|
-
|
|
93
|
-
clearTimeout(this._closeTimer)
|
|
94
|
-
this._closeTimer = undefined
|
|
95
|
-
super.hide()
|
|
96
|
-
}
|
|
97
|
-
this._open = true
|
|
109
|
+
override show(): void {
|
|
110
|
+
if (this._shown) return
|
|
111
|
+
this._syncPose()
|
|
98
112
|
super.show()
|
|
99
|
-
this.animateFrom(this._pose())
|
|
100
|
-
for (const callback of this._openListeners) callback()
|
|
101
113
|
}
|
|
102
114
|
|
|
103
|
-
hide(): void {
|
|
104
|
-
if (!this.
|
|
105
|
-
this.
|
|
106
|
-
|
|
107
|
-
this._closeTimer = setTimeout(() => { this._closeTimer = undefined; super.hide() }, this._duration)
|
|
108
|
-
for (const callback of this._closeListeners) callback()
|
|
115
|
+
override hide(): void {
|
|
116
|
+
if (!this._shown) return
|
|
117
|
+
this._syncPose()
|
|
118
|
+
super.hide()
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
get isOpen(): boolean {
|
|
112
|
-
return this.
|
|
122
|
+
return this._shown
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @internal onOpen / onClose follow the intent, whoever flipped it. */
|
|
126
|
+
override _shownChanged(shown: boolean): void {
|
|
127
|
+
for (const callback of (shown ? this._openListeners : this._closeListeners).slice()) callback()
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
onOpen(callback: () => void): this {
|
|
@@ -128,8 +143,9 @@ export class ModalElement extends WidgetElement {
|
|
|
128
143
|
|
|
129
144
|
const makeModal = (style: any, children: any) => new ModalElement(style, children)
|
|
130
145
|
|
|
131
|
-
/** Create a modal dialog (see {@link UIModal})
|
|
132
|
-
* `show()
|
|
146
|
+
/** Create a modal dialog (see {@link UIModal}): declare it among its screen's children with
|
|
147
|
+
* `display: "none"` and `show()` it from a tap, or `show()` a parentless one from anywhere.
|
|
148
|
+
* Same argument forms as `UIColumn`. */
|
|
133
149
|
export function UIModal(...children: UIChildArg[]): UIModal;
|
|
134
150
|
export function UIModal(children: UINodeChild[] | ChildrenFn): UIModal;
|
|
135
151
|
export function UIModal(...args: any[]): UIModal {
|
package/src/ui/UINode.ts
CHANGED
|
@@ -793,7 +793,15 @@ export function buildUI<R>(args: any[], defaults: any, make: (style: any, childr
|
|
|
793
793
|
* names the usual cause (both come from code written for the older SDK). */
|
|
794
794
|
const acceptChild = (parent: { type: string }, node: any): boolean => {
|
|
795
795
|
if (!node) return false
|
|
796
|
-
if (node instanceof Element)
|
|
796
|
+
if (node instanceof Element) {
|
|
797
|
+
// A widget belongs to a WINDOW, never to a box (docs/plans/widgets-plan.md decision 9): a
|
|
798
|
+
// child of a screen root, a surface's content or the app layer only. The runtime refuses it
|
|
799
|
+
// too; this says what to write instead.
|
|
800
|
+
if (node.type === "widget" && parent.type !== "screen" && parent.type !== "layer") {
|
|
801
|
+
throw new Error(`${parent.type}: a widget cannot be a child of a ${parent.type}. A widget (UIWidget / UIModal / UIBottomSheet / UIPopover) belongs to the window: put it among the screen's children — UIScreen(content, dialog) — or in scene.setContent([...]) / UIOverlay. A floating element of a box is an absolute child: UIBox(...).style({ position: "absolute", ... }), not a widget.`)
|
|
802
|
+
}
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
797
805
|
const hint =
|
|
798
806
|
typeof node === "function" ? "reactive children are the factory's only argument: UIColumn(() => [ ... ])"
|
|
799
807
|
// by its backing fields: in a bundle chisel drops the accessors nobody reads (`finished`)
|
package/src/ui/UIPopover.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ModalElement, type UIModal, type UIModalStyle } from "./UIModal"
|
|
2
|
-
import { Presentable } from "./presentable"
|
|
3
2
|
import { device } from "../runtime/device"
|
|
4
3
|
import { buildUI, type BoundingClientRect, type ChildrenFn, type UIChildArg, type UINodeChild } from "./UINode"
|
|
4
|
+
import { byId, tree } from "./tree"
|
|
5
5
|
|
|
6
6
|
export type UIPopoverStyle = UIModalStyle
|
|
7
7
|
|
|
@@ -22,9 +22,11 @@ const VIEWPORT_MARGIN = 8 // minimum distance kept to every screen edge
|
|
|
22
22
|
* tooltips; create it **once** at module scope and reuse it.
|
|
23
23
|
*/
|
|
24
24
|
export interface UIPopover extends UIModal {
|
|
25
|
-
/** Position next to `anchor` (an element or an `{x, y}` point),
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
/** Position next to `anchor` (an element or an `{x, y}` point), put the popover where the
|
|
26
|
+
* anchor is — the anchor's screen, page or surface content, found through the tree, so the
|
|
27
|
+
* menu lands where it was opened and goes with it — and play the entrance transition. A
|
|
28
|
+
* popover with no anchor and no parent goes to the app layer; without an anchor it shows
|
|
29
|
+
* wherever its own style puts it. */
|
|
28
30
|
show(anchor?: UIPopoverAnchor): void,
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -41,7 +43,7 @@ export class PopoverElement extends ModalElement {
|
|
|
41
43
|
this.onLayout(({ width, height }) => this._place(width, height))
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
show(anchor?: UIPopoverAnchor): void {
|
|
46
|
+
override show(anchor?: UIPopoverAnchor): void {
|
|
45
47
|
if (anchor) {
|
|
46
48
|
const r = "getBoundingClientRect" in anchor
|
|
47
49
|
? anchor.getBoundingClientRect()
|
|
@@ -54,18 +56,18 @@ export class PopoverElement extends ModalElement {
|
|
|
54
56
|
// refines it once the size is known.
|
|
55
57
|
this.style({ left: r.left, top: r.bottom + ANCHOR_GAP })
|
|
56
58
|
}
|
|
59
|
+
// A menu belongs where it opened: the anchor's root — a screen, a page, a surface's layer.
|
|
60
|
+
const root = anchorRoot(anchor)
|
|
61
|
+
if (root && this.parent !== root) root.append(this)
|
|
57
62
|
} else {
|
|
58
63
|
this._anchorRect = undefined // unanchored show: the user's own style owns the position
|
|
59
64
|
}
|
|
60
|
-
// A menu belongs to the page it opened on: it hides with it and rides its transition. An
|
|
61
|
-
// explicit attachTo() owner (e.g. a scene HUD's popover) is respected.
|
|
62
|
-
if (!this._owner) this.attachTo(Presentable.current)
|
|
63
65
|
super.show()
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
private _place(width: number, height: number): void {
|
|
67
69
|
const anchor = this._anchorRect
|
|
68
|
-
if (!anchor || !this.
|
|
70
|
+
if (!anchor || !this._shown) return
|
|
69
71
|
const displayWidth = device.width, displayHeight = device.height // 0 on hosts without a size
|
|
70
72
|
let left = anchor.left
|
|
71
73
|
let top = anchor.bottom + ANCHOR_GAP
|
|
@@ -87,6 +89,18 @@ export class PopoverElement extends ModalElement {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
// The root an anchor element sits in, when it is a place a widget can belong to: a screen or a
|
|
93
|
+
// layer that is ATTACHED (presented, in the router, a pager's page, a shown layer — the pins find
|
|
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): { append(...nodes: any[]): unknown, type: string } | null => {
|
|
96
|
+
const h = (anchor as any)._h
|
|
97
|
+
if (!h || typeof h.id !== "number" || h.id === 0) return null
|
|
98
|
+
let id = h.id as number
|
|
99
|
+
for (let up = tree().parent(id); up !== 0; up = tree().parent(id)) id = up
|
|
100
|
+
const el = byId(id) as any
|
|
101
|
+
return el && typeof el.append === "function" && (el.type === "screen" || el.type === "layer") ? el : null
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
const makePopover = (style: any, children: any) => new PopoverElement(style, children)
|
|
91
105
|
|
|
92
106
|
/** Create an anchored popover (see {@link UIPopover}) — create once at module scope, then
|
package/src/ui/UIVideo.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { VideoPlayer } from "../runtime/media"
|
|
2
|
+
import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "./UILayer"
|
|
2
3
|
import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
|
|
3
4
|
import { _transitionId, TRANSITION_NONE } from "./transitions"
|
|
4
5
|
import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
|
|
@@ -24,6 +25,8 @@ export interface UIVideo extends UIElementBase<UIVideoStyle, DrawableStyle & Bas
|
|
|
24
25
|
onClose(callback: () => void): this
|
|
25
26
|
/** Hardware/system back while current. */
|
|
26
27
|
onBack(callback: () => void): this
|
|
28
|
+
/** The UI over the video when presented: widgets over it (`Presentable.setContent`). */
|
|
29
|
+
setContent(content: UIWidgetContent): this
|
|
27
30
|
/** @internal The destination tuple (Presentable). */
|
|
28
31
|
_dest(): DestTuple
|
|
29
32
|
}
|
|
@@ -47,6 +50,14 @@ class VideoElement extends Element<"video"> implements Presentable {
|
|
|
47
50
|
|
|
48
51
|
/** @internal */
|
|
49
52
|
_dest(): DestTuple { return [TREE_DEST_VIDEO, this._h.id, "", ""] }
|
|
53
|
+
/** @internal The video's content layer (ui/UILayer.ts), made by the first setContent. */
|
|
54
|
+
_contentLayer?: LayerElement
|
|
55
|
+
/** The UI over the video when it is presented full screen — widgets (controls, a title),
|
|
56
|
+
* laid out over it; touches outside a widget's box reach the video. */
|
|
57
|
+
setContent(content: UIWidgetContent): this {
|
|
58
|
+
_setSurfaceContent(this, content)
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
50
61
|
|
|
51
62
|
open(options?: PresentOptions): void {
|
|
52
63
|
if (this._h.id === 0) return
|
package/src/ui/UIWidget.ts
CHANGED
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
import { TouchStartEvent } from "../runtime/touch"
|
|
2
|
-
import { Presentable } from "./presentable"
|
|
3
2
|
import { ContainerElement, buildUI, type BaseStyle, type Color, type ContainerStyle, type DrawableStyle, type PaddingStyle, type PositionStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn } from "./UINode"
|
|
4
|
-
import {
|
|
3
|
+
import { UIOverlay } from "./UILayer"
|
|
4
|
+
import { TREE_FLAG_BACK, TREE_FLAG_INTERACTIVE, TREE_FLAG_OVERLAY_TAP, tree } from "./tree"
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export type UIWidgetStyle = ContainerStyle & DrawableStyle & PaddingStyle & BaseStyle & PositionStyle & {
|
|
8
|
+
/** Shown (`"flex"`, the default) or not: what `show()` / `hide()` write, what a state block may
|
|
9
|
+
* flip (`$open: { display: "flex" }`). */
|
|
10
|
+
display?: "none" | "flex",
|
|
8
11
|
/** A layer behind the widget that intercepts clicks — `"transparent"` still intercepts, `null`
|
|
9
12
|
* removes the layer. A tap on it fires `onOverlayTap`. */
|
|
10
13
|
overlayColor?: Color | null
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
|
|
14
|
-
/** A floating overlay
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
/** A floating overlay that belongs to a WINDOW, not to a box (docs/plans/widgets-plan.md): a dialog,
|
|
18
|
+
* a sheet, a HUD, a mini-player. A widget is a CHILD of the place it belongs to — a `UIScreen`
|
|
19
|
+
* (among its children), a surface's content (`scene.setContent([hud])`) or the app layer
|
|
20
|
+
* (`UIOverlay`) — and is laid out and painted in the box of the outermost presented destination
|
|
21
|
+
* that contains it: absolutely positioned in that box, above everything else in it. A widget on a
|
|
22
|
+
* tab page covers the tab bar and rides the shell's push and pop. A widget inside a `UIColumn` or
|
|
23
|
+
* any other box is an error: a floating element of a box is an absolute child, not a widget.
|
|
24
|
+
*
|
|
25
|
+
* It shows with its place unless its style says `display: "none"`; `show()` / `hide()` flip that,
|
|
26
|
+
* and so does a state block (`$class: { display: "flex" }`). `UIModal` / `UIPopover` /
|
|
27
|
+
* `UIBottomSheet` build on it. */
|
|
17
28
|
export interface UIWidget extends UIContainerBase<UIWidgetStyle, DrawableStyle & BaseStyle & { overlayColor?: Color | null }> {
|
|
18
29
|
readonly type: "widget",
|
|
19
30
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (= back to a global overlay). Set it while the widget is hidden. The widget is always its own
|
|
25
|
-
* overlay root mounted into the owner's page view — it never becomes a child of the owner, so it
|
|
26
|
-
* stays topmost and its updates never trigger the owner's relayout. Works with a `UIScreen` that
|
|
27
|
-
* is a page inside a `UIPager`: the widget mounts into that tab and rides its swipe.
|
|
28
|
-
*/
|
|
29
|
-
attachTo(owner: Presentable | null): this,
|
|
30
|
-
|
|
31
|
-
/** Show the widget. By default it is a global overlay above every destination — it stays
|
|
32
|
-
* visible across navigation until `hide()` (dialogs, mini-player, global loader). If the
|
|
33
|
-
* widget was attached via `attachTo(owner)`, it mounts on that page's layer instead and is
|
|
34
|
-
* only visible while the owner is presented. */
|
|
31
|
+
/** Show the widget: `display: "flex"`, on top of its root — it moves to the end of its parent's
|
|
32
|
+
* children, so a dialog opened later covers an earlier one and a HUD declared after it. A widget
|
|
33
|
+
* with no parent is appended to the app layer first (`UIOverlay`) — a global overlay above every
|
|
34
|
+
* destination, up until `hide()`. */
|
|
35
35
|
show(): void,
|
|
36
|
-
/**
|
|
36
|
+
/** Hide the widget: `display: "none"`. It stays where it is (its place, the app layer), ready
|
|
37
|
+
* for the next `show()`. */
|
|
37
38
|
hide(): void,
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/** Shown right now by its own display (`show()` ran, `hide()` hasn't, no state block hides
|
|
41
|
+
* it). True for a widget hidden WITH its page or its covered screen: that is its place's
|
|
42
|
+
* visibility, not the widget's. */
|
|
40
43
|
readonly isShown: boolean,
|
|
41
44
|
|
|
42
45
|
/** Touch began on the widget; `ev.track(...)` takes over the rest of the gesture. */
|
|
@@ -53,41 +56,63 @@ export interface UIWidget extends UIContainerBase<UIWidgetStyle, DrawableStyle &
|
|
|
53
56
|
|
|
54
57
|
export class WidgetElement extends ContainerElement<"widget"> {
|
|
55
58
|
scrollable = false as const
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
/** @internal The widget's own display as last known, while it has a place: what the app wrote,
|
|
60
|
+
* corrected by the runtime's report when a state block flipped it (TREE_EVENT_WIDGET_VISIBLE).
|
|
61
|
+
* False while the widget is nowhere (made, not placed; removed). */
|
|
62
|
+
_shown = false
|
|
58
63
|
constructor(type: "widget", style: UIWidgetStyle, children: UINodeChild[] | ChildrenFn) {
|
|
59
|
-
// Widgets are absolutely positioned in
|
|
64
|
+
// Widgets are absolutely positioned in their root's box — in the constructor (not the
|
|
60
65
|
// factory) so subclasses like ModalElement inherit it.
|
|
61
66
|
super(type, { position: "absolute", ...style }, children)
|
|
62
67
|
}
|
|
63
|
-
attachTo(owner: Presentable | null): this {
|
|
64
|
-
this._owner = owner ?? undefined
|
|
65
|
-
return this
|
|
66
|
-
}
|
|
67
68
|
show(): void {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
tree().showWidget(this._h.id, kind, id)
|
|
69
|
+
if (this._h.id === 0) return
|
|
70
|
+
const parentId = tree().parent(this._h.id)
|
|
71
|
+
if (parentId !== 0 && this._shown) return
|
|
72
|
+
if (parentId === 0) {
|
|
73
|
+
// No place of its own: the app layer takes it HIDDEN, and the flip below shows it — a show()
|
|
74
|
+
// is a display flip wherever the widget is, so its pose plays (a modal's fade) exactly as on
|
|
75
|
+
// a placed one. Appended shown, it would merely appear: a widget shows with its place, no pose.
|
|
76
|
+
this.style({ display: "none" } as any)
|
|
77
|
+
UIOverlay.append(this as any) // pins it: _pinned reads the display
|
|
78
78
|
} else {
|
|
79
|
-
|
|
79
|
+
// Shown = on top of its root: a show() moves the widget to the TAIL of its parent's children
|
|
80
|
+
// (child order is the one ordering rule), so a modal opened later covers an earlier one and
|
|
81
|
+
// the HUD declared after it — while still hidden, so the move is silent (no onClose / onOpen).
|
|
82
|
+
// A state block flipping the display moves nothing: the order is what the tree says.
|
|
83
|
+
const siblings = tree().children(parentId)
|
|
84
|
+
if (siblings[siblings.length - 1] !== this._h.id) {
|
|
85
|
+
tree().remove(parentId, this._h.id)
|
|
86
|
+
tree().insert(parentId, this._h.id, siblings.length - 1)
|
|
87
|
+
}
|
|
80
88
|
}
|
|
89
|
+
this.style({ display: "flex" } as any)
|
|
90
|
+
this._setShown(true)
|
|
81
91
|
}
|
|
82
92
|
hide(): void {
|
|
83
93
|
if (this._h.id === 0 || !this._shown) return
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
unpin(this._h.id)
|
|
94
|
+
this._setShown(false)
|
|
95
|
+
this.style({ display: "none" } as any)
|
|
87
96
|
}
|
|
88
97
|
get isShown(): boolean {
|
|
89
98
|
return this._shown
|
|
90
99
|
}
|
|
100
|
+
/** @internal The intent changed (the app's call, or the runtime's report of a cascade flip):
|
|
101
|
+
* subclasses hook the change (UIModal's onOpen / onClose). */
|
|
102
|
+
_setShown(shown: boolean): void {
|
|
103
|
+
if (shown === this._shown) return
|
|
104
|
+
this._shown = shown
|
|
105
|
+
this._shownChanged(shown)
|
|
106
|
+
}
|
|
107
|
+
/** @internal */
|
|
108
|
+
_shownChanged(_shown: boolean): void {}
|
|
109
|
+
/** @internal The runtime: the widget's resolved display flipped — or, at its place, the cascade
|
|
110
|
+
* says otherwise than the style it read in `_pinned` (the runtime reports nothing else there). */
|
|
111
|
+
_emitVisible(shown: boolean): void { this._setShown(shown) }
|
|
112
|
+
/** @internal Placed (inserted under a screen, a layer): shown by its own display from here on. */
|
|
113
|
+
_pinned(): void { this._setShown((this._style as { display?: string }).display !== "none") }
|
|
114
|
+
/** @internal Taken out of its place: shown nowhere. */
|
|
115
|
+
_unpinned(): void { this._setShown(false) }
|
|
91
116
|
|
|
92
117
|
readonly touchStartListeners: any[] = []
|
|
93
118
|
onTouchStart (callback: any): this {
|
|
@@ -138,7 +163,8 @@ const makeWidget = (style: any, children: any) => new WidgetElement("widget", st
|
|
|
138
163
|
/** @internal Compiler fast path (chisel `flatten_ui` raw lowering) — see UIContainer. */
|
|
139
164
|
export const __UIWidget = (children: UINodeChild[] | ChildrenFn): UIWidget => makeWidget({}, children)
|
|
140
165
|
|
|
141
|
-
/** Create a widget (same argument forms as `UIColumn`).
|
|
166
|
+
/** Create a widget (same argument forms as `UIColumn`). Put it where it belongs — among a screen's
|
|
167
|
+
* children, in `scene.setContent([...])`, or `UIOverlay` — position it with absolute-style props
|
|
142
168
|
* (`top`/`left`/`bottom`/`right`), then `show()`/`hide()`. */
|
|
143
169
|
export function UIWidget(...children: UIChildArg[]): UIWidget;
|
|
144
170
|
export function UIWidget(children: UINodeChild[] | ChildrenFn): UIWidget;
|
package/src/ui/presentable.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Anything that can occupy the app's main slot implements it: UIScreen, Scene (+ARScene),
|
|
3
3
|
// Scene2D, NativeView, UIVideo. Exactly one Presentable is visible at a time — a screen no
|
|
4
4
|
// longer overlays a scene, it replaces it (with a transition); UI over a scene goes through
|
|
5
|
-
// `
|
|
5
|
+
// `scene.setContent([hud])` — a widget is the destination's content (docs/plans/widgets-plan.md).
|
|
6
6
|
//
|
|
7
7
|
// A destination is addressed by (kind, id): a screen / video by its node id, a scene by its
|
|
8
8
|
// scene id, a native view by its viewId (`_dest()`). The runtime reports the lifecycle back through
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// element code into engine-only bundles.
|
|
13
13
|
|
|
14
14
|
import type { EasingInput } from "../animate/tween/easing"
|
|
15
|
+
import type { UIWidget } from "./UIWidget"
|
|
15
16
|
|
|
16
17
|
/** The built-in transitions (ui/transitions.ts holds what each one is). `push` / `pop` are the
|
|
17
18
|
* stacked-navigation pair — the incoming screen slides over the other one, which drifts and dims;
|
|
@@ -32,6 +33,9 @@ export type TransitionPose = {
|
|
|
32
33
|
opacity?: number | number[]
|
|
33
34
|
/** Black over this screen, 0..1: how dark the screen UNDER the other one gets. */
|
|
34
35
|
dim?: number | number[]
|
|
36
|
+
/** A widget's overlay layer (its scrim) in the pose — a widget's enter / exit pose only
|
|
37
|
+
* (`UIModal.transition`): `"transparent"` fades the scrim in with the dialog. */
|
|
38
|
+
overlayColor?: string | string[]
|
|
35
39
|
/** ms (default 300). */
|
|
36
40
|
duration?: number
|
|
37
41
|
/** ms to wait; the screen holds its first pose through it. */
|
|
@@ -97,6 +101,12 @@ export interface Presentable {
|
|
|
97
101
|
onOpen(callback: () => void): this
|
|
98
102
|
onClose(callback: () => void): this
|
|
99
103
|
onBack(callback: () => void): this
|
|
104
|
+
/** The destination's widgets (docs/plans/widgets-plan.md): a dialog, a HUD, a sheet declared as
|
|
105
|
+
* CONTENT of the place they belong to. A `UIScreen` takes them among its children; a surface — a
|
|
106
|
+
* `Scene`, a `Scene2D`, a `NativeView`, a `UIVideo` — takes widgets alone, laid out over it while
|
|
107
|
+
* it is presented (touches outside a widget's box reach the surface). A widget shows with its
|
|
108
|
+
* destination unless its style says `display: "none"`; `show()` / `hide()` flip that. */
|
|
109
|
+
setContent(content: (UIWidget | null | undefined | false)[] | (() => (UIWidget | null | undefined | false)[])): this
|
|
100
110
|
/** @internal Async work that must finish BEFORE this destination replaces the current one
|
|
101
111
|
* (camera permission, warm render, session launch — ARScene). The previous destination —
|
|
102
112
|
* typically a loading screen — stays visible while it runs; `open()` and `Router.push/replace`
|