lecodes-cli 0.6.4 → 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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +920 -636
  3. package/package.json +9 -4
  4. package/runtime/scene-harness.json +1 -0
  5. package/runtime/sdk/compile/assetMacro.ts +4 -3
  6. package/runtime/sdk/compile/bundler.ts +35 -3
  7. package/runtime/sdk/compile/compileProject.ts +14 -2
  8. package/runtime/sdk/compile/libraryImports.ts +47 -0
  9. package/runtime/sdk/compile/sceneEditor.ts +16 -4
  10. package/runtime/sdk/core/Aspect.ts +37 -0
  11. package/runtime/sdk/core/InspectorUI.ts +212 -0
  12. package/runtime/sdk/core/fields.ts +15 -3
  13. package/runtime/sdk/g2/Scene2D.ts +51 -5
  14. package/runtime/sdk/gl/Model.ts +9 -0
  15. package/runtime/sdk/gl/Node.ts +4 -1
  16. package/runtime/sdk/gl/Scene.ts +76 -11
  17. package/runtime/sdk/gl/scenarios.ts +349 -0
  18. package/runtime/sdk/inject.ts +28 -8
  19. package/runtime/sdk/kit/UITabs.ts +105 -0
  20. package/runtime/sdk/plugins/camera.ts +81 -0
  21. package/runtime/sdk/plugins/geolocation.ts +123 -0
  22. package/runtime/sdk/plugins/permission.ts +7 -0
  23. package/runtime/sdk/plugins/qr.ts +61 -24
  24. package/runtime/sdk/runtime/app.ts +37 -0
  25. package/runtime/sdk/runtime/appEvents.ts +29 -0
  26. package/runtime/sdk/runtime/channel.ts +50 -0
  27. package/runtime/sdk/runtime/clipboard.ts +20 -0
  28. package/runtime/sdk/runtime/datetime.ts +2 -4
  29. package/runtime/sdk/runtime/device.ts +137 -4
  30. package/runtime/sdk/runtime/misc.ts +4 -0
  31. package/runtime/sdk/runtime/service.ts +82 -0
  32. package/runtime/sdk/runtime/touch.ts +26 -0
  33. package/runtime/sdk/scene/defineScene.ts +651 -17
  34. package/runtime/sdk/scene/editorPlugins.ts +86 -0
  35. package/runtime/sdk/ui/NativeView.ts +144 -0
  36. package/runtime/sdk/ui/UI.ts +6 -1
  37. package/runtime/sdk/ui/UIButton.ts +26 -3
  38. package/runtime/sdk/ui/UIInput.ts +2 -2
  39. package/runtime/sdk/ui/UINode.ts +24 -4
  40. package/runtime/sdk/ui/UIScreen.ts +86 -29
  41. package/runtime/sdk/ui/UIScreenHost.ts +213 -0
  42. package/runtime/sdk/ui/UIText.ts +1 -1
  43. package/runtime/sdk/ui/UIVideo.ts +48 -2
  44. package/runtime/sdk/ui/UIWidget.ts +34 -1
  45. package/runtime/sdk/ui/presentable.ts +116 -0
  46. package/runtime/sdk/ui/router.ts +73 -29
  47. package/runtime/sdk-types.json +1 -1
  48. package/runtime/sdk/runtime/camera.ts +0 -31
@@ -0,0 +1,86 @@
1
+ // Editor plugins: `registerEditorWindow` / `registerEditorTool` (docs/scene-editor-plan.md,
2
+ // phase 11). Registrations live in `*.editor.ts` files — compiled and executed ONLY in
3
+ // scene-editor bundles (phase 10), so none of this reaches shipped app.js (nothing references it
4
+ // there, and method-granular DCE drops it).
5
+ //
6
+ // The scene-editor harness (packages/projects/src/scene-editor) reads the registry through the
7
+ // injected `__editorPlugins` global — the harness, the scene, and the editor files compile into
8
+ // ONE bundle, so they share this module instance. Windows and tools describe their UI through the
9
+ // same immediate-mode `InspectorUI` protocol custom inspector cards use (one widget vocabulary,
10
+ // three mount points), and mutate the scene ONLY through the `editor` doc API — every plugin write
11
+ // lands in the scene document (undoable, diffable), never in live engine state.
12
+
13
+ import type { InspectorUI } from "../core/InspectorUI"
14
+
15
+ /** A geometry hit under the viewport pointer, handed to tools. Physics builds raycast the scene's
16
+ * meshes (precise); otherwise (and on misses) the hit falls back to the ground plane (y = 0). */
17
+ export type EditorRayHit = {
18
+ point: [number, number, number]
19
+ normal: [number, number, number]
20
+ /** The scene-file node the hit belongs to — null for ground-plane fallback hits. */
21
+ node: string | null
22
+ }
23
+
24
+ /**
25
+ * The editor scripting API handed to windows and tools. Doc-op methods write the scene DOCUMENT
26
+ * through the editor's normal commit path (undo, file, live patching all included); they return
27
+ * false / no-op when the document can't take the edit (duplicate name, unknown node).
28
+ */
29
+ export type EditorApi = {
30
+ /** The currently selected node name (or `model::part` key), null when nothing is selected. */
31
+ readonly selection: string | null
32
+ select(name: string | null): void
33
+ /** The scene document's nodes (name + source kind: mesh / model / light / group). */
34
+ nodes(): { name: string, kind: string }[]
35
+ /** First unused "base", "base2", "base3", … node name. */
36
+ uniqueName(base: string): string
37
+ /** Add a node from plain def data (scene-file grammar; a string `model` value means an asset
38
+ * path). One undo step unless grouped by `transact`. */
39
+ addNode(name: string, def: Record<string, unknown>): boolean
40
+ /** Write one def prop (transforms apply live; anything else patches/re-runs the node). */
41
+ setProp(name: string, key: string, value: unknown): boolean
42
+ removeNode(name: string): void
43
+ /** Duplicate a node; returns the copy's name (null when it can't). */
44
+ duplicate(name: string): string | null
45
+ /** Raycast the scene under a viewport pixel (same hit rules as tool clicks). */
46
+ raycast(screenX: number, screenY: number): EditorRayHit | null
47
+ /** Group every doc edit inside `fn` into ONE undo step. */
48
+ transact(fn: () => void): void
49
+ }
50
+
51
+ export type EditorWindowFn = (ui: InspectorUI, editor: EditorApi) => void
52
+
53
+ export type EditorToolHooks = {
54
+ /** CSS cursor for the viewport while this tool is active (default "crosshair"). */
55
+ cursor?: string
56
+ /** A short glyph (one character / emoji) for the tool's toolbar button — tools without one all
57
+ * share the generic wand icon, so any editor with two or more tools should set it. */
58
+ icon?: string
59
+ /** A viewport click while the tool is active — `hit` is the raycast result under the pointer.
60
+ * Selection clicks and the transform gizmo are suspended while a tool is active. */
61
+ onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
62
+ }
63
+
64
+ /** @internal The registry the scene-editor harness reads (windows/tools in registration order). */
65
+ export const __editorPlugins = {
66
+ windows: [] as { title: string, render: EditorWindowFn }[],
67
+ tools: [] as { name: string, hooks: EditorToolHooks }[],
68
+ }
69
+
70
+ /** Register an editor panel — a collapsible overlay docked over the viewport (the inspector rail
71
+ * stays selection-scoped). A window that emits `ui.toolButton` for a tool becomes that tool's
72
+ * settings panel: activating the tool expands and highlights it. `render` re-runs immediate-mode
73
+ * on every interaction — same protocol as `static inspector` cards. */
74
+ export const registerEditorWindow = (title: string, render: EditorWindowFn): void => {
75
+ const existing = __editorPlugins.windows.find((w) => w.title === title)
76
+ if (existing) existing.render = render
77
+ else __editorPlugins.windows.push({ title, render })
78
+ }
79
+
80
+ /** Register a viewport tool — a toolbar entry beside move/rotate/scale (activate it there or via
81
+ * `ui.toolButton(label, name)`). While active, viewport clicks arrive as raycast hits. */
82
+ export const registerEditorTool = (name: string, hooks: EditorToolHooks): void => {
83
+ const existing = __editorPlugins.tools.find((t) => t.name === name)
84
+ if (existing) existing.hooks = hooks
85
+ else __editorPlugins.tools.push({ name, hooks })
86
+ }
@@ -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'
@@ -13,10 +13,15 @@ export { UIWidget, type UIWidgetStyle } from './UIWidget'
13
13
 
14
14
  export { UIRow, UIColumn, UIBox, type UIContainerStyle } from './UIContainer'
15
15
  export { UIScrollable, type UIScrollableStyle } from './UIScrollable'
16
+ export { UIScreenHost, type UIScreenHostStyle } from './UIScreenHost'
16
17
 
17
18
  export { UIVirtualizedList } from './UIVirtualizedList'
18
19
 
19
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'
20
25
 
21
26
  // Internal: memoized map for reactive children — the chisel `reactiveUi` pass rewrites
22
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,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,39 +1,56 @@
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
36
42
 
43
+ /**
44
+ * Keep this screen's built native tree alive when a UIScreenHost detaches it (replace / remove /
45
+ * pop): instead of being destroyed, its views + layout + live state (scroll position, input text)
46
+ * are retained, so re-hosting the SAME screen object restores it in place rather than rebuilding.
47
+ * `onClose`/`onOpen` still fire on detach/re-attach. You own the lifetime — a kept tree is freed
48
+ * only by `dispose()` or when the project unloads. (Native-only today; web rebuilds.)
49
+ */
50
+ keepAlive(enabled?: boolean): this
51
+ /** Free a `keepAlive()` screen's retained tree now. No-op unless it's currently detached-and-kept. */
52
+ dispose(): this
53
+
37
54
  onLayout(onLayout: OnLayoutCallback): this
38
55
 
39
56
  setClass(name: string, enabled: boolean): this
@@ -41,16 +58,46 @@ export interface UIScreen <Scrollable extends boolean = false> {
41
58
  hasClass(name: string): boolean
42
59
  bindClass(name: string, fn: () => boolean): this
43
60
 
44
- 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,
45
79
  }
46
80
 
47
81
  export class ScreenElement extends ContainerElement<"screen"> {
48
- scrollable = false as const
49
- open(): void {
50
- _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)
51
92
  }
52
93
  close(): void {
53
- _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)
54
101
  }
55
102
  protected ol: (() => void)[] = []
56
103
  onOpen(callback: () => void): this {
@@ -62,6 +109,18 @@ export class ScreenElement extends ContainerElement<"screen"> {
62
109
  this.cl.push(callback)
63
110
  return this
64
111
  }
112
+ // Read by the host at detach time (a plain flag on the node, like _interactive).
113
+ _keepAlive = false
114
+ keepAlive(enabled = true): this {
115
+ this._keepAlive = enabled
116
+ return this
117
+ }
118
+ dispose(): this {
119
+ // Rides the generic command channel (no dedicated bridge). No-op while _id is 0 (never mounted)
120
+ // or when the tree isn't currently detached-and-kept — the host ignores it then.
121
+ if (this._id !== 0) _creatorUI.command(this, "disposeKeepAlive")
122
+ return this
123
+ }
65
124
  readonly touchStartListeners: any[] = []
66
125
  _interactive = false
67
126
  onTouchStart (callback: any): this {
@@ -80,8 +139,8 @@ export class ScreenElement extends ContainerElement<"screen"> {
80
139
  }
81
140
  return null
82
141
  }
83
- makeScrollable(): UIScreen<true> {
84
- this.scrollable = true as any
142
+ makeScrollable(): UIScrollableScreen {
143
+ this.scrollable = true
85
144
 
86
145
  Object.assign(this, {
87
146
  sl: [] as any[],
@@ -100,24 +159,22 @@ export class ScreenElement extends ContainerElement<"screen"> {
100
159
  }
101
160
  })
102
161
 
103
- return this as any as UIScreen<true>
162
+ return this as any as UIScrollableScreen
104
163
  }
105
164
  _backButtonCallback?: () => void
106
165
  onBackPressed(callback: any): this {
107
166
  this._backButtonCallback = callback
108
167
  return this
109
168
  }
169
+ // Set by the onRefresh method that makeScrollable() grafts on; read by the host at mount.
110
170
  declare _refreshCallback: any
111
- declare onRefresh: never
112
- declare onScroll: never
113
- declare onOverscroll: never
114
171
  }
115
172
 
116
- export function UIScreen(): UIScreen<false>;
117
- export function UIScreen(children: UINodeChild[] | ChildrenFn): UIScreen<false>;
118
- export function UIScreen(style: UIScreenStyle): UIScreen<false>;
119
- export function UIScreen(style: UIScreenStyle, children: UINodeChild[]): UIScreen<false>;
120
- 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 {
121
178
  if (args.length === 0) {
122
179
  return new ScreenElement("screen", {}, [])
123
180
  } else if (args.length === 1) {