lecodes-cli 0.7.1 → 0.8.0

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 (41) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +2941 -745
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/canvas/Canvas.ts +16 -0
  6. package/runtime/sdk/gl/Material.ts +4 -0
  7. package/runtime/sdk/inject.ts +8 -5
  8. package/runtime/sdk/plugins/service.ts +47 -0
  9. package/runtime/sdk/runtime/app.ts +21 -1
  10. package/runtime/sdk/runtime/appEvents.ts +25 -0
  11. package/runtime/sdk/runtime/service.ts +3 -2
  12. package/runtime/sdk/ui/NativeView.ts +2 -1
  13. package/runtime/sdk/ui/UI.ts +3 -1
  14. package/runtime/sdk/ui/UIButton.ts +10 -5
  15. package/runtime/sdk/ui/UIContainer.ts +16 -9
  16. package/runtime/sdk/ui/UIImage.ts +5 -2
  17. package/runtime/sdk/ui/UIInput.ts +75 -8
  18. package/runtime/sdk/ui/UIModal.ts +149 -0
  19. package/runtime/sdk/ui/UINode.ts +126 -55
  20. package/runtime/sdk/ui/UIPager.ts +297 -0
  21. package/runtime/sdk/ui/UIPopover.ts +106 -0
  22. package/runtime/sdk/ui/UIScreen.ts +11 -10
  23. package/runtime/sdk/ui/UIScrollable.ts +2 -1
  24. package/runtime/sdk/ui/UISpacer.ts +1 -1
  25. package/runtime/sdk/ui/UIText.ts +2 -1
  26. package/runtime/sdk/ui/UIVideo.ts +2 -1
  27. package/runtime/sdk/ui/UIVirtualizedList.ts +2 -1
  28. package/runtime/sdk/ui/UIWidget.ts +19 -22
  29. package/runtime/sdk/ui/theme.ts +66 -0
  30. package/runtime/sdk-types.json +1 -1
  31. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  32. package/runtime/sdk/compile/assetMacro.ts +0 -45
  33. package/runtime/sdk/compile/bundler.ts +0 -205
  34. package/runtime/sdk/compile/compileProject.ts +0 -108
  35. package/runtime/sdk/compile/header.ts +0 -46
  36. package/runtime/sdk/compile/index.ts +0 -18
  37. package/runtime/sdk/compile/libraryImports.ts +0 -47
  38. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  39. package/runtime/sdk/compile/sourcemap.ts +0 -25
  40. package/runtime/sdk/kit/UITabs.ts +0 -105
  41. package/runtime/sdk/ui/UIScreenHost.ts +0 -213
@@ -73,62 +73,63 @@ export type ScrollStyle = {
73
73
  scrollY?: "auto" | "all" | "none"
74
74
  }
75
75
 
76
- type UIValue = number |
77
- `${number}px` |
78
- `${number}vw` |
79
- `${number}vh` |
80
- `${number}vmin` |
81
- `${number}vmax` |
82
- `${number}em` |
76
+ type UIValue = number |
77
+ `${number}px` |
78
+ `${number}vw` |
79
+ `${number}vh` |
80
+ `${number}vmin` |
81
+ `${number}vmax` |
82
+ `${number}em` |
83
83
  `calc(${string})` |
84
84
  `min(${string})` |
85
85
  `max(${string})` |
86
86
  `${string}%` |
87
- `clamp(${string})`
87
+ `clamp(${string})` |
88
+ `var(--${string})`
88
89
 
89
90
  export type PaddingStyle = {
90
- paddingLeft?: UIValue | "safe-left" | "safe-left-comfort",
91
- paddingTop?: UIValue | "safe-top" | "safe-top-comfort",
92
- paddingRight?: UIValue | "safe-right" | "safe-right-comfort",
93
- paddingBottom?: UIValue | "safe-bottom" | "safe-bottom-comfort",
94
- padding?: UIValue | "safe-all" | "safe-all-comfort",
95
- paddingHorizontal?: UIValue,
96
- paddingVertical?: UIValue,
97
-
98
- pl?: UIValue | "safe-left" | "safe-left-comfort",
99
- pt?: UIValue | "safe-top" | "safe-top-comfort",
100
- pr?: UIValue | "safe-right" | "safe-right-comfort",
101
- pb?: UIValue | "safe-bottom" | "safe-bottom-comfort",
102
- p?: UIValue | "safe-all" | "safe-all-comfort",
103
- px?: UIValue,
104
- py?: UIValue,
91
+ paddingLeft?: UIValue | "safe-left" | "comfort-left",
92
+ paddingTop?: UIValue | "safe-top" | "comfort-top",
93
+ paddingRight?: UIValue | "safe-right" | "comfort-right",
94
+ paddingBottom?: UIValue | "safe-bottom" | "comfort-bottom",
95
+ padding?: UIValue | "safe-all" | "comfort-all",
96
+ paddingHorizontal?: UIValue | "comfort-x",
97
+ paddingVertical?: UIValue | "comfort-y",
98
+
99
+ pl?: UIValue | "safe-left" | "comfort-left",
100
+ pt?: UIValue | "safe-top" | "comfort-top",
101
+ pr?: UIValue | "safe-right" | "comfort-right",
102
+ pb?: UIValue | "safe-bottom" | "comfort-bottom",
103
+ p?: UIValue | "safe-all" | "comfort-all",
104
+ px?: UIValue | "comfort-x",
105
+ py?: UIValue | "comfort-y",
105
106
  }
106
107
 
107
108
 
108
109
 
109
110
  export type MarginStyle = {
110
- marginLeft?: UIValue | "auto" | "safe-left" | "safe-left-comfort",
111
- marginTop?: UIValue | "auto" | "safe-top" | "safe-top-comfort",
112
- marginRight?: UIValue | "auto" | "safe-right" | "safe-right-comfort",
113
- marginBottom?: UIValue | "auto" | "safe-bottom" | "safe-bottom-comfort",
114
- margin?: UIValue | "auto" | "safe-all" | "safe-all-comfort",
115
- marginHorizontal?: UIValue | "auto",
116
- marginVertical?: UIValue | "auto",
117
-
118
- ml?: UIValue | "auto" | "safe-left" | "safe-left-comfort",
119
- mt?: UIValue | "auto" | "safe-top" | "safe-top-comfort",
120
- mr?: UIValue | "auto" | "safe-right" | "safe-right-comfort",
121
- mb?: UIValue | "auto" | "safe-bottom" | "safe-bottom-comfort",
122
- m?: UIValue | "auto" | "safe-all" | "safe-all-comfort",
123
- mx?: UIValue | "auto",
124
- my?: UIValue | "auto",
111
+ marginLeft?: UIValue | "auto" | "safe-left" | "comfort-left",
112
+ marginTop?: UIValue | "auto" | "safe-top" | "comfort-top",
113
+ marginRight?: UIValue | "auto" | "safe-right" | "comfort-right",
114
+ marginBottom?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
115
+ margin?: UIValue | "auto" | "safe-all" | "comfort-all",
116
+ marginHorizontal?: UIValue | "auto" | "comfort-x",
117
+ marginVertical?: UIValue | "auto" | "comfort-y",
118
+
119
+ ml?: UIValue | "auto" | "safe-left" | "comfort-left",
120
+ mt?: UIValue | "auto" | "safe-top" | "comfort-top",
121
+ mr?: UIValue | "auto" | "safe-right" | "comfort-right",
122
+ mb?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
123
+ m?: UIValue | "auto" | "safe-all" | "comfort-all",
124
+ mx?: UIValue | "auto" | "comfort-x",
125
+ my?: UIValue | "auto" | "comfort-y",
125
126
  }
126
127
 
127
128
  export type PositionStyle = {
128
- top?: UIValue | `${number}%` | 'safe-top' | 'safe-top-comfort',
129
- left?: UIValue | `${number}%` | 'safe-left' | 'safe-left-comfort',
130
- bottom?: UIValue | `${number}%` | 'safe-bottom' | 'safe-bottom-comfort',
131
- right?: UIValue | `${number}%` | 'safe-right' | 'safe-right-comfort',
129
+ top?: UIValue | `${number}%` | 'safe-top' | 'comfort-top',
130
+ left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
131
+ bottom?: UIValue | `${number}%` | 'safe-bottom' | 'comfort-bottom',
132
+ right?: UIValue | `${number}%` | 'safe-right' | 'comfort-right',
132
133
  width?: UIValue | `${number}%` | "auto",
133
134
  height?: UIValue | `${number}%` | "auto",
134
135
  maxWidth?: UIValue | `${number}%` | "auto",
@@ -195,6 +196,7 @@ function styleFunction(this: any, newStyle: any) {
195
196
  this.name = newStyle.name
196
197
  delete newStyle.name
197
198
  }
199
+ if (newStyle) this._mapStyle?.(newStyle)
198
200
  const fns = extractBindings(this, newStyle)
199
201
  Object.assign(this._style, newStyle)
200
202
  if (this._id !== 0) {
@@ -220,10 +222,16 @@ const handler: ProxyHandler<any> = {
220
222
  createBinding(el, key, () => { el.style[key] = fn() })
221
223
  return true
222
224
  }
223
- if (target._el._id !== 0) {
224
- _creatorUI.updateStyle(target._el._id, key, value)
225
+ const el = target._el
226
+ if (el._mapStyle !== undefined) {
227
+ const mapped = { [key]: value }
228
+ el._mapStyle(mapped)
229
+ value = mapped[key]
225
230
  }
226
- target._el._style[key] = value
231
+ if (el._id !== 0) {
232
+ _creatorUI.updateStyle(el._id, key, value)
233
+ }
234
+ el._style[key] = value
227
235
  return true
228
236
  },
229
237
  getOwnPropertyDescriptor(target, p) {
@@ -245,15 +253,20 @@ export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T
245
253
 
246
254
  // User-defined style classes: any `$`-prefixed key in .style() declares a state block (like
247
255
  // onPressed, but with any name), toggled from code via setClass/toggleClass. `duration`/`delay`
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.
256
+ // make the swap transition. Class state INHERITS down the tree (CSS-`.dark`-on-body style): a
257
+ // class set on a node also activates same-name `$` blocks on all descendants within the same
258
+ // root — see docs/style-class-cascade-plan.md. `$pressed`/`$focused` are reserved: hosts toggle
259
+ // them on press/focus (they beat other classes, lose to onPressed/onFocused), so children can
260
+ // react to an ancestor's press. Instantiate with the node's own style `T` (see StyleFn below) — a
261
+ // class block accepts everything the node's `.style()` does (e.g. `color` on text, layout on any
262
+ // node), exactly like `onLandscape`/`onPortrait`. Narrowing it (it once was `DrawableStyle &
263
+ // BaseStyle`) wrongly rejected valid props such as `$active: { color }`. Guarded by
264
+ // tests/ui-types.test.ts.
252
265
  export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
253
266
 
254
267
  // NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
255
268
  // 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
269
+ // `UIScreen<false>` unassignable to `UIScreen<boolean>` (a navigator page), and would block any
257
270
  // UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
258
271
  // `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
259
272
  // R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
@@ -271,13 +284,42 @@ export type OrientationStyles<T> = {
271
284
  export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
272
285
  export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
273
286
 
274
- export type AnimateStyle<R, T extends object> = ((style: T & { duration?: number, delay?: number, layer?: number, commit?: boolean }) => R)
287
+ // The options bag of animateTo/animateFrom: the target style plus flat meta keys. Meta keys are
288
+ // stripped before the commit into _style and skipped by the hosts' style staging.
289
+ export type AnimateStyle<R, T extends object> = ((style: T & {
290
+ /** Tween length in **milliseconds** (default 225). */
291
+ duration?: number,
292
+ /** Wait before starting, in **milliseconds**. A looping animation delays once, not per cycle. */
293
+ delay?: number,
294
+ /** Escape hatch: target an internal style layer (the onPressed/orientation mechanism). Not part of the supported surface. */
295
+ layer?: number,
296
+ /** `false` = animate without writing the targets into the element's style (e.g. a fade-out right before `.hide()`). */
297
+ commit?: boolean,
298
+ /** Repeat the animation: `true` = forever, a number = that many cycles. A looping animation is an
299
+ * effect, not a state change — it never commits, and the element ends back at its base style.
300
+ * The loop stops when a later `animateTo`/`animateFrom` runs on the element or it leaves the screen. */
301
+ loop?: boolean | number,
302
+ /** `"ping-pong"` (default) animates there and back each cycle — no jump. `"restart"` snaps back and
303
+ * replays forward — for full-turn spinners (`transform: "rotate(360deg)"`) and shimmers. Only meaningful with `loop`. */
304
+ loopMode?: "restart" | "ping-pong",
305
+ }) => R)
306
+
307
+ /** Meta keys of the animateTo/animateFrom options bag — everything that is not a style target. */
308
+ const ANIMATE_META = new Set(["duration", "delay", "layer", "commit", "loop", "loopMode"])
275
309
 
276
310
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
277
311
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
278
312
 
279
313
  export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void
280
314
 
315
+ // Web-DOMRect shape on purpose (getBoundingClientRect priors must hold): all eight fields, with
316
+ // right/bottom/x/y derived SDK-side so hosts only report [left, top, width, height].
317
+ export type BoundingClientRect = {
318
+ x: number, y: number,
319
+ left: number, top: number, right: number, bottom: number,
320
+ width: number, height: number,
321
+ }
322
+
281
323
  export class Element<T extends string> {
282
324
  readonly type: T
283
325
  /** Author-given semantic name (LeCodes `name`) — see BaseStyle.name. Extracted from the options
@@ -294,12 +336,21 @@ export class Element<T extends string> {
294
336
  * is the effect's only strong owner, so discarding a subtree lets its bindings collect with it. */
295
337
  _fx?: Map<string, any>
296
338
 
339
+ /** @internal Per-subclass style normalization (SDK-name → wire-name, e.g. UIInput's
340
+ * `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
341
+ * every entry point: the first-arg style, `.style()`, and single-key `el.style.k =` writes. */
342
+ _mapStyle?(style: any): void
343
+
344
+ // Per-type default styles are inlined at each component's construction site (spread UNDER the
345
+ // user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
346
+ // explicit values and hosts keep no per-type style opinions of their own.
297
347
  constructor(type: T, style: any) {
298
348
  this.type = type
299
349
  if (style && typeof style.name === "string" && style.name.length > 0) {
300
350
  this.name = style.name
301
351
  delete style.name
302
352
  }
353
+ if (style) this._mapStyle?.(style)
303
354
  this._style = style
304
355
  if (style) {
305
356
  const fns = extractBindings(this, style)
@@ -326,8 +377,12 @@ export class Element<T extends string> {
326
377
 
327
378
  animateTo(style: any): this{
328
379
  _creatorUI.animateTo(this._id, style)
329
- if (style.commit !== false) {
330
- Object.assign(this._style, style)
380
+ // A loop is an effect, not a state change — looping animations never commit. Meta keys
381
+ // (duration/delay/…) stay out of the stored style.
382
+ if (style.commit !== false && !style.loop) {
383
+ for (const key in style) {
384
+ if (!ANIMATE_META.has(key)) this._style[key] = style[key]
385
+ }
331
386
  }
332
387
  return this
333
388
  }
@@ -341,7 +396,10 @@ export class Element<T extends string> {
341
396
  * re-assign _id on every open). */
342
397
  protected _classes?: Set<string>
343
398
 
344
- /** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`). */
399
+ /** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`).
400
+ * The class cascades: it is also active on every descendant (their same-name `$` blocks light
401
+ * up), until the subtree's root — hosted screens/widgets don't inherit. `hasClass`/`toggleClass`
402
+ * reflect only THIS node's own classes; there is no opt-out below an active ancestor. */
345
403
  setClass(name: string, enabled: boolean): this {
346
404
  if (name[0] === "$") name = name.slice(1)
347
405
  if (enabled) {
@@ -372,7 +430,7 @@ export class Element<T extends string> {
372
430
  }
373
431
 
374
432
  protected ll?: OnLayoutCallback[]
375
- onLayout(onLayout: OnLayoutCallback): this {
433
+ onLayout(onLayout: OnLayoutCallback): this {
376
434
  if (this.ll) {
377
435
  this.ll.push(onLayout)
378
436
  } else {
@@ -380,6 +438,19 @@ export class Element<T extends string> {
380
438
  }
381
439
  return this
382
440
  }
441
+
442
+ /** Absolute rect in device space — the space `UIWidget` positions in and touch events report
443
+ * `clientX`/`clientY` in — including scroll offsets, read live at call time (unlike `onLayout`,
444
+ * whose coordinates are parent-relative and go stale when an ancestor scrolls). `null` before
445
+ * the element is mounted and on hosts without the bridge read. Anchor popovers position-once
446
+ * at open; don't poll per frame. */
447
+ getBoundingClientRect(): BoundingClientRect | null {
448
+ if (this._id === 0) return null
449
+ const rect = _creatorUI.getBoundingClientRect?.(this._id)
450
+ if (!rect) return null
451
+ const [left, top, width, height] = rect
452
+ return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height }
453
+ }
383
454
  }
384
455
 
385
456
  // ---- reactive children --------------------------------------------------------------------------
@@ -0,0 +1,297 @@
1
+ import { ContainerElement, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style } from "./UINode"
2
+ import type { UIScreen } from "./UIScreen"
3
+
4
+ // UIPager — the system's ONE screen-navigation element (docs/pager-component-plan.md): sibling
5
+ // tabs that swipe natively side to side, with a native navigation STACK per tab. `push` opens a
6
+ // screen on top of the currently open tab (within the pager); `pop` / the interactive edge
7
+ // back-swipe unwinds it; `replace` swaps the top. A pager with one tab is a plain stack.
8
+ //
9
+ // Reachable from any scope as a global: `UIPager.push(screen)` pushes onto the pager that owns
10
+ // the current screen (UIPager.current) — no reference threading into child screens. Router stays
11
+ // distinct: `Router.push` opens ON TOP of the pager (bars and all), the pager navigates within.
12
+ //
13
+ // Wire node type "pager": children are separate layout roots (full UIScreens), NOT yoga children.
14
+ // The wire shape is `children` flat in TAB-MAJOR order (tab 0's stack root→top, then tab 1's, …)
15
+ // partitioned by `_lens` (per-tab stack lengths) with `_index` = selected tab. State is mutated
16
+ // BEFORE each command, so the host reconciles against the node; commands only name the native
17
+ // transition to play. Host-driven changes come back via `_hostDidSelect` / `_hostDidPop` — both
18
+ // idempotent no-ops when the logical state already matches.
19
+
20
+ /** Styles a pager accepts — layout box + drawable (background, borders, …). Pages style
21
+ * themselves; the pager only owns its own slot. */
22
+ export type UIPagerStyle = ElementStyle & DrawableStyle
23
+
24
+ /** The pagers whose screen is currently visible, outermost → innermost. `UIPager.current` is the
25
+ * last (innermost). Maintained by each owned screen's onOpen/onClose via _activate/_deactivate. */
26
+ const _activeStack: PagerElement[] = []
27
+
28
+ /**
29
+ * The screen-navigation element: sibling **tabs** that swipe natively side to side, each with its
30
+ * own navigation **stack**. `push` opens a screen on top of the current tab (within the pager);
31
+ * `pop` / the native edge back-swipe unwinds it. A one-tab pager is a plain navigation stack.
32
+ *
33
+ * ```ts
34
+ * const pager = UIPager([ HomeScreen(), ProfileScreen() ]) // two tabs, side by side
35
+ * Router.init(UIScreen([ pager.style({ flexGrow: 1 }), tabBar ]))
36
+ *
37
+ * // from anywhere inside a hosted screen — no reference threading:
38
+ * UIPager.push(PostScreen(id))
39
+ * ```
40
+ *
41
+ * To open something over *everything* (above bars, above the pager), use `Router.push` instead.
42
+ */
43
+ export interface UIPager {
44
+ readonly type: "pager",
45
+ style: Style<this, UIPagerStyle>,
46
+ animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
47
+ animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
48
+
49
+ /** Switch to tab `index`. **Instant by default** — the tab-bar convention (tabs are parallel
50
+ * modes, not neighbors); pass `animated: true` for the direction-aware slide of top-tab /
51
+ * pager UIs. Out-of-range and same-index calls are no-ops. Tabs keep their stacks: selecting
52
+ * away and back restores the tab exactly where it was, pushed screens included. */
53
+ select(index: number, animated?: boolean): this,
54
+ /** The selected tab index (0-based). */
55
+ readonly index: number,
56
+ /** Fires when the selected tab changes and has settled — a user swipe or `select()`. */
57
+ onSelect(callback: (index: number) => void): this,
58
+
59
+ /** Push `screen` on top of the **current tab** — slides in natively; the edge back-swipe (or
60
+ * `pop()`) removes it. Other tabs are unaffected. */
61
+ push(screen: UIScreen): this,
62
+ /** Pop the current tab's top screen (no-op at the tab root — the root never pops). The
63
+ * programmatic twin of the native edge back-swipe. */
64
+ pop(): this,
65
+ /** Unwind the current tab back to its root screen in one step. */
66
+ popToRoot(): this,
67
+ /** Swap the current tab's top screen for `screen` without growing the stack (at depth 1 this
68
+ * swaps the tab's root). */
69
+ replace(screen: UIScreen): this,
70
+ /** The current tab's screens, root → top (read-only snapshot). */
71
+ readonly stack: UIScreen[],
72
+ /** The current tab's stack depth — 1 means only the root. While `depth > 1`, swiping between
73
+ * tabs is disabled (the horizontal gesture belongs to the back-swipe). */
74
+ readonly depth: number,
75
+ /** Fires when the current tab's depth changes — push, pop, replace, or a back-swipe. */
76
+ onChange(callback: (depth: number) => void): this,
77
+
78
+ onLayout(onLayout: OnLayoutCallback): this,
79
+ getBoundingClientRect(): BoundingClientRect | null,
80
+
81
+ setClass(name: string, enabled: boolean): this,
82
+ toggleClass(name: string): this,
83
+ hasClass(name: string): boolean,
84
+ bindClass(name: string, fn: () => boolean): this,
85
+ }
86
+
87
+ export class PagerElement extends ContainerElement<"pager"> {
88
+ /** Per-tab stack lengths — partitions the flat tab-major `children`. Named `_*` so chisel
89
+ * keeps it (the host reads it live). */
90
+ _lens: number[]
91
+ /** Selected tab. */
92
+ _index = 0
93
+ // >0 while a screen owned by this pager is the visible one (its onOpen fired, onClose hasn't).
94
+ // Only one owned screen is ever open, so this is effectively 0/1 — the count only makes the
95
+ // transient during a transition's old-close/new-open pair order-independent.
96
+ _openCount = 0
97
+ private scl: ((index: number) => void)[] = [] // onSelect listeners
98
+ private ncl: ((depth: number) => void)[] = [] // onChange listeners
99
+
100
+ constructor(style: UIPagerStyle | null, tabs: UIScreen[]) {
101
+ super("pager", style ?? {}, tabs as any)
102
+ this._lens = tabs.map(() => 1)
103
+ for (const s of this.children) this._track(s)
104
+ }
105
+
106
+ /** Start offset of tab `i`'s segment in the flat `children`. */
107
+ private _offset(i: number): number {
108
+ let off = 0
109
+ for (let t = 0; t < i; t++) off += this._lens[t]
110
+ return off
111
+ }
112
+
113
+ get index(): number {
114
+ return this._index
115
+ }
116
+ get stack(): UIScreen[] {
117
+ const off = this._offset(this._index)
118
+ return this.children.slice(off, off + (this._lens[this._index] ?? 0))
119
+ }
120
+ get depth(): number {
121
+ return this._lens[this._index] ?? 0
122
+ }
123
+
124
+ onSelect(callback: (index: number) => void): this {
125
+ this.scl.push(callback)
126
+ return this
127
+ }
128
+ onChange(callback: (depth: number) => void): this {
129
+ this.ncl.push(callback)
130
+ return this
131
+ }
132
+
133
+ select(index: number, animated = false): this {
134
+ if (index < 0 || index >= this._lens.length || index === this._index) return this
135
+ this._index = index
136
+ if (this._id !== 0) _creatorUI.command(this, "select", animated)
137
+ this._emitSelect()
138
+ return this
139
+ }
140
+
141
+ push(screen: UIScreen): this {
142
+ if (this._lens.length === 0) {
143
+ console.warn("UIPager.push: pager has no tabs")
144
+ return this
145
+ }
146
+ this.children.splice(this._offset(this._index) + this._lens[this._index], 0, screen)
147
+ this._lens[this._index]++
148
+ this._track(screen)
149
+ if (this._id !== 0) _creatorUI.command(this, "push")
150
+ this._emitChange()
151
+ return this
152
+ }
153
+
154
+ pop(): this {
155
+ if ((this._lens[this._index] ?? 0) <= 1) return this
156
+ this.children.splice(this._offset(this._index) + this._lens[this._index] - 1, 1)
157
+ this._lens[this._index]--
158
+ if (this._id !== 0) _creatorUI.command(this, "pop")
159
+ this._emitChange()
160
+ return this
161
+ }
162
+
163
+ popToRoot(): this {
164
+ const len = this._lens[this._index] ?? 0
165
+ if (len <= 1) return this
166
+ this.children.splice(this._offset(this._index) + 1, len - 1)
167
+ this._lens[this._index] = 1
168
+ if (this._id !== 0) _creatorUI.command(this, "popToRoot")
169
+ this._emitChange()
170
+ return this
171
+ }
172
+
173
+ replace(screen: UIScreen): this {
174
+ if (this._lens.length === 0) {
175
+ console.warn("UIPager.replace: pager has no tabs")
176
+ return this
177
+ }
178
+ this.children[this._offset(this._index) + this._lens[this._index] - 1] = screen
179
+ this._track(screen)
180
+ if (this._id !== 0) _creatorUI.command(this, "replace")
181
+ this._emitChange()
182
+ return this
183
+ }
184
+
185
+ // Host-driven tab change (committed swipe): the native pager already moved — sync the logical
186
+ // index + fire onSelect. Named `_*` so chisel keeps it (hosts call it).
187
+ _hostDidSelect(index: number): void {
188
+ if (index < 0 || index >= this._lens.length || index === this._index) return
189
+ this._index = index
190
+ this._emitSelect()
191
+ }
192
+
193
+ // Host-driven pop on the current tab (interactive edge back-swipe): the native stack already
194
+ // popped — sync the logical stack + fire onChange. Idempotent, safe after every transition.
195
+ _hostDidPop(depth: number): void {
196
+ if (depth < 1) depth = 1
197
+ const len = this._lens[this._index] ?? 0
198
+ if (depth >= len) return
199
+ this.children.splice(this._offset(this._index) + depth, len - depth)
200
+ this._lens[this._index] = depth
201
+ this._emitChange()
202
+ }
203
+
204
+ private _emitSelect(): void {
205
+ const i = this._index
206
+ for (const cb of this.scl) cb(i)
207
+ }
208
+ private _emitChange(): void {
209
+ const d = this._lens[this._index] ?? 0
210
+ for (const cb of this.ncl) cb(d)
211
+ }
212
+
213
+ // Register a screen for `current` tracking: while a screen owned by this pager is the visible
214
+ // top, this pager is active. The hook reads screen._pagerOwner LIVE (not a captured `this`), so
215
+ // re-homing a screen into another pager activates the right one.
216
+ private _track(screen: any): void {
217
+ screen._pagerOwner = this
218
+ if (!screen._pagerHooked) {
219
+ screen._pagerHooked = true
220
+ screen.onOpen(() => screen._pagerOwner?._activate())
221
+ screen.onClose(() => screen._pagerOwner?._deactivate())
222
+ }
223
+ }
224
+ _activate(): void {
225
+ if (++this._openCount === 1 && _activeStack.indexOf(this) < 0) _activeStack.push(this)
226
+ }
227
+ _deactivate(): void {
228
+ if (--this._openCount <= 0) {
229
+ this._openCount = 0
230
+ const i = _activeStack.indexOf(this)
231
+ if (i >= 0) _activeStack.splice(i, 1)
232
+ }
233
+ }
234
+ }
235
+
236
+ const isScreenArg = (v: any): boolean => v != null && typeof v === "object" && v.type === "screen"
237
+
238
+ /** Callable factory + ambient statics. The name `UIPager` is a value (this factory) and a type
239
+ * (the instance interface above) — a standard value/type merge. */
240
+ interface UIPagerConstructor {
241
+ /** One tab — a plain navigation stack rooted at `root` (push/pop, no tab swiping). */
242
+ (root: UIScreen): UIPager
243
+ /** Sibling tabs, one per screen, in order — swipe (or `select`) between them. Tabs are fixed
244
+ * at construction; each starts as its own one-screen stack. */
245
+ (tabs: UIScreen[]): UIPager
246
+ /** Styled pager + content (a single root screen or an array of tab screens). */
247
+ (style: UIPagerStyle, content: UIScreen | UIScreen[]): UIPager
248
+ /** The pager that owns the currently visible screen, or `null` when the visible screen isn't
249
+ * hosted by one. With pagers nested inside pages, resolves to the **innermost** — so ambient
250
+ * calls act on the pager the user is actually looking at. */
251
+ readonly current: UIPager | null
252
+ /** `UIPager.current.push(screen)` — push onto the pager owning the visible screen, no
253
+ * reference threading. No-op with a console warning when there is no current pager. */
254
+ push(screen: UIScreen): void
255
+ /** `UIPager.current.pop()` — pop the visible pager's current tab (no-op at the tab root, or
256
+ * with a warning when there is no current pager). */
257
+ pop(): void
258
+ /** `UIPager.current.popToRoot()` — unwind the visible pager's current tab to its root. */
259
+ popToRoot(): void
260
+ }
261
+
262
+ function pagerFactory(...args: any[]): UIPager {
263
+ if (args.length === 1) {
264
+ const a = args[0]
265
+ if (Array.isArray(a)) return new PagerElement(null, a) as unknown as UIPager
266
+ if (isScreenArg(a)) return new PagerElement(null, [a]) as unknown as UIPager
267
+ return new PagerElement(a, []) as unknown as UIPager
268
+ }
269
+ const content = args[1]
270
+ return new PagerElement(args[0] ?? null, Array.isArray(content) ? content : content ? [content] : []) as unknown as UIPager
271
+ }
272
+
273
+ /**
274
+ * Create a pager — THE screen-navigation element: an array of screens becomes swipeable sibling
275
+ * tabs, a single screen becomes a plain stack. Navigate within it via `pager.push/pop/replace`
276
+ * (per-tab) and `pager.select(i)` (between tabs), or from any child screen via the ambient
277
+ * `UIPager.push` / `UIPager.pop` / `UIPager.current`. Pages are full `UIScreen`s (own
278
+ * `onOpen`/`onClose`, `makeScrollable()`); give the pager `flexGrow: 1` or a size so it doesn't
279
+ * collapse. `Router` stays the way to open a destination ON TOP of the pager.
280
+ */
281
+ export const UIPager = pagerFactory as unknown as UIPagerConstructor
282
+
283
+ const _resolveCurrent = (): UIPager | null => {
284
+ const cur = _activeStack[_activeStack.length - 1]
285
+ if (!cur) {
286
+ console.warn("UIPager.push/pop: no active pager on the current screen")
287
+ return null
288
+ }
289
+ return cur as unknown as UIPager
290
+ }
291
+
292
+ Object.defineProperty(UIPager, "current", {
293
+ get: () => (_activeStack[_activeStack.length - 1] ?? null) as unknown as UIPager | null,
294
+ })
295
+ ;(UIPager as any).push = (screen: UIScreen) => { _resolveCurrent()?.push(screen) }
296
+ ;(UIPager as any).pop = () => { _resolveCurrent()?.pop() }
297
+ ;(UIPager as any).popToRoot = () => { _resolveCurrent()?.popToRoot() }