lecodes-cli 0.2.0 → 0.5.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 (48) hide show
  1. package/README.md +3 -0
  2. package/dist/index.js +883 -507
  3. package/package.json +9 -4
  4. package/runtime/sdk/canvas/Canvas.ts +270 -0
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetMacro.ts +44 -0
  7. package/runtime/sdk/compile/bundler.ts +173 -0
  8. package/runtime/sdk/compile/compileProject.ts +84 -0
  9. package/runtime/sdk/compile/header.ts +46 -0
  10. package/runtime/sdk/compile/index.ts +12 -0
  11. package/runtime/sdk/compile/sourcemap.ts +25 -0
  12. package/runtime/sdk/core/Aspect.ts +82 -26
  13. package/runtime/sdk/core/signals.ts +277 -0
  14. package/runtime/sdk/g2/CharacterController2D.ts +5 -4
  15. package/runtime/sdk/g2/Node2D.ts +1 -1
  16. package/runtime/sdk/g2/Physics2D.ts +22 -12
  17. package/runtime/sdk/g2/Scene2D.ts +30 -0
  18. package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
  19. package/runtime/sdk/g2/Sprite.ts +16 -6
  20. package/runtime/sdk/g2/Texture2D.ts +12 -2
  21. package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
  22. package/runtime/sdk/gl/CharacterController.ts +101 -0
  23. package/runtime/sdk/gl/Material.ts +10 -3
  24. package/runtime/sdk/gl/Node.ts +11 -2
  25. package/runtime/sdk/gl/Physics.ts +65 -41
  26. package/runtime/sdk/gl/Scene.ts +18 -0
  27. package/runtime/sdk/gl/Shape.ts +98 -0
  28. package/runtime/sdk/gl/Texture.ts +13 -2
  29. package/runtime/sdk/gl/Trigger.ts +46 -0
  30. package/runtime/sdk/gl/physicsEvents.ts +20 -0
  31. package/runtime/sdk/inject.ts +14 -3
  32. package/runtime/sdk/runtime/camera.ts +31 -0
  33. package/runtime/sdk/runtime/device.ts +26 -0
  34. package/runtime/sdk/runtime/share.ts +9 -0
  35. package/runtime/sdk/ui/UI.ts +5 -1
  36. package/runtime/sdk/ui/UIButton.ts +11 -5
  37. package/runtime/sdk/ui/UIContainer.ts +28 -13
  38. package/runtime/sdk/ui/UIImage.ts +31 -11
  39. package/runtime/sdk/ui/UIInput.ts +10 -0
  40. package/runtime/sdk/ui/UINode.ts +206 -8
  41. package/runtime/sdk/ui/UIScreen.ts +17 -7
  42. package/runtime/sdk/ui/UIScrollable.ts +10 -5
  43. package/runtime/sdk/ui/UISpacer.ts +4 -0
  44. package/runtime/sdk/ui/UIText.ts +21 -5
  45. package/runtime/sdk/ui/UIVideo.ts +5 -0
  46. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
  47. package/runtime/sdk/ui/UIWidget.ts +10 -5
  48. package/runtime/sdk/gl/Collider.ts +0 -29
@@ -0,0 +1,98 @@
1
+ // A collision shape, as an aspect on a 3D Node. PURE GEOMETRY: attaching a Shape does not by itself
2
+ // make the node rigid or a sensor — pair it with a Physics (rigid body) or a Trigger (sensor), both
3
+ // of which require a Shape. The Shape also makes the node pointer-pickable: on its own it creates a
4
+ // lightweight "pick-only" JoltPhysics body (collides with nothing, exists only for the raycast), so
5
+ // any node with a Shape can receive click/touchstart. Physics/Trigger REPLACE that body with the real
6
+ // one. Set `raycast = false` to opt a shape out of pointer picking.
7
+ //
8
+ // wall.aspect(Shape, { box: [1, 2, 1] }).aspect(Physics, { motion: 'static' })
9
+ // coin.aspect(Shape, { sphere: 0.5 }).aspect(Trigger)
10
+ // crate.aspect(Shape, {}).aspect(Physics) // auto box from the mesh AABB × world scale
11
+ //
12
+ // Explicit dims are world-space half-extents (not scaled by the node). Use {} ("auto") to track the
13
+ // mesh × world scale. Note: a Shape-only pick body is a STATIC snapshot at attach time — for a moving
14
+ // pickable object add a Physics body (which follows the sim), rather than relying on the pick body.
15
+
16
+ import { Aspect } from "../core/Aspect"
17
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
18
+ import type { Node } from "./Node"
19
+
20
+ export class Shape extends Aspect<"shape", Node> {
21
+ static readonly aspect = "shape"
22
+
23
+ /** Box half-extents [hx, hy, hz] (world units). */
24
+ box?: Vec3Like
25
+ /** Sphere radius. */
26
+ sphere?: number
27
+ /** Cylinder aligned on Y: half-height + radius. */
28
+ cylinder?: { halfHeight: number, radius: number }
29
+ /** Capsule aligned on Y: half-height of the cylinder section + radius. */
30
+ capsule?: { halfHeight: number, radius: number }
31
+ /** Whether pointer rays can hit this shape (click / touchstart). Default true. */
32
+ raycast = true
33
+
34
+ private _shapeId = 0
35
+ private _bodyId = 0
36
+
37
+ /** @internal native shape id (0 if the build has no physics). */
38
+ get shapeId(): number { return this._shapeId }
39
+ /** @internal current native body id (a pick-only body until Physics/Trigger claim it). */
40
+ get bodyId(): number { return this._bodyId }
41
+
42
+ onAttach(): void {
43
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
44
+ this._shapeId = this._build()
45
+ if (this._shapeId) this._createPickBody()
46
+ }
47
+
48
+ onDetach(): void {
49
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
50
+ if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
51
+ }
52
+
53
+ /** @internal Physics/Trigger take over: drop the pick body and hand back the shape id. */
54
+ _claim(): number {
55
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
56
+ return this._shapeId
57
+ }
58
+ /** @internal Physics/Trigger report the body they created, so Shape owns its lifetime + pickability. */
59
+ _ownBody(bodyId: number): void {
60
+ this._bodyId = bodyId
61
+ if (!this.raycast && bodyId) _creator.physicsSetPickable(bodyId, false)
62
+ }
63
+ /** @internal restore a pick-only body when Physics/Trigger detach but the Shape remains. */
64
+ _recreatePickBody(): void {
65
+ if (!this._bodyId && this._shapeId) this._createPickBody()
66
+ }
67
+
68
+ private _createPickBody(): void {
69
+ this._bodyId = _creator.physicsCreateBody(this.node.id, this._shapeId, 0, 0, false, /*pickOnly*/ true)
70
+ if (!this.raycast && this._bodyId) _creator.physicsSetPickable(this._bodyId, false)
71
+ }
72
+
73
+ private _build(): number {
74
+ if (this.sphere !== undefined) return _creator.physicsBuildSphere(this.sphere)
75
+ if (this.cylinder) return _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
76
+ if (this.capsule) return _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
77
+ if (this.box) return _creator.physicsBuildBox(cx(this.box), cy(this.box), cz(this.box))
78
+
79
+ // auto: a box from the mesh AABB × world scale (so the physical box matches the rendered size).
80
+ const s = this.node.worldScale
81
+ let hx = 0.5, hy = 0.5, hz = 0.5
82
+ const geom = this.node.geometry
83
+ if (geom) {
84
+ const v = geom.vertices
85
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
86
+ for (let i = 0; i + 2 < v.length; i += 3) {
87
+ if (v[i] < minX) minX = v[i]
88
+ if (v[i] > maxX) maxX = v[i]
89
+ if (v[i + 1] < minY) minY = v[i + 1]
90
+ if (v[i + 1] > maxY) maxY = v[i + 1]
91
+ if (v[i + 2] < minZ) minZ = v[i + 2]
92
+ if (v[i + 2] > maxZ) maxZ = v[i + 2]
93
+ }
94
+ hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
95
+ }
96
+ return _creator.physicsBuildBox(hx * s.x, hy * s.y, hz * s.z)
97
+ }
98
+ }
@@ -1,6 +1,6 @@
1
1
  // A 3D GPU texture, decoded natively by creator-gl. Load from a URL or an already-fetched response.
2
2
 
3
- import { fetch, type FetchResponse } from "../runtime/fetch"
3
+ import { fetch, type FetchResponse, type File } from "../runtime/fetch"
4
4
 
5
5
  export class Texture {
6
6
  /** @internal native texture handle. */
@@ -19,7 +19,18 @@ export class Texture {
19
19
  this._id = id
20
20
  }
21
21
 
22
- static load(source: string | FetchResponse): Promise<Texture> {
22
+ // Wrap a Canvas's baked surface as a 3D texture (RGBA8, already rasterized → synchronous, no decode).
23
+ // The id is cached on the canvas (opaque _tex3dId), so backing several materials shares one texture and
24
+ // canvas.update() re-uploads it. Structural type avoids a Canvas import (keeps 3D free of 2D-canvas code).
25
+ static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number, _tex3dId: number }): Texture {
26
+ if (!canvas._tex3dId) {
27
+ const surfaceId = canvas._bakeSurface()
28
+ canvas._tex3dId = _creator.createTextureFromCanvas(surfaceId)
29
+ }
30
+ return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
31
+ }
32
+
33
+ static load(source: string | FetchResponse | File): Promise<Texture> {
23
34
  if (typeof source === "string") {
24
35
  return fetch(source, { useOnce: true }).then((resp) => {
25
36
  if (resp.status >= 400) {
@@ -0,0 +1,46 @@
1
+ // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
+ // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
+ // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
+ //
5
+ // const goal = new Mesh(box(), material)
6
+ // .aspect(Shape, { box: [1, 2, 1] })
7
+ // .aspect(Trigger)
8
+ // goal.addEventListener('enter', other => win(other))
9
+ // goal.addEventListener('exit', other => …)
10
+
11
+ import { Aspect } from "../core/Aspect"
12
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
13
+ import { Shape } from "./Shape"
14
+ import { ensurePhysicsEvents } from "./physicsEvents"
15
+ import type { Node } from "./Node"
16
+
17
+ export class Trigger extends Aspect<"trigger", Node> {
18
+ static readonly aspect = "trigger"
19
+
20
+ private _bodyId = 0
21
+ /** Native body id (0 if no physics support). */
22
+ get id(): number { return this._bodyId }
23
+
24
+ onAttach(): void {
25
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
26
+ const shape = this.node.get(Shape)
27
+ if (!shape) {
28
+ throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
29
+ }
30
+ const shapeId = shape._claim()
31
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
32
+ shape._ownBody(this._bodyId)
33
+ ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
34
+ }
35
+
36
+ onDetach(): void {
37
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
38
+ this.node.get(Shape)?._recreatePickBody()
39
+ }
40
+
41
+ /** Reposition the trigger zone to a world point. */
42
+ moveTo(p: Vec3Like): this {
43
+ if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
44
+ return this
45
+ }
46
+ }
@@ -0,0 +1,20 @@
1
+ // Routes native physics contact/sensor events to the owning nodes' 'enter'/'exit' events. Each event
2
+ // is delivered to BOTH nodes with the other as the argument. type: 0 = enter (contact/overlap began),
3
+ // 1 = exit (ended). Registered lazily on the first trigger/physics body or 'enter'/'exit' listener.
4
+
5
+ import { nodeRegistry } from "./Node"
6
+
7
+ let registered = false
8
+ export const ensurePhysicsEvents = (): void => {
9
+ if (registered) return
10
+ if (!_creator.setOnPhysicsEvent) return // physics-less build / bridge without the hook
11
+ registered = true
12
+ _creator.setOnPhysicsEvent((a, b, type) => {
13
+ const na = nodeRegistry.get(a)
14
+ const nb = nodeRegistry.get(b)
15
+ if (!na || !nb) return
16
+ const channel = type === 1 ? "exit" : "enter"
17
+ na._emitCollision(channel, nb)
18
+ nb._emitCollision(channel, na)
19
+ })
20
+ }
@@ -17,6 +17,12 @@ export { Color } from "./core/color"
17
17
  // ---- aspects (attachable node capabilities) ----
18
18
  export { Aspect, type With } from "./core/Aspect"
19
19
 
20
+ // ---- reactivity (signals; UI text/style props also accept `() => value` bindings) ----
21
+ export { signal, computed, effect, type Signal, type Computed } from "./core/signals"
22
+
23
+ // ---- canvas (core 2D drawing that bakes to a texture; shared by 2D/3D/UI) ----
24
+ export { Canvas, Bitmap } from "./canvas/Canvas"
25
+
20
26
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
21
27
  export { toast, SvgSource } from "./runtime/misc"
22
28
  export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
@@ -25,7 +31,9 @@ export { device } from "./runtime/device"
25
31
  export { Input } from "./runtime/input"
26
32
  export { WebSocket } from "./runtime/net"
27
33
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
34
+ export { CameraViewer } from "./runtime/camera"
28
35
  export { openFilePicker } from "./runtime/files"
36
+ export { share } from "./runtime/share"
29
37
  export { ClickEvent, TouchStartEvent } from "./runtime/touch"
30
38
 
31
39
  // ---- 2D engine (creator-2d) ----
@@ -34,9 +42,9 @@ export { Node2D } from "./g2/Node2D"
34
42
  export { Camera2D } from "./g2/Camera2D"
35
43
  export { Sprite } from "./g2/Sprite"
36
44
  export { SpriteAnimation } from "./g2/SpriteAnimation"
37
- export { Shape } from "./g2/Shape"
45
+ export { Shape2D } from "./g2/Shape2D"
38
46
  export { Physics2D } from "./g2/Physics2D"
39
- export { Trigger } from "./g2/Trigger"
47
+ export { Trigger2D } from "./g2/Trigger2D"
40
48
  export { CharacterController2D } from "./g2/CharacterController2D"
41
49
  export { Tilemap } from "./g2/Tilemap"
42
50
  export { Texture2D } from "./g2/Texture2D"
@@ -56,6 +64,7 @@ export {
56
64
  UIVirtualizedList,
57
65
  Router,
58
66
  registerFont,
67
+ __uiMap,
59
68
  } from "./ui/UI"
60
69
 
61
70
  // ---- animate ----
@@ -71,8 +80,10 @@ export { Scene, ARScene } from "./gl/Scene"
71
80
  export { Node } from "./gl/Node"
72
81
  export { Mesh } from "./gl/Mesh"
73
82
  export { Model } from "./gl/Model"
83
+ export { Shape } from "./gl/Shape"
74
84
  export { Physics } from "./gl/Physics"
75
- export { Collider } from "./gl/Collider"
85
+ export { Trigger } from "./gl/Trigger"
86
+ export { CharacterController } from "./gl/CharacterController"
76
87
  export { ModelAnimation } from "./gl/ModelAnimation"
77
88
  export { Light } from "./gl/Light"
78
89
  export { Material } from "./gl/Material"
@@ -0,0 +1,31 @@
1
+ // Device camera → a still-photo File. Ported from worker/src/utils/CameraViewer.ts. Permission is
2
+ // requested through the engine bridge's `requestCamera` (same as QRScanner); the live preview and the
3
+ // capture go through `_creatorUtils.camera*`. `takePhoto()` returns a File (a host buffer handle), so
4
+ // the shot can be used as a texture (`Texture2D.load(file)`), uploaded via FormData, or shared.
5
+
6
+ import { File } from "./fetch"
7
+
8
+ export class CameraViewer {
9
+ /** Ask for camera permission and start the live preview (default: the back camera). */
10
+ static async open(facingMode: "front" | "back" = "back"): Promise<void> {
11
+ await new Promise<void>((res, rej) => _creator.requestCamera(res, rej))
12
+ await new Promise<void>((res, rej) => _creatorUtils.cameraOpen(facingMode, res, rej))
13
+ }
14
+
15
+ /** Switch between the front and back camera while the preview is open. */
16
+ static setFacingMode(facingMode: "front" | "back"): void {
17
+ _creatorUtils.cameraSetFacingMode(facingMode)
18
+ }
19
+
20
+ /** Capture a still frame as an image File (host-encoded). */
21
+ static takePhoto(): Promise<File> {
22
+ return new Promise<File>((res, rej) => {
23
+ _creatorUtils.cameraTakePhoto((bufferId, name, size) => res(new File(bufferId, name, size)), rej)
24
+ })
25
+ }
26
+
27
+ /** Stop the preview and release the camera. */
28
+ static close(): void {
29
+ _creatorUtils.cameraClose()
30
+ }
31
+ }
@@ -17,6 +17,32 @@ export const device = {
17
17
  get language(): string {
18
18
  return _creatorUtils.language
19
19
  },
20
+ /** Display device-pixel ratio (physical px per logical/CSS px): 1 on standard displays, 2–3 on
21
+ * retina / iOS. Bake a Canvas at this ratio for crisp output without hardcoding, e.g.
22
+ * `new Canvas(w, h, { pixelRatio: device.pixelRatio })`. NOTE: the web 2D/GL surface currently
23
+ * renders at logical resolution, so on web this only helps UI canvases / is forward-looking for
24
+ * 2D; on iOS (physical surface) it makes 2D canvases crisp. Falls back to 1 if the host omits it. */
25
+ get pixelRatio(): number {
26
+ return _creatorUtils.pixelRatio || 1
27
+ },
28
+ /** Current display width in logical px — the same value the "resize" event delivers, but readable at
29
+ * any time (not only inside the listener). 0 until the host has reported a size. */
30
+ get width(): number {
31
+ return _creatorUI.getDisplaySize?.()?.[0] ?? 0
32
+ },
33
+ /** Current display height in logical px — the same value the "resize" event delivers, readable at any
34
+ * time. 0 until the host has reported a size. */
35
+ get height(): number {
36
+ return _creatorUI.getDisplaySize?.()?.[1] ?? 0
37
+ },
38
+ /** Opt into the precise-touch system where the platform supports it. OFF by default. When on, fast
39
+ * strokes are sampled at the touch digitizer's full rate (iOS coalesced touches, ≈120–240 Hz) instead
40
+ * of once per display frame (~60 Hz), so a pointer-heavy app (drawing, handwriting, dragging) gets more
41
+ * points and smoother lines. Leave it off for tap/button UIs. No-op on hosts without a coalesced-input
42
+ * concept (web already coalesces pointermove; headless). */
43
+ setPreciseTouch(enabled: boolean): void {
44
+ _creatorUtils.setPreciseTouch?.(enabled)
45
+ },
20
46
  addEventListener(channel: "resize", callback: ResizeCallback): void {
21
47
  if (channel !== "resize") return
22
48
  resizeListeners.push(callback)
@@ -0,0 +1,9 @@
1
+ // Share a media File (image/video) via the OS share sheet. Wraps `_creatorUtils.shareMedia`, which
2
+ // takes the host buffer id behind the File. On iOS the sheet also lets the user save to Files/Photos;
3
+ // on web it saves (downloads) the file. `text` is an optional caption/message.
4
+
5
+ import type { File, FetchResponse } from "./fetch"
6
+
7
+ export function share(media: File | FetchResponse, text?: string): void {
8
+ _creatorUtils.shareMedia((media as any).id ?? (media as any)._id, text)
9
+ }
@@ -16,4 +16,8 @@ export { UIScrollable, type UIScrollableStyle } from './UIScrollable'
16
16
 
17
17
  export { UIVirtualizedList } from './UIVirtualizedList'
18
18
 
19
- export { Router } from './router'
19
+ export { Router } from './router'
20
+
21
+ // Internal: memoized map for reactive children — the chisel `reactiveUi` pass rewrites
22
+ // `list.map(fn)` inside children bindings to `__uiMap(list, fn, slot)` calls.
23
+ export { __uiMap } from './UINode'
@@ -1,6 +1,6 @@
1
1
  import { ClickEvent, TouchStartEvent } from "../runtime/touch"
2
2
  import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType } from "../runtime/touch"
3
- import { type ElementStyle, type ContainerStyle, type Style, ContainerElement, type DrawableStyle, type UINodeChild, type OnLayoutCallback, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
3
+ import { type ElementStyle, type ContainerStyle, type Style, ContainerElement, type DrawableStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
4
4
 
5
5
  export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { rippleColor?: Color | "default" }
6
6
 
@@ -18,9 +18,15 @@ export interface UIButton {
18
18
  append(...nodes: UINodeChild[]): this
19
19
  insert(index: number, ...nodes: UINodeChild[]): this
20
20
  remove(...nodes: UINodeChild[]): this
21
- setContent(nodes: UINodeChild[]): this
21
+ setContent(nodes: UINodeChild[] | ChildrenFn): this
22
22
 
23
23
  onLayout(onLayout: OnLayoutCallback): this
24
+
25
+ setClass(name: string, enabled: boolean): this
26
+ toggleClass(name: string): this
27
+ hasClass(name: string): boolean
28
+ bindClass(name: string, fn: () => boolean): this
29
+
24
30
  readonly children: UINodeChild[],
25
31
  }
26
32
 
@@ -66,14 +72,14 @@ class ButtonElement extends ContainerElement<"button"> {
66
72
  }
67
73
 
68
74
  export function UIButton(): UIButton;
69
- export function UIButton(children: UINodeChild[]): UIButton;
75
+ export function UIButton(children: UINodeChild[] | ChildrenFn): UIButton;
70
76
  export function UIButton(style: UIButtonStyle): UIButton;
71
77
  export function UIButton(style: UIButtonStyle, children: UINodeChild[]): UIButton;
72
- export function UIButton(...args: [] | [UINodeChild[] | UIButtonStyle] | [UIButtonStyle, children: UINodeChild[]]): UIButton {
78
+ export function UIButton(...args: [] | [UINodeChild[] | ChildrenFn | UIButtonStyle] | [UIButtonStyle, children: UINodeChild[] | ChildrenFn]): UIButton {
73
79
  if (args.length === 0) {
74
80
  return new ButtonElement("button", {}, [])
75
81
  } else if (args.length === 1) {
76
- if (Array.isArray(args[0])) {
82
+ if (Array.isArray(args[0]) || typeof args[0] === "function") {
77
83
  return new ButtonElement("button", {}, args[0])
78
84
  } else {
79
85
  return new ButtonElement("button", args[0], [])
@@ -1,4 +1,4 @@
1
- import { ContainerElement, type AnimateStyle, type AppearStyle, type ContainerStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type StyleFn, type UINode, type UINodeChild } from "./UINode"
1
+ import { ContainerElement, type AnimateStyle, type AppearStyle, type ChildrenFn, type ContainerStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type StyleFn, type UINode, type UINodeChild } from "./UINode"
2
2
 
3
3
  export type UIContainerStyle = ElementStyle & DrawableStyle & ContainerStyle
4
4
 
@@ -11,21 +11,26 @@ export interface UIColumn {
11
11
  append(...nodes: UINodeChild[]): UIColumn
12
12
  insert(index: number, ...nodes: UINodeChild[]): UIColumn
13
13
  remove(...nodes: UINodeChild[]): UIColumn
14
- setContent(nodes: UINodeChild[]): this
14
+ setContent(nodes: UINodeChild[] | ChildrenFn): this
15
15
 
16
16
  onLayout(onLayout: OnLayoutCallback): this
17
17
 
18
+ setClass(name: string, enabled: boolean): this
19
+ toggleClass(name: string): this
20
+ hasClass(name: string): boolean
21
+ bindClass(name: string, fn: () => boolean): this
22
+
18
23
  readonly children: UINodeChild[]
19
24
  }
20
25
  export function UIColumn(): UIColumn
21
- export function UIColumn(children: UINodeChild[]): UIColumn
26
+ export function UIColumn(children: UINodeChild[] | ChildrenFn): UIColumn
22
27
  export function UIColumn(style: UIContainerStyle): UIColumn
23
28
  export function UIColumn(style: UIContainerStyle, children: UINodeChild[]): UIColumn
24
- export function UIColumn(...args: [] | [UINodeChild[] | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[]]): UIColumn {
29
+ export function UIColumn(...args: [] | [UINodeChild[] | ChildrenFn | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[] | ChildrenFn]): UIColumn {
25
30
  if (args.length === 0) {
26
31
  return new ContainerElement("column", {}, [])
27
32
  } else if (args.length === 1) {
28
- if (Array.isArray(args[0])) {
33
+ if (Array.isArray(args[0]) || typeof args[0] === "function") {
29
34
  return new ContainerElement("column", {}, args[0])
30
35
  } else {
31
36
  return new ContainerElement("column", args[0], [])
@@ -44,20 +49,25 @@ export interface UIRow {
44
49
  append(...nodes: UINodeChild[]): UIRow
45
50
  insert(index: number, ...nodes: UINodeChild[]): UIRow
46
51
  remove(...nodes: UINodeChild[]): UIRow
47
- setContent(nodes: UINodeChild[]): this
52
+ setContent(nodes: UINodeChild[] | ChildrenFn): this
48
53
 
49
54
  onLayout(onLayout: OnLayoutCallback): this
50
55
 
56
+ setClass(name: string, enabled: boolean): this
57
+ toggleClass(name: string): this
58
+ hasClass(name: string): boolean
59
+ bindClass(name: string, fn: () => boolean): this
60
+
51
61
  readonly children: UINodeChild[],
52
62
  }
53
- export function UIRow(children: UINodeChild[]): UIRow
63
+ export function UIRow(children: UINodeChild[] | ChildrenFn): UIRow
54
64
  export function UIRow(style: UIContainerStyle): UIRow
55
65
  export function UIRow(style: UIContainerStyle, children: UINodeChild[]): UIRow
56
- export function UIRow(...args: [] | [UINodeChild[] | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[]]): UIRow {
66
+ export function UIRow(...args: [] | [UINodeChild[] | ChildrenFn | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[] | ChildrenFn]): UIRow {
57
67
  if (args.length === 0) {
58
68
  return new ContainerElement("row", {}, [])
59
69
  } else if (args.length === 1) {
60
- if (Array.isArray(args[0])) {
70
+ if (Array.isArray(args[0]) || typeof args[0] === "function") {
61
71
  return new ContainerElement("row", {}, args[0])
62
72
  } else {
63
73
  return new ContainerElement("row", args[0], [])
@@ -76,20 +86,25 @@ export interface UIBox {
76
86
  append(...nodes: UINodeChild[]): this
77
87
  insert(index: number, ...nodes: UINodeChild[]): this
78
88
  remove(...nodes: UINodeChild[]): this
79
- setContent(nodes: UINodeChild[]): this
89
+ setContent(nodes: UINodeChild[] | ChildrenFn): this
80
90
 
81
91
  onLayout(onLayout: OnLayoutCallback): this
82
92
 
93
+ setClass(name: string, enabled: boolean): this
94
+ toggleClass(name: string): this
95
+ hasClass(name: string): boolean
96
+ bindClass(name: string, fn: () => boolean): this
97
+
83
98
  readonly children: UINodeChild[],
84
99
  }
85
- export function UIBox(children: UINodeChild[]): UIBox
100
+ export function UIBox(children: UINodeChild[] | ChildrenFn): UIBox
86
101
  export function UIBox(style: UIContainerStyle): UIBox
87
102
  export function UIBox(style: UIContainerStyle, children: UINodeChild[]): UIBox
88
- export function UIBox(...args: [] | [UINodeChild[] | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[]]): UIBox {
103
+ export function UIBox(...args: [] | [UINodeChild[] | ChildrenFn | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[] | ChildrenFn]): UIBox {
89
104
  if (args.length === 0) {
90
105
  return new ContainerElement("box", {}, [])
91
106
  } else if (args.length === 1) {
92
- if (Array.isArray(args[0])) {
107
+ if (Array.isArray(args[0]) || typeof args[0] === "function") {
93
108
  return new ContainerElement("box", {}, args[0])
94
109
  } else {
95
110
  return new ContainerElement("box", args[0], [])
@@ -1,13 +1,28 @@
1
1
  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
+ import { Canvas, isCanvas } from "../canvas/Canvas"
4
5
  import { Element, type AnimateStyle, type Color, type ElementStyle, type OnLayoutCallback, type Style } from "./UINode"
5
6
 
6
7
  export type UIImageStyle = ElementStyle & { borderRadius?: number, objectFit?: "cover" | "contain" | "fill", tintColor?: Color }
7
8
 
9
+ /** A baked Canvas surface as an image source. The UI backend resolves it to a UI texture — see
10
+ * docs/canvas-contract.md ("Pending"). */
11
+ export type CanvasImageSource = { canvasSurface: number }
12
+
13
+ /** Accepted image sources. A Canvas is baked to a surface marker on assign. */
14
+ export type ImageSource = string | FetchResponse | File | SvgSource | Canvas
15
+
16
+ const toSrc = (src: ImageSource): string | FetchResponse | File | SvgSource | CanvasImageSource =>
17
+ isCanvas(src) ? { canvasSurface: src._bakeSurface() } : src
18
+
19
+ /** The stored/resolved image source (a Canvas is resolved to a surface marker). */
20
+ export type ResolvedImageSource = string | FetchResponse | File | SvgSource | CanvasImageSource
21
+
8
22
  export interface UIImage {
9
23
  readonly type: "image",
10
- src: string | FetchResponse | File | SvgSource,
24
+ get src(): ResolvedImageSource
25
+ set src(value: ImageSource)
11
26
  style: Style<this, UIImageStyle>
12
27
  animateTo: AnimateStyle<this, UIImageStyle>
13
28
  animateFrom: AnimateStyle<this, UIImageStyle>
@@ -20,26 +35,31 @@ export interface UIImage {
20
35
  setSourceRect(x: number, y: number, w: number, h: number): this
21
36
 
22
37
  onLayout(onLayout: OnLayoutCallback): this
38
+
39
+ setClass(name: string, enabled: boolean): this
40
+ toggleClass(name: string): this
41
+ hasClass(name: string): boolean
42
+ bindClass(name: string, fn: () => boolean): this
23
43
  }
24
44
 
25
45
  class ImageElement extends Element<"image"> {
26
- private _src: string | FetchResponse | File | SvgSource
46
+ private _src: ResolvedImageSource
27
47
  // Current source sub-rect; undefined until the first setSourceRect. Kept on the node (not
28
48
  // just sent as a command) so a crop set while detached (_id == 0) is reapplied at mount —
29
49
  // the viewer reads it when it creates the element.
30
50
  private _sourceRect?: [number, number, number, number]
31
- constructor(style: any, src: string | FetchResponse | File | SvgSource) {
51
+ constructor(style: any, src: ImageSource) {
32
52
  super("image", style)
33
- this._src = src
53
+ this._src = toSrc(src)
34
54
  }
35
55
 
36
- get src() {
56
+ get src(): ResolvedImageSource {
37
57
  return this._src
38
58
  }
39
- set src(value) {
40
- this._src = value
59
+ set src(value: ImageSource) {
60
+ this._src = toSrc(value)
41
61
  if (this._id !== 0) {
42
- _creatorUI.updateNode(this._id, "src", value)
62
+ _creatorUI.updateNode(this._id, "src", this._src)
43
63
  }
44
64
  }
45
65
 
@@ -57,9 +77,9 @@ class ImageElement extends Element<"image"> {
57
77
  }
58
78
  }
59
79
 
60
- export function UIImage(src: string | FetchResponse | File | SvgSource): UIImage
61
- export function UIImage(style: UIImageStyle, src: string | FetchResponse | File | SvgSource): UIImage
62
- export function UIImage(...args: [string | FetchResponse | File | SvgSource] | [UIImageStyle, string | FetchResponse | File | SvgSource]): UIImage {
80
+ export function UIImage(src: ImageSource): UIImage
81
+ export function UIImage(style: UIImageStyle, src: ImageSource): UIImage
82
+ export function UIImage(...args: [ImageSource] | [UIImageStyle, ImageSource]): UIImage {
63
83
  if (args.length === 1) {
64
84
  return new ImageElement({}, args[0])
65
85
  } else {
@@ -16,6 +16,11 @@ export interface UIInput {
16
16
  onFocus(callback: () => void): UIInput
17
17
  onBlur(callback: () => void): UIInput
18
18
  onLayout(onLayout: OnLayoutCallback): this
19
+
20
+ setClass(name: string, enabled: boolean): this
21
+ toggleClass(name: string): this
22
+ hasClass(name: string): boolean
23
+ bindClass(name: string, fn: () => boolean): this
19
24
  }
20
25
 
21
26
  export interface UITextArea {
@@ -33,6 +38,11 @@ export interface UITextArea {
33
38
  onChange(callback: (value: string) => void): UITextArea
34
39
  onFocus(callback: () => void): UITextArea
35
40
  onBlur(callback: () => void): UITextArea
41
+
42
+ setClass(name: string, enabled: boolean): this
43
+ toggleClass(name: string): this
44
+ hasClass(name: string): boolean
45
+ bindClass(name: string, fn: () => boolean): this
36
46
  }
37
47
 
38
48
  class InputElement<T extends "input" | "textarea"> extends Element<T> {