lecodes-cli 0.11.0 → 0.13.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.
@@ -34,10 +34,15 @@
34
34
 
35
35
  import { Aspect, type AspectCtor, type With } from "../core/Aspect"
36
36
  import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
37
+ import {
38
+ collectRefDeps, isEditMode, isNodeRef, resolveRefs, use, ref, make, EDIT_FLAG,
39
+ type AspectEntry, type MakeEntry as SharedMakeEntry,
40
+ } from "./grammar"
37
41
  import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
38
42
  import type { ColorInput } from "../core/color"
39
43
  import type { Vec3Like } from "../math/vec"
40
44
  import { Scene, type SceneOptions } from "../gl/Scene"
45
+ import { CAMERA_DEFAULTS } from "../gl/Camera"
41
46
  import { Node } from "../gl/Node"
42
47
  import { Mesh } from "../gl/Mesh"
43
48
  import { Model } from "../gl/Model"
@@ -63,65 +68,13 @@ export type MaterialDef =
63
68
  // An imported/shared Material instance — valid at runtime; the editor shows it read-only.
64
69
  | Material
65
70
 
66
- /** One aspect to attach, created by `use(Ctor, props)`. */
67
- export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
68
- /** @internal grammar marker. */
69
- readonly __use: true
70
- ctor: AspectCtor<A>
71
- props?: Partial<A>
72
- }
73
-
74
- /** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
75
- * typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
76
- export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
77
- ({ __use: true, ctor, props })
78
-
79
- /** A `make(fn, args)` source entry — see {@link make}. */
80
- export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = {
81
- /** @internal grammar marker. */
82
- readonly __make: true
83
- fn: (args: A) => Node | Promise<Node>
84
- args?: A
85
- }
71
+ // The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
72
+ // re-exported here so this module remains the one import site for scene-file machinery.
73
+ export { use, ref, make }
74
+ export type { AspectEntry }
86
75
 
87
- /**
88
- * A code-created source in a scene file: `tower: { make: make(buildTower, { floors: 5 }), … }`.
89
- * The factory runs after every scene node exists (so `ref()` args resolve, forward references
90
- * included) and its returned subtree mounts under the def node — the def's transform stays
91
- * editor-owned, so moving the node never re-calls the factory. Args must be literal data (same
92
- * grammar as aspect props); in the editor they edit as fields, and an arg change re-CALLS the
93
- * factory live — the code is already in the bundle, so no compile happens. Keep factories pure
94
- * builders: same args → same subtree, no side effects outside the returned nodes.
95
- */
96
- export const make = <A extends Record<string, unknown>>(
97
- fn: (args: A) => Node | Promise<Node>, args?: A,
98
- ): MakeEntry<A> => ({ __make: true, fn, args })
99
-
100
- /**
101
- * Reference another scene node by name in an aspect's props:
102
- * `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live Node when aspects
103
- * attach — after EVERY node of the scene exists, so forward references work. An unknown name
104
- * resolves to `null` (type your aspect field `Node | null`). Scene files only — hand-written code
105
- * passes nodes directly: `node.aspect(Road, { from: nodes.pointA })`.
106
- */
107
- export const ref = <T extends Node = Node>(name: string): T => ({ $ref: name } as unknown as T)
108
-
109
- const isNodeRef = (v: unknown): v is { $ref: string } =>
110
- typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
111
-
112
- /** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep). */
113
- const resolveRefs = (props: Record<string, unknown> | undefined, nodes: Record<string, Node>): Record<string, unknown> | undefined => {
114
- if (!props) return props
115
- let out: Record<string, unknown> | undefined
116
- for (const [ k, v ] of Object.entries(props)) {
117
- if (isNodeRef(v)) {
118
- ;(out ??= { ...props })[k] = nodes[v.$ref] ?? null
119
- } else if (Array.isArray(v) && v.some(isNodeRef)) {
120
- ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
121
- }
122
- }
123
- return out ?? props
124
- }
76
+ /** A `make(fn, args)` source entry whose factory returns a 3D {@link Node}. */
77
+ export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = SharedMakeEntry<A, Node>
125
78
 
126
79
  /** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
127
80
  * (`overrides` on a model/prefab node, keyed by part path). */
@@ -132,8 +85,19 @@ export type ModelOverrideDef = {
132
85
  visible?: boolean
133
86
  }
134
87
 
135
- /** The `camera: {}` source block — reserved for projection settings (fov, …) later. */
136
- export type CameraNodeDef = Record<string, never>
88
+ /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
89
+ * block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
90
+ export type CameraProjectionDef = {
91
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
92
+ fov?: number
93
+ /** Near clip distance (default 0.01). */
94
+ near?: number
95
+ /** Far clip distance = view range (default 1000); geometry past it is culled. */
96
+ far?: number
97
+ }
98
+
99
+ /** The `camera: {}` source block — projection settings for the node that drives the view. */
100
+ export type CameraNodeDef = CameraProjectionDef
137
101
 
138
102
  export type SceneNodeDef = {
139
103
  // -- source (at most one; none = plain group node) --
@@ -163,7 +127,7 @@ export type SceneNodeDef = {
163
127
  eulerAngles?: Vec3Like
164
128
  scale?: Vec3Like | number
165
129
  visible?: boolean
166
- /** Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect. */
130
+ /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
167
131
  locked?: boolean
168
132
  castShadows?: boolean
169
133
  receiveShadows?: boolean
@@ -172,7 +136,7 @@ export type SceneNodeDef = {
172
136
  children?: Record<string, SceneNodeDef>
173
137
  }
174
138
 
175
- export type SceneCameraDef = {
139
+ export type SceneCameraDef = CameraProjectionDef & {
176
140
  position?: Vec3Like
177
141
  /** Point the camera looks at. */
178
142
  target?: Vec3Like
@@ -272,9 +236,6 @@ const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverride
272
236
 
273
237
  // ---- runtime -------------------------------------------------------------------
274
238
 
275
- const EDIT_FLAG = "__lecodesSceneEdit"
276
- const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
277
-
278
239
  const resolveMaterial = (def: MaterialDef): Material => {
279
240
  if (def instanceof Material) return def
280
241
  if ("lit" in def) return Material.lit(def.lit)
@@ -309,7 +270,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
309
270
  if (def.eulerAngles) node.eulerAngles = def.eulerAngles
310
271
  if (def.scale !== undefined) node.scale = def.scale
311
272
  if (def.visible !== undefined) node.visible = def.visible
312
- // editor-only flag (the harness reads it when targeting the gizmo); inert at runtime
273
+ // editor-only flag (the harness's manipulation layer consults it); inert at runtime
313
274
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
314
275
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
315
276
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
@@ -368,16 +329,27 @@ class CameraRig extends Aspect<"__cameraRig"> {
368
329
  }
369
330
  }
370
331
 
371
- /** The first camera-source def in file order (depth-first), or null. */
372
- const findCameraName = (defs?: Record<string, SceneNodeDef>): string | null => {
332
+ /** The first camera-source def in file order (depth-first) — its name and block — or null. */
333
+ const findCamera = (defs?: Record<string, SceneNodeDef>): { name: string, def: CameraNodeDef } | null => {
373
334
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
374
- if (nd.camera !== undefined) return name
375
- const inner = findCameraName(nd.children)
335
+ if (nd.camera !== undefined) return { name, def: nd.camera }
336
+ const inner = findCamera(nd.children)
376
337
  if (inner) return inner
377
338
  }
378
339
  return null
379
340
  }
380
341
 
342
+ /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
343
+ * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
344
+ * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
345
+ const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
346
+ const { fov, near, far } = def
347
+ if (!reset && fov === undefined && near === undefined && far === undefined) return
348
+ scene.camera.setProjection(reset
349
+ ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
350
+ : { fov, near, far })
351
+ }
352
+
381
353
  // ---- editor-run aspects (generators) ------------------------------------------------------------
382
354
  // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
383
355
  // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
@@ -429,15 +401,6 @@ type EditorRun = {
429
401
  deps: Set<string>
430
402
  }
431
403
 
432
- const collectRefDeps = (props: Record<string, unknown>): Set<string> => {
433
- const deps = new Set<string>()
434
- for (const v of Object.values(props)) {
435
- if (isNodeRef(v)) deps.add(v.$ref)
436
- else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) deps.add(el.$ref)
437
- }
438
- return deps
439
- }
440
-
441
404
  const safeRebuild = (run: EditorRun): void => {
442
405
  // a throwing generator must not take the editor session down with it
443
406
  try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostName}":`, e) }
@@ -626,18 +589,22 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
626
589
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
627
590
 
628
591
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
629
- // (CameraRig), in edit mode it only seeds the editor's starting viewpoint
630
- const camName = findCameraName(def.nodes)
631
- const camNode = camName ? nodes[camName] : undefined
632
- if (camNode) {
592
+ // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
593
+ // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
594
+ // lens the running app will use.
595
+ const cam = findCamera(def.nodes)
596
+ const camNode = cam ? nodes[cam.name] : undefined
597
+ if (cam && camNode) {
633
598
  scene.camera.position = camNode.worldPosition
634
599
  scene.camera.quaternion = camNode.worldQuaternion
600
+ applyCameraProjection(scene, cam.def)
635
601
  if (!isEditMode()) {
636
602
  ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
637
603
  }
638
604
  } else if (def.camera) {
639
605
  if (def.camera.position) scene.camera.position = def.camera.position
640
606
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
607
+ applyCameraProjection(scene, def.camera)
641
608
  }
642
609
 
643
610
  return { scene, nodes }
@@ -734,6 +701,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
734
701
  dispose(old) // fresh already replaced nodes[name] in build(), so it survives
735
702
  nodes[name] = fresh
736
703
  }
704
+ // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
705
+ // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
706
+ if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
737
707
  return fresh
738
708
  }
739
709
 
@@ -57,7 +57,7 @@ export type EditorToolHooks = {
57
57
  * share the generic wand icon, so any editor with two or more tools should set it. */
58
58
  icon?: string
59
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. */
60
+ * Viewport clicks route to the tool while it is active. */
61
61
  onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
62
62
  }
63
63
 
@@ -0,0 +1,85 @@
1
+ // The scene-file literal grammar markers, shared by `.scene.ts` (defineScene, 3D) and
2
+ // `.scene2d.ts` (defineScene2d, 2D): `use(Ctor, props)` aspect entries, `ref('name')` node
3
+ // references, `make(fn, args)` code-built sources — plus the ref-resolution helpers both loaders
4
+ // run. One implementation so the two formats can't drift; the engine-specific node/def types stay
5
+ // in their own modules (defineScene.ts / defineScene2d.ts), which re-export these for user code.
6
+
7
+ import type { Aspect, AspectCtor } from "../core/Aspect"
8
+ import type { Node } from "../gl/Node"
9
+
10
+ /** One aspect to attach, created by `use(Ctor, props)`. */
11
+ export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
12
+ /** @internal grammar marker. */
13
+ readonly __use: true
14
+ ctor: AspectCtor<A>
15
+ props?: Partial<A>
16
+ }
17
+
18
+ /** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
19
+ * typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
20
+ export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
21
+ ({ __use: true, ctor, props })
22
+
23
+ /** A `make(fn, args)` source entry — see {@link make}. `N` is the node family the factory returns
24
+ * (3D `Node` by default; a `.scene2d.ts` file's factories return `Node2D`). */
25
+ export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>, N = Node> = {
26
+ /** @internal grammar marker. */
27
+ readonly __make: true
28
+ fn: (args: A) => N | Promise<N>
29
+ args?: A
30
+ }
31
+
32
+ /**
33
+ * A code-created source in a scene file: `tower: { make: make(buildTower, { floors: 5 }), … }`.
34
+ * The factory runs after every scene node exists (so `ref()` args resolve, forward references
35
+ * included) and its returned subtree mounts under the def node — the def's transform stays
36
+ * editor-owned, so moving the node never re-calls the factory. Args must be literal data (same
37
+ * grammar as aspect props); in the editor they edit as fields, and an arg change re-CALLS the
38
+ * factory live — the code is already in the bundle, so no compile happens. Keep factories pure
39
+ * builders: same args → same subtree, no side effects outside the returned nodes.
40
+ */
41
+ export const make = <A extends Record<string, unknown>, N>(
42
+ fn: (args: A) => N | Promise<N>, args?: A,
43
+ ): MakeEntry<A, N> => ({ __make: true, fn, args })
44
+
45
+ /**
46
+ * Reference another scene node by name in an aspect's props:
47
+ * `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live node when aspects
48
+ * attach — after EVERY node of the scene exists, so forward references work. An unknown name
49
+ * resolves to `null` (type your aspect field `Node | null`). Scene files only — hand-written code
50
+ * passes nodes directly: `node.aspect(Road, { from: nodes.pointA })`.
51
+ */
52
+ export const ref = <T = Node>(name: string): T => ({ $ref: name } as unknown as T)
53
+
54
+ export const isNodeRef = (v: unknown): v is { $ref: string } =>
55
+ typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
56
+
57
+ /** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep). */
58
+ export const resolveRefs = <N>(
59
+ props: Record<string, unknown> | undefined, nodes: Record<string, N>,
60
+ ): Record<string, unknown> | undefined => {
61
+ if (!props) return props
62
+ let out: Record<string, unknown> | undefined
63
+ for (const [ k, v ] of Object.entries(props)) {
64
+ if (isNodeRef(v)) {
65
+ ;(out ??= { ...props })[k] = nodes[v.$ref] ?? null
66
+ } else if (Array.isArray(v) && v.some(isNodeRef)) {
67
+ ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
68
+ }
69
+ }
70
+ return out ?? props
71
+ }
72
+
73
+ /** Names of the nodes a props record's ref() values point at (editor dep tracking). */
74
+ export const collectRefDeps = (props: Record<string, unknown>): Set<string> => {
75
+ const deps = new Set<string>()
76
+ for (const v of Object.values(props)) {
77
+ if (isNodeRef(v)) deps.add(v.$ref)
78
+ else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) deps.add(el.$ref)
79
+ }
80
+ return deps
81
+ }
82
+
83
+ /** The edit-mode flag an editor host sets before running the bundle (shared by both formats). */
84
+ export const EDIT_FLAG = "__lecodesSceneEdit"
85
+ export const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
@@ -11,29 +11,30 @@
11
11
 
12
12
  import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
13
13
  import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
14
- import { Element, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Classes, type Style } from "./UINode"
14
+ import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
15
15
 
16
16
  export type UINativeViewStyle = ElementStyle & DrawableStyle
17
17
 
18
18
  /** An instance of a host-registered platform view (map, QR scanner, camera preview, …) —
19
19
  * embeddable among a screen's children AND fullscreen-presentable (`open()`); one instance lives
20
20
  * in one place at a time. Talk to it via the `call`/`on` channel. */
21
- export interface NativeView {
21
+ export interface NativeView extends UIElementBase<UINativeViewStyle, DrawableStyle & BaseStyle> {
22
22
  readonly type: "native",
23
23
  /** The registered view kind this instance resolves to (the `registerView` name). */
24
24
  readonly viewName: string,
25
25
  /** Creation params, passed to the host factory (JSON-serializable). */
26
26
  readonly params: any,
27
- style: Style<this, UINativeViewStyle>,
28
- animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
29
- animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
30
27
 
31
28
  /** Show fullscreen as the current destination (see Presentable). */
32
29
  open(options?: PresentOptions): void,
30
+ /** Dismiss if this is the visible destination. */
33
31
  close(): void,
34
32
 
33
+ /** Became the visible destination. */
35
34
  onOpen(callback: () => void): this
35
+ /** Stopped being the visible destination. */
36
36
  onClose(callback: () => void): this
37
+ /** Hardware/system back while current. */
37
38
  onBackPressed(callback: () => void): this
38
39
 
39
40
  /** Invoke a method on the native view (`map.call("setCenter", [lng, lat])`). Args and the
@@ -42,13 +43,6 @@ export interface NativeView {
42
43
  /** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
43
44
  on(event: string, callback: (data?: any) => void): this
44
45
  off(event: string, callback: (data?: any) => void): this
45
-
46
- onLayout(onLayout: OnLayoutCallback): this
47
- getBoundingClientRect(): BoundingClientRect | null
48
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
49
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
50
- * Classes cascade to descendants. See {@link Classes}. */
51
- readonly class: Classes<this>
52
46
  }
53
47
 
54
48
  let nextViewId = 1
@@ -51,10 +51,6 @@ export interface UIBottomSheet extends UIWidget {
51
51
  /** `dismissible(false)`: scrim taps, the back button and dragging below the lowest detent no
52
52
  * longer close the sheet (a drag collapses to the lowest detent instead). Default `true`. */
53
53
  dismissible(enabled: boolean): this,
54
-
55
- append(...nodes: UINodeChild[]): this
56
- insert(index: number, ...nodes: UINodeChild[]): this
57
- remove(...nodes: UINodeChild[]): this
58
54
  }
59
55
 
60
56
  export class BottomSheetElement extends ModalElement {
@@ -1,18 +1,14 @@
1
1
  import { ClickEvent, TouchStartEvent, LongPressEvent } from "../runtime/touch"
2
2
  import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType, LongPressEvent as LongPressEventType } from "../runtime/touch"
3
- import { type ElementStyle, type ContainerStyle, type Classes, type Style, ContainerElement, buildUI, type DrawableStyle, type UIChildArg, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
3
+ import { type ElementStyle, type ContainerStyle, ContainerElement, buildUI, type DrawableStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, type BaseStyle, type Color } from "./UINode"
4
4
 
5
5
  export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { rippleColor?: Color | "default" }
6
6
 
7
7
  /** A tappable container — row + centered children by default. Press feedback via the `onPressed`
8
8
  * style block, the `$pressed` class, or `rippleColor`. */
9
- export interface UIButton {
9
+ export interface UIButton extends UIContainerBase<UIButtonStyle & { onPressed?: UIButtonStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
10
10
  readonly type: "button",
11
11
 
12
- style: Style<this, UIButtonStyle & { onPressed?: UIButtonStyle & { duration?: number } }>,
13
- animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
14
- animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
15
-
16
12
  /** Fired on a completed tap/click. */
17
13
  onClick(callback: (ev: ClickEventType<UIButton>) => void): this,
18
14
  /** Fired when a touch begins; `ev.track(...)` takes over the rest of the gesture (dragging). */
@@ -23,21 +19,6 @@ export interface UIButton {
23
19
  onLongPress(callback: (ev: LongPressEventType<UIButton>) => void): this,
24
20
  /** Is a finger currently down on the button? */
25
21
  isPressed(): boolean,
26
-
27
- append(...nodes: UINodeChild[]): this
28
- insert(index: number, ...nodes: UINodeChild[]): this
29
- remove(...nodes: UINodeChild[]): this
30
- setContent(nodes: UINodeChild[] | ChildrenFn): this
31
-
32
- onLayout(onLayout: OnLayoutCallback): this
33
- getBoundingClientRect(): BoundingClientRect | null
34
-
35
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
36
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
37
- * Classes cascade to descendants. See {@link Classes}. */
38
- readonly class: Classes<this>
39
-
40
- readonly children: UINodeChild[],
41
22
  }
42
23
 
43
24
 
@@ -1,28 +1,10 @@
1
- import { ContainerElement, buildUI, type AnimateStyle, type AppearStyle, type ChildrenFn, type ContainerStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Classes, type Style, type StyleFn, type UIChildArg, type UINode, type UINodeChild } from "./UINode"
1
+ import { ContainerElement, buildUI, type ChildrenFn, type ContainerStyle, type DrawableStyle, type ElementStyle, type UIChildArg, type UIContainerBase, type UINodeChild } from "./UINode"
2
2
 
3
3
  export type UIContainerStyle = ElementStyle & DrawableStyle & ContainerStyle
4
4
 
5
5
  /** A vertical flex container — children stack top to bottom. */
6
- export interface UIColumn {
6
+ export interface UIColumn extends UIContainerBase<UIContainerStyle> {
7
7
  readonly type: "column",
8
- style: Style<this, UIContainerStyle>,
9
- animateTo: AnimateStyle<this, UIContainerStyle>
10
- animateFrom: AnimateStyle<this, UIContainerStyle>
11
-
12
- append(...nodes: UINodeChild[]): UIColumn
13
- insert(index: number, ...nodes: UINodeChild[]): UIColumn
14
- remove(...nodes: UINodeChild[]): UIColumn
15
- setContent(nodes: UINodeChild[] | ChildrenFn): this
16
-
17
- onLayout(onLayout: OnLayoutCallback): this
18
- getBoundingClientRect(): BoundingClientRect | null
19
-
20
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
21
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
22
- * Classes cascade to descendants. See {@link Classes}. */
23
- readonly class: Classes<this>
24
-
25
- readonly children: UINodeChild[]
26
8
  }
27
9
  const makeColumn = (style: any, children: any) => new ContainerElement("column", style, children)
28
10
 
@@ -44,26 +26,8 @@ export function UIColumn(...args: any[]): UIColumn {
44
26
  }
45
27
 
46
28
  /** A horizontal flex container — children flow left to right. */
47
- export interface UIRow {
29
+ export interface UIRow extends UIContainerBase<UIContainerStyle> {
48
30
  readonly type: "row",
49
- style: Style<this, UIContainerStyle>,
50
- animateTo: AnimateStyle<this, UIContainerStyle>,
51
- animateFrom: AnimateStyle<this, UIContainerStyle>,
52
-
53
- append(...nodes: UINodeChild[]): UIRow
54
- insert(index: number, ...nodes: UINodeChild[]): UIRow
55
- remove(...nodes: UINodeChild[]): UIRow
56
- setContent(nodes: UINodeChild[] | ChildrenFn): this
57
-
58
- onLayout(onLayout: OnLayoutCallback): this
59
- getBoundingClientRect(): BoundingClientRect | null
60
-
61
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
62
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
63
- * Classes cascade to descendants. See {@link Classes}. */
64
- readonly class: Classes<this>
65
-
66
- readonly children: UINodeChild[],
67
31
  }
68
32
  const ROW_DEFAULTS = { flexDirection: "row" } as const
69
33
  const makeRow = (style: any, children: any) => new ContainerElement("row", style, children)
@@ -78,26 +42,8 @@ export function UIRow(...args: any[]): UIRow {
78
42
  }
79
43
 
80
44
  /** A centering container — `justifyContent`/`alignItems` default to center. */
81
- export interface UIBox {
45
+ export interface UIBox extends UIContainerBase<UIContainerStyle> {
82
46
  readonly type: "box",
83
- style: Style<this, UIContainerStyle>,
84
- animateTo: AnimateStyle<this, UIContainerStyle>,
85
- animateFrom: AnimateStyle<this, UIContainerStyle>,
86
-
87
- append(...nodes: UINodeChild[]): this
88
- insert(index: number, ...nodes: UINodeChild[]): this
89
- remove(...nodes: UINodeChild[]): this
90
- setContent(nodes: UINodeChild[] | ChildrenFn): this
91
-
92
- onLayout(onLayout: OnLayoutCallback): this
93
- getBoundingClientRect(): BoundingClientRect | null
94
-
95
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
96
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
97
- * Classes cascade to descendants. See {@link Classes}. */
98
- readonly class: Classes<this>
99
-
100
- readonly children: UINodeChild[],
101
47
  }
102
48
  const BOX_DEFAULTS = { justifyContent: "center", alignItems: "center" } as const
103
49
 
@@ -2,7 +2,7 @@ import type { FetchResponse } from "../runtime/fetch"
2
2
  import type { SvgSourceValue as SvgSource } from "../runtime/misc"
3
3
  import type { File } from "../runtime/fetch"
4
4
  import { Canvas, isCanvas } from "../canvas/Canvas"
5
- import { Element, type AnimateStyle, type Color, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Classes, type Style } from "./UINode"
5
+ import { Element, type Color, type ElementStyle, type UIElementBase } from "./UINode"
6
6
 
7
7
  export type UIImageStyle = ElementStyle & { borderRadius?: number, objectFit?: "cover" | "contain" | "fill", tintColor?: Color }
8
8
 
@@ -23,27 +23,16 @@ export type ResolvedImageSource = string | FetchResponse | File | SvgSource | Ca
23
23
 
24
24
  /** An image element. Sources: URL / `asset(...)`, fetch response / File, an SVG source
25
25
  * (`assetIcon`), or a live Canvas (`canvas.update()` refreshes the node in place). */
26
- export interface UIImage {
26
+ export interface UIImage extends UIElementBase<UIImageStyle> {
27
27
  readonly type: "image",
28
28
  /** The image source — writable to swap the picture in place. */
29
29
  get src(): ResolvedImageSource
30
30
  set src(value: ImageSource)
31
- style: Style<this, UIImageStyle>
32
- animateTo: AnimateStyle<this, UIImageStyle>
33
- animateFrom: AnimateStyle<this, UIImageStyle>
34
31
 
35
32
  /** Render only a sub-rectangle of the source texture (texture pixels) — the atlas/spritesheet
36
33
  * primitive: crop once, then swap the rect per frame for sprite animation. The rect's w/h
37
34
  * become the node's intrinsic size and override `objectFit` — the cropped frame fills the box. */
38
35
  setSourceRect(x: number, y: number, w: number, h: number): this
39
-
40
- onLayout(onLayout: OnLayoutCallback): this
41
- getBoundingClientRect(): BoundingClientRect | null
42
-
43
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
44
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
45
- * Classes cascade to descendants. See {@link Classes}. */
46
- readonly class: Classes<this>
47
36
  }
48
37
 
49
38
  class ImageElement extends Element<"image"> {
@@ -1,4 +1,4 @@
1
- import { type ElementStyle, type DrawableStyle, type Classes, type Style, Element, type TextStyle, type Color, type OnLayoutCallback, type BoundingClientRect, type BaseStyle, type AnimateStyle } from "./UINode";
1
+ import { type ElementStyle, type DrawableStyle, Element, type TextStyle, type Color, type BaseStyle, type UIElementBase } from "./UINode";
2
2
 
3
3
  type InputStyle = {
4
4
  placeholder?: string,
@@ -51,7 +51,7 @@ type SubmitStyle = {
51
51
 
52
52
  /** A single-line text input. Keyboard/picker kind via the `type` style, return-key behavior via
53
53
  * `enterKey` + `onSubmit`; keyboard policy on `keyboardShrink`/`keyboardDismiss`. */
54
- export interface UIInput {
54
+ export interface UIInput extends UIElementBase<UIInputStyle & SubmitStyle & { onFocused?: UIInputStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
55
55
  readonly type: "input",
56
56
 
57
57
  /**
@@ -62,51 +62,39 @@ export interface UIInput {
62
62
  */
63
63
  value: string
64
64
 
65
- style: Style<this, UIInputStyle & SubmitStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
66
- animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
67
- animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
68
-
65
+ /** The text changed — fires on every user edit. */
69
66
  onChange(callback: (value: string) => void): UIInput
67
+ /** Gained focus (keyboard opens). */
70
68
  onFocus(callback: () => void): UIInput
69
+ /** Lost focus. */
71
70
  onBlur(callback: () => void): UIInput
72
71
  /** The keyboard's return key was pressed. `value` is the current text. See `enterKey`. */
73
72
  onSubmit(callback: (value: string) => void): UIInput
74
- onLayout(onLayout: OnLayoutCallback): this
75
- getBoundingClientRect(): BoundingClientRect | null
76
73
 
77
74
  /** Focus the input (opens the keyboard). No-op before mount. */
78
75
  focus(): void
79
76
  /** Release focus (dismisses the keyboard when this input held it). */
80
77
  blur(): void
81
-
82
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
83
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
84
- * Classes cascade to descendants. See {@link Classes}. */
85
- readonly class: Classes<this>
86
78
  }
87
79
 
88
80
  /** A multi-line text field — see the {@link UITextArea} factory for the auto-grow contract. */
89
- export interface UITextArea {
81
+ export interface UITextArea extends UIElementBase<UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
90
82
  readonly type: "textarea",
91
83
 
92
84
  /** The field's text — same contract as {@link UIInput.value}: settable before mount to pre-fill. */
93
85
  value: string
94
86
 
95
- style: Style<this, UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
96
-
87
+ /** The text changed — fires on every user edit. */
97
88
  onChange(callback: (value: string) => void): UITextArea
89
+ /** Gained focus (keyboard opens). */
98
90
  onFocus(callback: () => void): UITextArea
91
+ /** Lost focus. */
99
92
  onBlur(callback: () => void): UITextArea
100
93
 
101
94
  /** Focus the text area (opens the keyboard). No-op before mount. */
102
95
  focus(): void
103
96
  /** Release focus (dismisses the keyboard when this text area held it). */
104
97
  blur(): void
105
-
106
- /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
107
- * `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
108
- * Classes cascade to descendants. See {@link Classes}. */
109
- readonly class: Classes<this>
110
98
  }
111
99
 
112
100
  class InputElement<T extends "input" | "textarea"> extends Element<T> {
@@ -50,10 +50,6 @@ export interface UIModal extends UIWidget {
50
50
  /** `dismissible(false)` keeps scrim taps and the back button from closing the modal
51
51
  * (forced-choice dialogs). Default `true`. */
52
52
  dismissible(enabled: boolean): this,
53
-
54
- append(...nodes: UINodeChild[]): this
55
- insert(index: number, ...nodes: UINodeChild[]): this
56
- remove(...nodes: UINodeChild[]): this
57
53
  }
58
54
 
59
55
  export class ModalElement extends WidgetElement {