lecodes-cli 0.7.0 → 0.7.2

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.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +256 -244
  3. package/package.json +4 -4
  4. package/runtime/sdk/compile/bundler.ts +4 -2
  5. package/runtime/sdk/compile/compileProject.ts +6 -1
  6. package/runtime/sdk/g2/Scene2D.ts +51 -5
  7. package/runtime/sdk/gl/Model.ts +9 -0
  8. package/runtime/sdk/gl/Scene.ts +76 -11
  9. package/runtime/sdk/inject.ts +15 -7
  10. package/runtime/sdk/plugins/camera.ts +81 -0
  11. package/runtime/sdk/plugins/geolocation.ts +123 -0
  12. package/runtime/sdk/plugins/permission.ts +7 -0
  13. package/runtime/sdk/plugins/qr.ts +61 -24
  14. package/runtime/sdk/runtime/app.ts +37 -0
  15. package/runtime/sdk/runtime/appEvents.ts +29 -0
  16. package/runtime/sdk/runtime/channel.ts +50 -0
  17. package/runtime/sdk/runtime/clipboard.ts +20 -0
  18. package/runtime/sdk/runtime/datetime.ts +2 -4
  19. package/runtime/sdk/runtime/device.ts +137 -4
  20. package/runtime/sdk/runtime/misc.ts +4 -0
  21. package/runtime/sdk/runtime/service.ts +82 -0
  22. package/runtime/sdk/runtime/touch.ts +26 -0
  23. package/runtime/sdk/ui/NativeView.ts +144 -0
  24. package/runtime/sdk/ui/UI.ts +5 -1
  25. package/runtime/sdk/ui/UIButton.ts +26 -3
  26. package/runtime/sdk/ui/UIInput.ts +2 -2
  27. package/runtime/sdk/ui/UINode.ts +33 -5
  28. package/runtime/sdk/ui/UIScreen.ts +63 -29
  29. package/runtime/sdk/ui/UIScreenHost.ts +3 -2
  30. package/runtime/sdk/ui/UIText.ts +1 -1
  31. package/runtime/sdk/ui/UIVideo.ts +48 -2
  32. package/runtime/sdk/ui/UIWidget.ts +34 -1
  33. package/runtime/sdk/ui/presentable.ts +116 -0
  34. package/runtime/sdk/ui/router.ts +73 -29
  35. package/runtime/sdk-types.json +1 -1
  36. package/runtime/sdk/runtime/camera.ts +0 -31
@@ -0,0 +1,144 @@
1
+ // A host-registered platform view (map, QR scanner, camera preview, …) — the plugin surface of
2
+ // the Presentable model (docs/navigation-presentable-plan.md). The host registers a *capability*
3
+ // (`engine.registerView("map") { params, channel -> MapView(...) }`); when and with what to show
4
+ // it is app code. Dual-natured like every Presentable: a fullscreen destination
5
+ // (`Router.push(map)` / `map.open()`) AND an embeddable layout node (wire type `"native"` — place
6
+ // it among a screen's children, sized by its styles). One instance lives in one place at a time;
7
+ // pushing a mounted instance fullscreen is promotion (the native view reparents, state intact).
8
+ //
9
+ // Plugins ship a typed TS wrapper over the raw `call`/`on` channel — see QRScanner for the
10
+ // wrapper convention.
11
+
12
+ import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
13
+ import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
14
+ import { Element, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style } from "./UINode"
15
+
16
+ export type UINativeViewStyle = ElementStyle & DrawableStyle
17
+
18
+ export interface NativeView {
19
+ readonly type: "native",
20
+ /** The registered view kind this instance resolves to (the `registerView` name). */
21
+ readonly viewName: string,
22
+ /** Creation params, passed to the host factory (JSON-serializable). */
23
+ readonly params: any,
24
+ style: Style<this, UINativeViewStyle>,
25
+ animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
26
+ animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
27
+
28
+ /** Show fullscreen as the current destination (see Presentable). */
29
+ open(options?: PresentOptions): void,
30
+ close(): void,
31
+
32
+ onOpen(callback: () => void): this
33
+ onClose(callback: () => void): this
34
+ onBackPressed(callback: () => void): this
35
+
36
+ /** Invoke a method on the native view (`map.call("setCenter", [lng, lat])`). Args and the
37
+ * result are JSON-serialized. Rejects if the host has no such view/method. */
38
+ call(method: string, ...args: any[]): Promise<any>
39
+ /** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
40
+ on(event: string, callback: (data?: any) => void): this
41
+ off(event: string, callback: (data?: any) => void): this
42
+
43
+ onLayout(onLayout: OnLayoutCallback): this
44
+ setClass(name: string, enabled: boolean): this
45
+ toggleClass(name: string): this
46
+ hasClass(name: string): boolean
47
+ bindClass(name: string, fn: () => boolean): this
48
+ }
49
+
50
+ let nextViewId = 1
51
+
52
+ /** @internal Base for typed plugin wrappers (QRScanner, CameraView — src/plugins/): subclass it,
53
+ * pin the viewName/params, and layer typed methods over the raw `call`/`on` channel. Not a
54
+ * public global — apps use the `NativeView(name)` factory below. */
55
+ export class NativeViewElement extends Element<"native"> implements Presentable {
56
+ readonly viewName: string
57
+ readonly params: any
58
+ /** @internal stable per-instance handle for the viewCall channel + promotion identity. */
59
+ readonly _viewId: number
60
+ /** @internal event listeners by event name — the host invokes these (vlist pcl/psl convention)
61
+ * or calls _emitViewEvent. */
62
+ readonly nvl: ChannelListeners = {}
63
+ readonly ol: (() => void)[] = []
64
+ readonly cl: (() => void)[] = []
65
+ _backButtonCallback?: () => void
66
+ private _vd?: object
67
+
68
+ constructor(viewName: string, params: any) {
69
+ super("native", {})
70
+ this.viewName = viewName
71
+ this.params = params
72
+ this._viewId = nextViewId++
73
+ }
74
+
75
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
76
+ _viewDesc(): object {
77
+ return this._vd ??= { type: "native", viewName: this.viewName, params: this.params, viewId: this._viewId, _p: this }
78
+ }
79
+
80
+ open(options?: PresentOptions): void {
81
+ if (!_navSupported()) {
82
+ throw new Error(`NativeView("${this.viewName}") is not supported on this host (requires _creatorUI.openView)`)
83
+ }
84
+ _bumpNavEpoch()
85
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
86
+ _setCurrent(this)
87
+ }
88
+ close(): void {
89
+ _bumpNavEpoch()
90
+ if (_navSupported()) _creatorUI.closeView!()
91
+ if (Presentable.current === this) _setCurrent(null)
92
+ }
93
+
94
+ onOpen(callback: () => void): this {
95
+ this.ol.push(callback)
96
+ return this
97
+ }
98
+ onClose(callback: () => void): this {
99
+ this.cl.push(callback)
100
+ return this
101
+ }
102
+ onBackPressed(callback: () => void): this {
103
+ this._backButtonCallback = callback
104
+ return this
105
+ }
106
+
107
+ call(method: string, ...args: any[]): Promise<any> {
108
+ return _channelCall(
109
+ typeof _creatorUI !== "undefined" ? _creatorUI.viewCall : undefined,
110
+ `NativeView("${this.viewName}") is not supported on this host (requires _creatorUI.viewCall)`,
111
+ this._viewId, method, args)
112
+ }
113
+
114
+ on(event: string, callback: (data?: any) => void): this {
115
+ _channelOn(this.nvl, event, callback)
116
+ return this
117
+ }
118
+ off(event: string, callback: (data?: any) => void): this {
119
+ _channelOff(this.nvl, event, callback)
120
+ return this
121
+ }
122
+ /** @internal host entry: deliver a native event (payload as a JSON string, or omitted). */
123
+ _emitViewEvent(event: string, dataJson?: string): void {
124
+ _channelEmit(this.nvl, event, dataJson)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create an instance of a host-registered platform view. `NativeView.isSupported(name)` reports
130
+ * whether this host registered a factory for it. Style via the chained `.style()`.
131
+ */
132
+ // PURE IIFE (not a plain function + expando assignment) so an app that never uses NativeView
133
+ // tree-shakes it away — top-level property assignments read as side effects to the bundler.
134
+ export const NativeView: {
135
+ (name: string, params?: object): NativeView
136
+ /** Whether this host registered a `registerView` factory under `name`. */
137
+ isSupported(name: string): boolean
138
+ } = /*#__PURE__*/ (() => {
139
+ const factory = (name: string, params: object = {}): NativeView =>
140
+ new NativeViewElement(name, params) as unknown as NativeView
141
+ factory.isSupported = (name: string): boolean =>
142
+ typeof _creatorUI !== "undefined" && typeof _creatorUI.isViewSupported === "function" && _creatorUI.isViewSupported(name)
143
+ return factory
144
+ })()
@@ -1,7 +1,7 @@
1
1
  // Renderer-side contract + per-renderer status (DOM / canvas-ui / desktop) for every component
2
2
  // below is tracked in packages/canvas-ui/COMPONENTS.md.
3
3
  export { UIButton, type UIButtonStyle } from './UIButton'
4
- export { UIScreen, type UIScreenStyle } from './UIScreen'
4
+ export { UIScreen, type UIScreenStyle, type UIScrollableScreen } from './UIScreen'
5
5
  export { UISpacer, type UISpacerStyle } from './UISpacer'
6
6
  export { UIText, type UITextStyle } from './UIText'
7
7
  export { registerFont } from './fonts'
@@ -18,6 +18,10 @@ export { UIScreenHost, type UIScreenHostStyle } from './UIScreenHost'
18
18
  export { UIVirtualizedList } from './UIVirtualizedList'
19
19
 
20
20
  export { Router } from './router'
21
+ export { NativeView, type UINativeViewStyle } from './NativeView'
22
+ // Presentable is both the interface and its runtime companion (Presentable.current).
23
+ export { Presentable } from './presentable'
24
+ export type { PresentOptions, Transition, TransitionName, TransitionSpec, TransitionTransform } from './presentable'
21
25
 
22
26
  // Internal: memoized map for reactive children — the chisel `reactiveUi` pass rewrites
23
27
  // `list.map(fn)` inside children bindings to `__uiMap(list, fn, slot)` calls.
@@ -1,5 +1,5 @@
1
- import { ClickEvent, TouchStartEvent } from "../runtime/touch"
2
- import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType } from "../runtime/touch"
1
+ import { ClickEvent, TouchStartEvent, LongPressEvent } from "../runtime/touch"
2
+ import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType, LongPressEvent as LongPressEventType } from "../runtime/touch"
3
3
  import { type ElementStyle, type ContainerStyle, type Style, ContainerElement, type DrawableStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
4
4
 
5
5
  export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { rippleColor?: Color | "default" }
@@ -7,12 +7,16 @@ export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { ri
7
7
  export interface UIButton {
8
8
  readonly type: "button",
9
9
 
10
- style: Style<this, UIButtonStyle & { onPressed?: DrawableStyle & BaseStyle & { duration?: number } }>,
10
+ style: Style<this, UIButtonStyle & { onPressed?: UIButtonStyle & { duration?: number } }>,
11
11
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
12
12
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
13
13
 
14
14
  onClick(callback: (ev: ClickEventType<UIButton>) => void): this,
15
15
  onTouchStart(callback: (ev: TouchStartEventType<UIButton>) => void): this,
16
+ /** Fired when a finger is held on the button past the long-press threshold. The event can
17
+ * `ev.track(...)` the rest of the gesture (like `onTouchStart`), so a hold can flow straight into
18
+ * dragging the element. A handled long-press suppresses the click that would follow the release. */
19
+ onLongPress(callback: (ev: LongPressEventType<UIButton>) => void): this,
16
20
  isPressed(): boolean,
17
21
 
18
22
  append(...nodes: UINodeChild[]): this
@@ -35,6 +39,7 @@ class ButtonElement extends ContainerElement<"button"> {
35
39
 
36
40
  readonly clickListeners: any[] = []
37
41
  readonly touchStartListeners: any[] = []
42
+ readonly longPressListeners: any[] = []
38
43
 
39
44
  onClick (callback: any): this {
40
45
  this.clickListeners.push(callback)
@@ -65,6 +70,24 @@ class ButtonElement extends ContainerElement<"button"> {
65
70
  return null
66
71
  }
67
72
 
73
+ onLongPress (callback: any): this {
74
+ this.longPressListeners.push(callback)
75
+ return this
76
+ }
77
+ // Fired by the host when the finger is held past the long-press threshold. Returns null when
78
+ // nothing is listening — the host then lets the trailing click through as usual. Otherwise it
79
+ // returns an object whose presence tells the host to swallow that click, carrying the optional
80
+ // drag handler a listener registered via `ev.track(...)` for the host to drive the gesture.
81
+ _emitLongPress(pointerId: number, clientX: number, clientY: number): any {
82
+ if (this.longPressListeners.length === 0) return null
83
+ const longPressEvent = new LongPressEvent(clientX, clientY, pointerId)
84
+ longPressEvent.target = this
85
+ for (let callback of this.longPressListeners) {
86
+ callback(longPressEvent)
87
+ }
88
+ return { track: (longPressEvent as any)._trackHandler ?? null }
89
+ }
90
+
68
91
  isPressed(): boolean {
69
92
  if (!this._id) return false
70
93
  return _creatorUI.isButtonPressed(this._id)
@@ -8,7 +8,7 @@ export interface UIInput {
8
8
 
9
9
  value: string
10
10
 
11
- style: Style<this, UIInputStyle & { onFocused?: DrawableStyle & BaseStyle & { duration?: number } }>,
11
+ style: Style<this, UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
12
12
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
13
13
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
14
14
 
@@ -28,7 +28,7 @@ export interface UITextArea {
28
28
 
29
29
  value: string
30
30
 
31
- style: Style<this, UIInputStyle & { onFocused?: DrawableStyle & BaseStyle & { duration?: number } }>,
31
+ style: Style<this, UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
32
32
  // styleV: StyleFn<this, UIInputStyle>,
33
33
  // styleH: StyleFn<this, UIInputStyle>,
34
34
 
@@ -245,13 +245,38 @@ export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T
245
245
 
246
246
  // User-defined style classes: any `$`-prefixed key in .style() declares a state block (like
247
247
  // onPressed, but with any name), toggled from code via setClass/toggleClass. `duration`/`delay`
248
- // make the swap transition.
248
+ // make the swap transition. Class state INHERITS down the tree (CSS-`.dark`-on-body style): a
249
+ // class set on a node also activates same-name `$` blocks on all descendants within the same
250
+ // root — see docs/style-class-cascade-plan.md. `$pressed`/`$focused` are reserved: hosts toggle
251
+ // them on press/focus (they beat other classes, lose to onPressed/onFocused), so children can
252
+ // react to an ancestor's press. Instantiate with the node's own style `T` (see StyleFn below) — a
253
+ // class block accepts everything the node's `.style()` does (e.g. `color` on text, layout on any
254
+ // node), exactly like `onLandscape`/`onPortrait`. Narrowing it (it once was `DrawableStyle &
255
+ // BaseStyle`) wrongly rejected valid props such as `$active: { color }`. Guarded by
256
+ // tests/ui-types.test.ts.
249
257
  export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
250
258
 
251
- export type StyleFn <R, T extends object> = ((this: R, style: Reactive<T> & { onLandscape?: T, onPortait?: T } & ClassStyles<DrawableStyle & BaseStyle>) => R)
252
- export type Style <R, T extends object> = StyleFn<R,T> & T & { onLandscape?: T, onPortait?: T } & ClassStyles<DrawableStyle & BaseStyle>
259
+ // NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
260
+ // which makes every node type INVARIANT in its own type params (and in `this`). That is what made
261
+ // `UIScreen<false>` unassignable to `UIScreen<boolean>` / `HostedScreen`, and would block any
262
+ // UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
263
+ // `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
264
+ // R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
265
+ // chaining. Guarded by the type tests in tests/ui-types.test-d.ts.
266
+ // Orientation state blocks — override props applied only in landscape / portrait (like a `$`-class,
267
+ // keyed by device orientation instead of setClass). Same full-`T` rule as class blocks.
268
+ export type OrientationStyles<T> = {
269
+ onLandscape?: T,
270
+ onPortrait?: T,
271
+ /** @deprecated Misspelling of `onPortrait` (no second "r"). Kept so existing projects and published
272
+ * bundles keep working — hosts still honour it — but new code should use `onPortrait`. */
273
+ onPortait?: T,
274
+ }
275
+
276
+ export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
277
+ export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
253
278
 
254
- export type AnimateStyle<R, T extends object> = ((this: R, style: T & { duration?: number, delay?: number, layer?: number, commit?: boolean }) => R)
279
+ export type AnimateStyle<R, T extends object> = ((style: T & { duration?: number, delay?: number, layer?: number, commit?: boolean }) => R)
255
280
 
256
281
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
257
282
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
@@ -321,7 +346,10 @@ export class Element<T extends string> {
321
346
  * re-assign _id on every open). */
322
347
  protected _classes?: Set<string>
323
348
 
324
- /** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`). */
349
+ /** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`).
350
+ * The class cascades: it is also active on every descendant (their same-name `$` blocks light
351
+ * up), until the subtree's root — hosted screens/widgets don't inherit. `hasClass`/`toggleClass`
352
+ * reflect only THIS node's own classes; there is no opt-out below an active ancestor. */
325
353
  setClass(name: string, enabled: boolean): this {
326
354
  if (name[0] === "$") name = name.slice(1)
327
355
  if (enabled) {
@@ -1,35 +1,41 @@
1
1
  import { TouchStartEvent } from "../runtime/touch"
2
+ import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
2
3
  import { ContainerElement, type AnimateStyle, type BaseStyle, type Color, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type PaddingStyle, type ScrollStyle, type Style, type UINodeChild, type ChildrenFn, } from "./UINode"
3
4
 
4
5
  export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & ScrollStyle & BaseStyle & ScrollStyle & { refreshControlColor?: Color }
5
6
 
6
- // Default to `false`: a bare `UIScreen` means the non-scrollable screen the `UIScreen(...)`
7
- // constructor returns, so `const s: UIScreen = UIScreen([...])` type-checks. The type is invariant
8
- // in `Scrollable` (the fluent `style()` puts `this` in a contravariant position), so a `boolean`
9
- // default wouldn't accept the constructor's `UIScreen<false>`. Opt into scrolling via
10
- // `.makeScrollable(): UIScreen<true>`; write `UIScreen<boolean>` explicitly when you need the union.
11
- export interface UIScreen <Scrollable extends boolean = false> {
7
+ // A screen — the root the router/host mounts, sized to its slot. Two flavours share one contract: a
8
+ // plain `UIScreen`, and a `UIScrollableScreen` (a screen that also scrolls). `makeScrollable()`
9
+ // upgrades a plain screen to the scrollable one, where `onScroll`/`onOverscroll`/`onRefresh` live —
10
+ // they mean nothing without scrolling. `UIScrollableScreen extends UIScreen`, so anywhere a
11
+ // `UIScreen` / `HostedScreen` is accepted, a scrollable one works too.
12
+ //
13
+ // This is a subtype split, NOT a `UIScreen<Scrollable>` generic: a generic can't work while `style`
14
+ // is `Style<this,…>`, because `this` is then contravariant and the type becomes invariant in its own
15
+ // params — that was the old `UIScreen<false>`-not-assignable-to-`HostedScreen` bug. See the StyleFn
16
+ // note in UINode.ts; the contract is pinned by tests/ui-types.test.ts.
17
+ export interface UIScreen {
12
18
  readonly type: "screen",
13
- scrollable: Scrollable,
19
+ readonly scrollable: boolean,
14
20
  style: Style<this, UIScreenStyle>,
15
21
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
16
22
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
17
23
 
18
- open(): void,
24
+ /** Show this screen as the current destination (replaces whatever is visible — a screen, a
25
+ * scene, a native view — suspending an active Router until `Router.restore()`). */
26
+ open(options?: PresentOptions): void,
19
27
  close(): void
20
28
 
21
29
  append(...nodes: UINodeChild[]): this
22
30
  insert(index: number, ...nodes: UINodeChild[]): this
23
31
  remove(...nodes: UINodeChild[]): this
24
32
  setContent(nodes: UINodeChild[] | ChildrenFn): this
25
-
33
+
26
34
  readonly children: UINodeChild[],
27
35
 
28
- makeScrollable(): UIScreen<true>,
29
- onScroll: Scrollable extends true? (callback: (scrollPosition: number) => void) => void: never,
30
- onOverscroll: Scrollable extends true? (callback: (scrollPosition: number) => void) => void: never,
36
+ /** Upgrade this screen into a scroll view, unlocking `onScroll`/`onOverscroll`/`onRefresh`. */
37
+ makeScrollable(): UIScrollableScreen,
31
38
  onBackPressed(callback: () => void): this
32
- onRefresh: Scrollable extends true? ((callback: () => void | Promise<void>) => this): never,
33
39
 
34
40
  onOpen(callback: () => void): this
35
41
  onClose(callback: () => void): this
@@ -52,16 +58,46 @@ export interface UIScreen <Scrollable extends boolean = false> {
52
58
  hasClass(name: string): boolean
53
59
  bindClass(name: string, fn: () => boolean): this
54
60
 
55
- onTouchStart(callback: (ev: TouchStartEvent<UIScreen<boolean>>) => void): this,
61
+ onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
62
+ }
63
+
64
+ /**
65
+ * A `UIScreen` in scroll mode — what `makeScrollable()` returns. Adds the scroll lifecycle that a
66
+ * plain screen doesn't carry. A subtype of `UIScreen`, so it's accepted anywhere a screen /
67
+ * `HostedScreen` is (router pages, `UIScreenHost`, `UITabs`).
68
+ */
69
+ export interface UIScrollableScreen extends UIScreen {
70
+ readonly scrollable: true,
71
+ /** Fires as the content scrolls; the offset is logical px from the start edge. Chainable. */
72
+ onScroll(callback: (scrollPosition: number) => void): this,
73
+ /** Fires while the user drags past an edge (rubber-band). Chainable. */
74
+ onOverscroll(callback: (scrollPosition: number) => void): this,
75
+ /** Pull-to-refresh: the spinner stays until the returned promise resolves. Chainable. */
76
+ onRefresh(callback: () => void | Promise<void>): this,
77
+ /** Already scrollable — returns itself. */
78
+ makeScrollable(): UIScrollableScreen,
56
79
  }
57
80
 
58
81
  export class ScreenElement extends ContainerElement<"screen"> {
59
- scrollable = false as const
60
- open(): void {
61
- _creatorUI.openScreen(this)
82
+ scrollable = false
83
+ open(options?: PresentOptions): void {
84
+ _bumpNavEpoch()
85
+ if (_navSupported()) {
86
+ _creatorUI.openView!(this, options?.transition ?? "none")
87
+ } else {
88
+ // Legacy host: no destination navigation — transition is dropped.
89
+ _creatorUI.openScreen(this)
90
+ }
91
+ _setCurrent(this as any)
62
92
  }
63
93
  close(): void {
64
- _creatorUI.closeScreen()
94
+ _bumpNavEpoch()
95
+ if (_navSupported()) {
96
+ _creatorUI.closeView!()
97
+ } else {
98
+ _creatorUI.closeScreen()
99
+ }
100
+ if (Presentable.current === (this as any)) _setCurrent(null)
65
101
  }
66
102
  protected ol: (() => void)[] = []
67
103
  onOpen(callback: () => void): this {
@@ -103,8 +139,8 @@ export class ScreenElement extends ContainerElement<"screen"> {
103
139
  }
104
140
  return null
105
141
  }
106
- makeScrollable(): UIScreen<true> {
107
- this.scrollable = true as any
142
+ makeScrollable(): UIScrollableScreen {
143
+ this.scrollable = true
108
144
 
109
145
  Object.assign(this, {
110
146
  sl: [] as any[],
@@ -123,24 +159,22 @@ export class ScreenElement extends ContainerElement<"screen"> {
123
159
  }
124
160
  })
125
161
 
126
- return this as any as UIScreen<true>
162
+ return this as any as UIScrollableScreen
127
163
  }
128
164
  _backButtonCallback?: () => void
129
165
  onBackPressed(callback: any): this {
130
166
  this._backButtonCallback = callback
131
167
  return this
132
168
  }
169
+ // Set by the onRefresh method that makeScrollable() grafts on; read by the host at mount.
133
170
  declare _refreshCallback: any
134
- declare onRefresh: never
135
- declare onScroll: never
136
- declare onOverscroll: never
137
171
  }
138
172
 
139
- export function UIScreen(): UIScreen<false>;
140
- export function UIScreen(children: UINodeChild[] | ChildrenFn): UIScreen<false>;
141
- export function UIScreen(style: UIScreenStyle): UIScreen<false>;
142
- export function UIScreen(style: UIScreenStyle, children: UINodeChild[]): UIScreen<false>;
143
- export function UIScreen(...args: [] | [UINodeChild[] | ChildrenFn | UIScreenStyle] | [UIScreenStyle, children: UINodeChild[] | ChildrenFn]): UIScreen<false> {
173
+ export function UIScreen(): UIScreen;
174
+ export function UIScreen(children: UINodeChild[] | ChildrenFn): UIScreen;
175
+ export function UIScreen(style: UIScreenStyle): UIScreen;
176
+ export function UIScreen(style: UIScreenStyle, children: UINodeChild[]): UIScreen;
177
+ export function UIScreen(...args: [] | [UINodeChild[] | ChildrenFn | UIScreenStyle] | [UIScreenStyle, children: UINodeChild[] | ChildrenFn]): UIScreen {
144
178
  if (args.length === 0) {
145
179
  return new ScreenElement("screen", {}, [])
146
180
  } else if (args.length === 1) {
@@ -12,8 +12,9 @@ import type { UIScreen } from "./UIScreen"
12
12
  // implements exactly one page kind: mount the screen root in the slot, lay it out to the slot size.
13
13
  // To host a plain element, wrap it: UIScreenHost(UIScreen([el])).
14
14
 
15
- /** A hosted page: any UIScreen, scrollable or not. Sizing styles on it are ignored (screens are roots sized by their host). */
16
- export type HostedScreen = UIScreen<boolean>
15
+ /** A hosted page: any UIScreen (a `UIScrollableScreen` is a subtype, so it's accepted too). Sizing
16
+ * styles on it are ignored — screens are roots sized by their host. */
17
+ export type HostedScreen = UIScreen
17
18
  /** Reactive pages — ChildrenFn narrowed to screens. */
18
19
  export type ScreensFn = () => (HostedScreen | null | undefined | false)[]
19
20
 
@@ -1,7 +1,7 @@
1
1
  import { createBinding } from "../core/signals"
2
2
  import { Element, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type TextStyle } from "./UINode"
3
3
 
4
- export type UITextStyle = ElementStyle & TextStyle
4
+ export type UITextStyle = ElementStyle & TextStyle & DrawableStyle
5
5
 
6
6
  export interface UIText {
7
7
  readonly type: "text",
@@ -1,4 +1,5 @@
1
1
  import type { VideoPlayer } from "../runtime/media"
2
+ import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
2
3
  import { Element, type AnimateStyle, type AppearStyle, type BaseStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type StyleFn } from "./UINode"
3
4
 
4
5
  export type UIVideoStyle = ElementStyle & DrawableStyle & { objectFit?: "cover" | "contain" | "fill" }
@@ -10,6 +11,14 @@ export interface UIVideo {
10
11
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
11
12
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
12
13
 
14
+ /** Show fullscreen as the current destination. Promotion: if this video is mounted in a screen,
15
+ * the SAME native player moves fullscreen — playback uninterrupted; a pop moves it back. */
16
+ open(options?: PresentOptions): void
17
+ close(): void
18
+ onOpen(callback: () => void): this
19
+ onClose(callback: () => void): this
20
+ onBackPressed(callback: () => void): this
21
+
13
22
  onLayout(onLayout: OnLayoutCallback): this
14
23
 
15
24
  setClass(name: string, enabled: boolean): this
@@ -18,7 +27,7 @@ export interface UIVideo {
18
27
  bindClass(name: string, fn: () => boolean): this
19
28
  }
20
29
 
21
- class VideoElement extends Element<"video"> {
30
+ class VideoElement extends Element<"video"> implements Presentable {
22
31
  private _playerId: number
23
32
  private _player: VideoPlayer
24
33
  constructor(style: any, player: VideoPlayer) {
@@ -30,6 +39,43 @@ class VideoElement extends Element<"video"> {
30
39
  get player() {
31
40
  return this._player
32
41
  }
42
+
43
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
44
+ readonly ol: (() => void)[] = []
45
+ readonly cl: (() => void)[] = []
46
+ _backButtonCallback?: () => void
47
+ private _vd?: object
48
+
49
+ /** @internal wire descriptor — type "videoView" (distinct from the "video" node type). */
50
+ _viewDesc(): object {
51
+ return this._vd ??= { type: "videoView", node: this, _p: this }
52
+ }
53
+
54
+ open(options?: PresentOptions): void {
55
+ if (!_navSupported()) {
56
+ throw new Error("Fullscreen video is not supported on this host (requires _creatorUI.openView)")
57
+ }
58
+ _bumpNavEpoch()
59
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
60
+ _setCurrent(this)
61
+ }
62
+ close(): void {
63
+ _bumpNavEpoch()
64
+ if (_navSupported()) _creatorUI.closeView!()
65
+ if (Presentable.current === this) _setCurrent(null)
66
+ }
67
+ onOpen(callback: () => void): this {
68
+ this.ol.push(callback)
69
+ return this
70
+ }
71
+ onClose(callback: () => void): this {
72
+ this.cl.push(callback)
73
+ return this
74
+ }
75
+ onBackPressed(callback: () => void): this {
76
+ this._backButtonCallback = callback
77
+ return this
78
+ }
33
79
  }
34
80
 
35
81
  export function UIVideo(player: VideoPlayer): UIVideo
@@ -40,4 +86,4 @@ export function UIVideo(...args: [VideoPlayer] | [UIVideoStyle, VideoPlayer]): U
40
86
  } else {
41
87
  return new VideoElement(args[0], args[1])
42
88
  }
43
- }
89
+ }
@@ -1,4 +1,5 @@
1
1
  import { TouchStartEvent } from "../runtime/touch"
2
+ import { _descOf, _navSupported, Presentable } from "./presentable"
2
3
  import { ContainerElement, type AnimateStyle, type BaseStyle, type Color, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type PaddingStyle, type PositionStyle, type Style, type UINodeChild, type ChildrenFn } from "./UINode"
3
4
 
4
5
 
@@ -15,7 +16,23 @@ export interface UIWidget {
15
16
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle & { overlayColor?: Color | null }>
16
17
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle & { overlayColor?: Color | null }>
17
18
 
19
+ /**
20
+ * Pre-bind this widget to a destination (a `Scene`, a `UIScreen`, …) that is not current yet —
21
+ * e.g. HUD for an `ARScene` built before opening it, so it's in place from the first frame.
22
+ * `show()` binds to the *current* destination automatically, so this is only needed ahead of
23
+ * time (or to target a non-current owner). Sticky: an explicit owner wins over the automatic
24
+ * one until `attachTo(null)` (= back to automatic). Set it while the widget is hidden.
25
+ */
26
+ attachTo(owner: Presentable | null): this,
27
+
28
+ /** Show the widget, attached to the current destination (or the `attachTo` owner): it mounts
29
+ * inside that page, shows/hides with it and rides its transition — a dialog opened on a screen
30
+ * disappears when the user navigates away. For an overlay that survives navigation use
31
+ * `showDetached()`. */
18
32
  show(): void,
33
+ /** Show the widget as a global overlay above every destination — it stays visible across
34
+ * navigation (mini-player, global loader). */
35
+ showDetached(): void,
19
36
  hide(): void,
20
37
 
21
38
  readonly isShow: boolean,
@@ -41,8 +58,24 @@ export interface UIWidget {
41
58
 
42
59
  export class WidgetElement extends ContainerElement<"widget"> {
43
60
  scrollable = false as const
61
+ _owner?: Presentable
62
+ attachTo(owner: Presentable | null): this {
63
+ this._owner = owner ?? undefined
64
+ return this
65
+ }
44
66
  show(): void {
45
- _creatorUI.showWidget(this)
67
+ // Explicit attachTo() wins; otherwise bind to whatever is on screen right now — re-showing
68
+ // the same widget from another page re-attaches it there.
69
+ const owner = this._owner ?? Presentable.current
70
+ if (owner && _navSupported()) {
71
+ _creatorUI.showWidget(this, _descOf(owner))
72
+ } else {
73
+ // Legacy host (or nothing presented yet): global overlay.
74
+ _creatorUI.showWidget(this)
75
+ }
76
+ }
77
+ showDetached(): void {
78
+ _creatorUI.showWidget(this)
46
79
  }
47
80
  hide(): void {
48
81
  _creatorUI.hideWidget(this)