lecodes-cli 0.7.0 → 0.7.1

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.
@@ -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,33 @@ 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. Instantiate with the node's own style `T` (see StyleFn below) — a class
249
+ // block accepts everything the node's `.style()` does (e.g. `color` on text, layout on any node),
250
+ // exactly like `onLandscape`/`onPortrait`. Narrowing it (it once was `DrawableStyle & BaseStyle`)
251
+ // wrongly rejected valid props such as `$active: { color }`. Guarded by tests/ui-types.test.ts.
249
252
  export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
250
253
 
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>
254
+ // NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
255
+ // which makes every node type INVARIANT in its own type params (and in `this`). That is what made
256
+ // `UIScreen<false>` unassignable to `UIScreen<boolean>` / `HostedScreen`, and would block any
257
+ // UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
258
+ // `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
259
+ // R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
260
+ // chaining. Guarded by the type tests in tests/ui-types.test-d.ts.
261
+ // Orientation state blocks — override props applied only in landscape / portrait (like a `$`-class,
262
+ // keyed by device orientation instead of setClass). Same full-`T` rule as class blocks.
263
+ export type OrientationStyles<T> = {
264
+ onLandscape?: T,
265
+ onPortrait?: T,
266
+ /** @deprecated Misspelling of `onPortrait` (no second "r"). Kept so existing projects and published
267
+ * bundles keep working — hosts still honour it — but new code should use `onPortrait`. */
268
+ onPortait?: T,
269
+ }
270
+
271
+ export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
272
+ export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
253
273
 
254
- export type AnimateStyle<R, T extends object> = ((this: R, style: T & { duration?: number, delay?: number, layer?: number, commit?: boolean }) => R)
274
+ export type AnimateStyle<R, T extends object> = ((style: T & { duration?: number, delay?: number, layer?: number, commit?: boolean }) => R)
255
275
 
256
276
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
257
277
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
@@ -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)
@@ -0,0 +1,116 @@
1
+ // The Presentable contract — the one navigation model (docs/navigation-presentable-plan.md).
2
+ // Anything that can occupy the app's main slot implements it: UIScreen, Scene (+ARScene),
3
+ // Scene2D, NativeView, UIVideo. Exactly one Presentable is visible at a time — a screen no
4
+ // longer overlays a scene, it replaces it (with a transition); UI over a scene goes through
5
+ // `UIWidget.attachTo(scene)`.
6
+ //
7
+ // This module is dependency-free on purpose: gl/ and g2/ import it, so it must not pull any UI
8
+ // element code into engine-only bundles.
9
+
10
+ /** Built-in transition catalog. `push`/`pop` are the stacked-navigation pair (slide + parallax +
11
+ * dim + edge shadow); the rest are simple two-view moves. */
12
+ export type TransitionName =
13
+ | "push" | "pop"
14
+ | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom"
15
+ | "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
16
+
17
+ /** Start/end pose of one side of a custom transition. `x`/`y` are logical px, or a percentage of
18
+ * the destination's size (e.g. `"100%"` = one full width to the right). */
19
+ export type TransitionTransform = {
20
+ x?: number | `${number}%`
21
+ y?: number | `${number}%`
22
+ scale?: number
23
+ opacity?: number
24
+ }
25
+
26
+ /**
27
+ * A custom transition: declarative poses for the incoming/outgoing destination, played natively.
28
+ * Serializes onto each platform's existing transition machinery — there is no per-frame JS.
29
+ * Fields omitted keep the identity pose. Interactive back-swipe stays system-native (push/pop
30
+ * only); a spec applies to non-interactive transitions.
31
+ */
32
+ export interface TransitionSpec {
33
+ /** Duration in ms (default 300). */
34
+ duration?: number
35
+ /** cubic-bezier control points (default ease-out). */
36
+ easing?: [number, number, number, number]
37
+ /** The entering destination animates FROM this pose to identity. */
38
+ incoming?: { from?: TransitionTransform }
39
+ /** The leaving destination animates from identity TO this pose. */
40
+ outgoing?: { to?: TransitionTransform }
41
+ /** Scrim alpha (0..1) painted over whichever destination is underneath. */
42
+ dim?: number
43
+ /** Which side stacks on top during the animation (default "incoming"). On Android, transitions
44
+ * involving a scene always animate the screen side on top — the scene view itself never moves. */
45
+ onTop?: "incoming" | "outgoing"
46
+ }
47
+
48
+ export type Transition = TransitionName | TransitionSpec
49
+
50
+ export type PresentOptions = {
51
+ /** Transition to play while this destination replaces the current one (default "none" for a
52
+ * direct open(); the Router applies its own defaults — push/fade/pop). */
53
+ transition?: Transition
54
+ }
55
+
56
+ /**
57
+ * Anything that can be shown as the app's current destination: a `UIScreen`, a `Scene` /
58
+ * `ARScene`, a `Scene2D`, a `NativeView`, or a `UIVideo`. One Presentable is visible at a time;
59
+ * open it directly (`p.open()` — replaces the current destination, suspending an active Router
60
+ * until `Router.restore()`) or navigate with `Router.push/replace/pop`.
61
+ *
62
+ * `onOpen`/`onClose` are the presentation lifecycle (fired when the destination becomes / stops
63
+ * being the visible one — including router pushes covering it and pops revealing it).
64
+ */
65
+ export interface Presentable {
66
+ // Return type covers the implementers: UIScreen → void, Scene2D → this, ARScene → Promise.
67
+ open(options?: PresentOptions): void | this | Promise<void>
68
+ close(): void
69
+ onOpen(callback: () => void): this
70
+ onClose(callback: () => void): this
71
+ onBackPressed(callback: () => void): this
72
+ /** @internal Async work that must finish BEFORE this destination replaces the current one
73
+ * (camera permission, warm render, session launch — ARScene). The previous destination —
74
+ * typically a loading screen — stays visible while it runs; `open()` and `Router.push/replace`
75
+ * await it and skip the swap entirely if it rejects (permission denied, or superseded by
76
+ * another navigation that happened while preparing). */
77
+ _prepare?(): Promise<void>
78
+ }
79
+
80
+ // The destination visible right now. Set optimistically by every successful open()/router
81
+ // navigation, cleared by close(), and corrected by router onChange (hosts hand the wire desc
82
+ // back on every stack change, including back gestures — `_p` resolves it to the SDK instance).
83
+ let current: Presentable | null = null
84
+ /** @internal Call whenever the visible destination changes. */
85
+ export const _setCurrent = (p: Presentable | null): void => { current = p }
86
+
87
+ /** Runtime companion of the `Presentable` interface (declaration merging): the navigation
88
+ * state that isn't tied to the Router. */
89
+ export const Presentable = /*#__PURE__*/ Object.freeze({
90
+ /** The destination visible right now (a `UIScreen`, `Scene`, `Scene2D`, `NativeView` or
91
+ * `UIVideo`), or `null` before the first open. Distinct from `Router.current` — that is the
92
+ * top of the router stack, which stays meaningful while the router is suspended by a direct
93
+ * `open()`; this is what is actually on screen. `UIWidget.show()` attaches to it. */
94
+ get current(): Presentable | null { return current },
95
+ })
96
+
97
+ // Monotonic navigation epoch: every destination change (open/close/router nav) bumps it. Async
98
+ // preparers capture the epoch before their awaits and reject as "superseded" if it moved — so a
99
+ // slow AR prepare can't steal the screen from a navigation the user made in the meantime.
100
+ let navEpoch = 0
101
+ /** @internal */
102
+ export const _navEpoch = (): number => navEpoch
103
+ /** @internal Call on every action that changes the visible destination. */
104
+ export const _bumpNavEpoch = (): void => { navEpoch++ }
105
+
106
+ /** @internal Does this host implement the Presentable navigation surface? The SDK falls back to
107
+ * the legacy openScreen/openScene bridges when it doesn't (screens keep working; scene/native
108
+ * destinations in the Router then throw). */
109
+ export const _navSupported = (): boolean =>
110
+ typeof _creatorUI !== "undefined" && typeof _creatorUI.openView === "function"
111
+
112
+ /** @internal The wire descriptor for a Presentable. Screens (and anything without `_viewDesc`)
113
+ * travel as themselves; scenes/native views/video provide a stable descriptor object carrying
114
+ * `_p` (the SDK instance) so hosts can hand it back through router onChange / lifecycle. */
115
+ export const _descOf = (p: Presentable): object =>
116
+ typeof (p as any)._viewDesc === "function" ? (p as any)._viewDesc() : (p as object)