lecodes-cli 0.7.2 → 0.8.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 (40) hide show
  1. package/dist/index.js +3005 -725
  2. package/package.json +2 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/canvas/Canvas.ts +16 -0
  5. package/runtime/sdk/gl/Material.ts +4 -0
  6. package/runtime/sdk/inject.ts +7 -4
  7. package/runtime/sdk/plugins/service.ts +47 -0
  8. package/runtime/sdk/runtime/app.ts +21 -1
  9. package/runtime/sdk/runtime/appEvents.ts +25 -0
  10. package/runtime/sdk/runtime/service.ts +3 -2
  11. package/runtime/sdk/ui/NativeView.ts +2 -1
  12. package/runtime/sdk/ui/UI.ts +3 -1
  13. package/runtime/sdk/ui/UIButton.ts +10 -5
  14. package/runtime/sdk/ui/UIContainer.ts +16 -9
  15. package/runtime/sdk/ui/UIImage.ts +5 -2
  16. package/runtime/sdk/ui/UIInput.ts +75 -8
  17. package/runtime/sdk/ui/UIModal.ts +149 -0
  18. package/runtime/sdk/ui/UINode.ts +113 -50
  19. package/runtime/sdk/ui/UIPager.ts +297 -0
  20. package/runtime/sdk/ui/UIPopover.ts +106 -0
  21. package/runtime/sdk/ui/UIScreen.ts +11 -10
  22. package/runtime/sdk/ui/UIScrollable.ts +2 -1
  23. package/runtime/sdk/ui/UISpacer.ts +1 -1
  24. package/runtime/sdk/ui/UIText.ts +2 -1
  25. package/runtime/sdk/ui/UIVideo.ts +2 -1
  26. package/runtime/sdk/ui/UIVirtualizedList.ts +2 -1
  27. package/runtime/sdk/ui/UIWidget.ts +19 -22
  28. package/runtime/sdk/ui/theme.ts +66 -0
  29. package/runtime/sdk-types.json +1 -1
  30. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  31. package/runtime/sdk/compile/assetMacro.ts +0 -45
  32. package/runtime/sdk/compile/bundler.ts +0 -207
  33. package/runtime/sdk/compile/compileProject.ts +0 -113
  34. package/runtime/sdk/compile/header.ts +0 -46
  35. package/runtime/sdk/compile/index.ts +0 -18
  36. package/runtime/sdk/compile/libraryImports.ts +0 -47
  37. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  38. package/runtime/sdk/compile/sourcemap.ts +0 -25
  39. package/runtime/sdk/kit/UITabs.ts +0 -105
  40. 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) {
@@ -258,7 +266,7 @@ export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, del
258
266
 
259
267
  // NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
260
268
  // which makes every node type INVARIANT in its own type params (and in `this`). That is what made
261
- // `UIScreen<false>` unassignable to `UIScreen<boolean>` / `HostedScreen`, and would block any
269
+ // `UIScreen<false>` unassignable to `UIScreen<boolean>` (a navigator page), and would block any
262
270
  // UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
263
271
  // `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
264
272
  // R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
@@ -276,13 +284,42 @@ export type OrientationStyles<T> = {
276
284
  export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
277
285
  export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
278
286
 
279
- 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"])
280
309
 
281
310
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
282
311
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
283
312
 
284
313
  export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void
285
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
+
286
323
  export class Element<T extends string> {
287
324
  readonly type: T
288
325
  /** Author-given semantic name (LeCodes `name`) — see BaseStyle.name. Extracted from the options
@@ -299,12 +336,21 @@ export class Element<T extends string> {
299
336
  * is the effect's only strong owner, so discarding a subtree lets its bindings collect with it. */
300
337
  _fx?: Map<string, any>
301
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.
302
347
  constructor(type: T, style: any) {
303
348
  this.type = type
304
349
  if (style && typeof style.name === "string" && style.name.length > 0) {
305
350
  this.name = style.name
306
351
  delete style.name
307
352
  }
353
+ if (style) this._mapStyle?.(style)
308
354
  this._style = style
309
355
  if (style) {
310
356
  const fns = extractBindings(this, style)
@@ -331,8 +377,12 @@ export class Element<T extends string> {
331
377
 
332
378
  animateTo(style: any): this{
333
379
  _creatorUI.animateTo(this._id, style)
334
- if (style.commit !== false) {
335
- 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
+ }
336
386
  }
337
387
  return this
338
388
  }
@@ -380,7 +430,7 @@ export class Element<T extends string> {
380
430
  }
381
431
 
382
432
  protected ll?: OnLayoutCallback[]
383
- onLayout(onLayout: OnLayoutCallback): this {
433
+ onLayout(onLayout: OnLayoutCallback): this {
384
434
  if (this.ll) {
385
435
  this.ll.push(onLayout)
386
436
  } else {
@@ -388,6 +438,19 @@ export class Element<T extends string> {
388
438
  }
389
439
  return this
390
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
+ }
391
454
  }
392
455
 
393
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() }
@@ -0,0 +1,106 @@
1
+ import { ModalElement, type UIModal, type UIModalStyle } from "./UIModal"
2
+ import { Presentable } from "./presentable"
3
+ import { device } from "../runtime/device"
4
+ import type { BoundingClientRect, ChildrenFn, UINodeChild } from "./UINode"
5
+
6
+ export type UIPopoverStyle = UIModalStyle
7
+
8
+ /** What a popover can anchor to: any element (it reads `getBoundingClientRect()`), or a raw
9
+ * `{x, y}` point — a long-press context menu anchors to the touch position. */
10
+ export type UIPopoverAnchor = { getBoundingClientRect(): BoundingClientRect | null } | { x: number, y: number }
11
+
12
+ const POPOVER_ANIM_MS = 120
13
+ const ANCHOR_GAP = 4 // between the anchor's edge and the popover
14
+ const VIEWPORT_MARGIN = 8 // minimum distance kept to every screen edge
15
+
16
+ /**
17
+ * An anchored menu: a `UIModal` whose scrim is transparent (invisible but still intercepting —
18
+ * an outside tap dismisses, and nothing underneath can scroll while it's open) and whose
19
+ * position comes from the anchor passed to `show(anchor)`: below the anchor's left edge,
20
+ * flipped above when there's no room, clamped into the viewport. Position is computed once per
21
+ * show — anchored popovers don't follow their anchor. Backs dropdowns, context menus, and
22
+ * tooltips; create it **once** at module scope and reuse it.
23
+ */
24
+ export interface UIPopover extends UIModal {
25
+ /** Position next to `anchor` (an element or an `{x, y}` point), attach to
26
+ * `Presentable.current` (unless an owner was set via `attachTo`), and play the entrance
27
+ * transition. Without an anchor the popover shows wherever its own style puts it. */
28
+ show(anchor?: UIPopoverAnchor): void,
29
+ }
30
+
31
+ export class PopoverElement extends ModalElement {
32
+ private _anchorRect?: { left: number, top: number, right: number, bottom: number }
33
+ private _placedLeft?: number
34
+ private _placedTop?: number
35
+
36
+ constructor(style: UIPopoverStyle, children: UINodeChild[] | ChildrenFn) {
37
+ super({ overlayColor: "transparent", ...style }, children)
38
+ this.transition({ opacity: 0, duration: POPOVER_ANIM_MS })
39
+ // The flip/clamp decision needs the popover's measured size, which exists only after the
40
+ // mount lays it out — so placement is corrected on layout, inside the entrance fade.
41
+ this.onLayout(({ width, height }) => this._place(width, height))
42
+ }
43
+
44
+ show(anchor?: UIPopoverAnchor): void {
45
+ if (anchor) {
46
+ const r = "getBoundingClientRect" in anchor
47
+ ? anchor.getBoundingClientRect()
48
+ : { left: anchor.x, top: anchor.y, right: anchor.x, bottom: anchor.y }
49
+ if (r) {
50
+ this._anchorRect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
51
+ this._placedLeft = undefined
52
+ this._placedTop = undefined
53
+ // Preliminary spot (below, left-aligned) so the first paint is already close; _place
54
+ // refines it once the size is known.
55
+ this.style({ left: r.left, top: r.bottom + ANCHOR_GAP })
56
+ }
57
+ } else {
58
+ this._anchorRect = undefined // unanchored show: the user's own style owns the position
59
+ }
60
+ // A menu belongs to the page it opened on: it hides with it and rides its transition. An
61
+ // explicit attachTo() owner (e.g. a scene HUD's popover) is respected.
62
+ if (!this._owner) this.attachTo(Presentable.current)
63
+ super.show()
64
+ }
65
+
66
+ private _place(width: number, height: number): void {
67
+ const anchor = this._anchorRect
68
+ if (!anchor || !this._open) return
69
+ const displayWidth = device.width, displayHeight = device.height // 0 on hosts without a size
70
+ let left = anchor.left
71
+ let top = anchor.bottom + ANCHOR_GAP
72
+ if (displayWidth > 0) {
73
+ left = Math.min(left, displayWidth - width - VIEWPORT_MARGIN)
74
+ left = Math.max(left, VIEWPORT_MARGIN)
75
+ }
76
+ if (displayHeight > 0 && top + height > displayHeight - VIEWPORT_MARGIN) {
77
+ const above = anchor.top - ANCHOR_GAP - height
78
+ top = above >= VIEWPORT_MARGIN ? above : Math.max(VIEWPORT_MARGIN, displayHeight - VIEWPORT_MARGIN - height)
79
+ }
80
+ // Idempotence guard: hosts re-fire onLayout on recalc, and writing the same position would
81
+ // trigger another recalc — only write when the target actually moved.
82
+ if (this._placedLeft !== undefined && Math.abs(left - this._placedLeft) < 0.5
83
+ && this._placedTop !== undefined && Math.abs(top - this._placedTop) < 0.5) return
84
+ this._placedLeft = left
85
+ this._placedTop = top
86
+ this.style({ left, top })
87
+ }
88
+ }
89
+
90
+ export function UIPopover(): UIPopover;
91
+ export function UIPopover(children: UINodeChild[] | ChildrenFn): UIPopover;
92
+ export function UIPopover(style: UIPopoverStyle): UIPopover;
93
+ export function UIPopover(style: UIPopoverStyle, children: UINodeChild[] | ChildrenFn): UIPopover;
94
+ export function UIPopover(...args: [] | [UINodeChild[] | ChildrenFn | UIPopoverStyle] | [UIPopoverStyle, children: UINodeChild[] | ChildrenFn]): UIPopover {
95
+ if (args.length === 0) {
96
+ return new PopoverElement({}, [])
97
+ } else if (args.length === 1) {
98
+ if (Array.isArray(args[0]) || typeof args[0] === "function") {
99
+ return new PopoverElement({}, args[0])
100
+ } else {
101
+ return new PopoverElement(args[0], [])
102
+ }
103
+ } else {
104
+ return new PopoverElement(args[0], args[1])
105
+ }
106
+ }