lecodes-web-canvas 2.0.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.
@@ -0,0 +1,822 @@
1
+ /// <reference path="../contract.d.ts" />
2
+ // The tree UI: the HostUI table of the runtime (sdk/src/bridges/tree.d.ts `HostUI`) implemented
3
+ // in TypeScript over the wasm runtime module — the shared core of the web renderers (the canvas
4
+ // mount in the browser, the DOM renderer, the headless host). The runtime OWNS the tree (creator-ui
5
+ // Yoga, the screens / destinations / widgets / pager / vlist); this class keeps one HNode per node
6
+ // id (the yoga pointer), the paint-ready style creator-ui's handlers write into it, the content
7
+ // properties, and the boxes of the last layout pass — and turns them into the render tree (the
8
+ // RenderNode: the semantic JSON AND the paint source), hit-tests pointers over it and reports what
9
+ // happened back to the runtime through the creatorWeb* entry points (node events, touches, scroll,
10
+ // focus / change). Nothing of JavaScript reaches it: the SDK's element objects live behind the
11
+ // runtime's emitter.
12
+ import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
13
+ import type { WasmHostUI } from "lecodes-web-runtime/src/gen/host.gen"
14
+ import { paintPending, readPaintOf, syncPaint, type PaintModule } from "../layout/creatorUi/paint-read"
15
+ import type { CreatorUiModule } from "../layout/creatorUi/loadCreatorUi"
16
+ import type { Host } from "../host/Host"
17
+ import type { Layer, Rect, RenderNode, ResolvedStyle, WorkerNode } from "../types"
18
+ import type { SceneNode } from "../core/SceneNode"
19
+ import type { Painter } from "../paint/Painter"
20
+ import { clampLines, measureEditableNode, measureImageNode, measureTextNode, resolveTextStyle, toFontSpec, wrapText } from "../core/text"
21
+ import { paintFrame, type PaintOptions } from "../paint/paintTree"
22
+ import { frameToText, serializeFrame } from "../serialize/serializeRenderTree"
23
+ import { hitTest } from "../core/hitTest"
24
+ import { svgToDataUri } from "../core/util/svgSrc"
25
+ import {
26
+ TREE_DEST_SCREEN, TREE_EVENT_BLUR, TREE_EVENT_CHANGE, TREE_EVENT_FOCUS, TREE_EVENT_OVERLAY_TAP, TREE_EVENT_OVERSCROLL,
27
+ TREE_EVENT_SCROLL_RELEASE, TREE_EVENT_SUBMIT, TREE_FLAG_BLUR, TREE_FLAG_CHANGE, TREE_FLAG_CLICK, TREE_FLAG_FOCUS,
28
+ TREE_FLAG_INTERACTIVE, TREE_FLAG_LAYOUT, TREE_FLAG_LONG_PRESS, TREE_FLAG_OVERLAY_TAP, TREE_FLAG_OVERSCROLL, TREE_FLAG_SCROLL,
29
+ TREE_FLAG_SCROLL_RELEASE, TREE_FLAG_SUBMIT,
30
+ } from "lecodes-sdk/ui/tree"
31
+
32
+ /** Fraction of the viewport the simulated keyboard occupies while an input is focused (the number
33
+ * every simulator host uses); the focused root re-flows above it (runtime pkgSetShrunkRoot). */
34
+ const KEYBOARD_FRACTION = 0.32
35
+ /** Drag distance (px) before a press commits to scrolling. */
36
+ const SCROLL_THRESHOLD = 4
37
+
38
+ /** One node of the runtime's tree as this renderer sees it. Structurally a web-canvas SceneNode for
39
+ * the shared text / measure helpers (they read `style`, `parent`, `text`, `value`, `sourceRect`). */
40
+ /**
41
+ * The image src as the SDK puts it on the wire (sdk/src/ui/UIImage.ts): a url, `id:<buffer>` (the
42
+ * fetch host serves it), `canvas:<id>` (a baked _creatorCanvas surface → the web-canvas host's
43
+ * `lecodes-canvas://` scheme), `svg:<markup>` (an inline SvgSource → the data-uri the image decoder
44
+ * loads, encoded once per distinct markup). A `scene2d:` source has no image: the box is the render.
45
+ */
46
+ const svgUris = new Map<string, string>()
47
+ function wireSrc(s: string | undefined): string | undefined {
48
+ if (!s) return s
49
+ if (s.startsWith("canvas:")) return `lecodes-canvas://${s.slice(7)}`
50
+ if (s.startsWith("svg:")) {
51
+ let uri = svgUris.get(s)
52
+ if (uri === undefined) { uri = svgToDataUri(s.slice(4)); svgUris.set(s, uri) }
53
+ return uri
54
+ }
55
+ return s
56
+ }
57
+
58
+ export class HNode {
59
+ parent: HNode | null = null
60
+ children: HNode[] = []
61
+ style: ResolvedStyle = { lineHeight: 14 * 1.2 } // the core's fresh-node record; the first paint sync replaces it
62
+ name?: string
63
+ text = ""
64
+ src: string | undefined
65
+ value = ""
66
+ sourceRect?: [number, number, number, number]
67
+ viewName?: string
68
+ viewParams?: string
69
+ /** "playerId" of a video node / "viewId" of a native node (setPropertyInt). */
70
+ playerId = 0
71
+ viewId = 0
72
+ flags = 0
73
+ /** The box of the last layout pass (parent-relative; a root's is its own frame). */
74
+ layoutRect: Rect | null = null
75
+ scrollOffset = 0
76
+ wrapCache?: { key: string, lines: string[] }
77
+ /** The minimal WorkerNode the shared helpers expect (unused fields). */
78
+ readonly jsNode: Record<string, unknown>
79
+ constructor(readonly id: number, readonly type: string) { this.jsNode = { type, _id: id } }
80
+ get measured(): boolean { return this.type === "text" || this.type === "image" || this.editable }
81
+ get editable(): boolean { return this.type === "input" || this.type === "textarea" }
82
+ get scrollable(): boolean { return this.type === "scrollable" || this.type === "scrollableScreen" || this.type === "vlist" }
83
+ get scrollAxis(): "x" | "y" { return this.style.scrollAxis === "x" ? "x" : "y" }
84
+ get interactive(): boolean { return (this.flags & TREE_FLAG_INTERACTIVE) !== 0 }
85
+ get imageSrcRaw(): unknown { return this.type === "image" ? wireSrc(this.src) : undefined }
86
+ has(flag: number): boolean { return (this.flags & flag) !== 0 }
87
+ }
88
+
89
+ type Gesture = {
90
+ node: HNode | null // the pressed interactive node
91
+ scroll: HNode | null // the overflowing scrollable this gesture may drive
92
+ backdrop: HNode | null // a modal widget whose backdrop was tapped
93
+ edit: HNode | null // the editable the press landed in
94
+ global: boolean // fell through to the scene
95
+ dismiss: boolean // a clean release blurs the focused input
96
+ lastX: number
97
+ lastY: number
98
+ acc: number
99
+ scrolling: boolean
100
+ swallowClick: boolean // a long-press listener took the gesture
101
+ }
102
+
103
+ export type TreeUIOptions = {
104
+ /** The app-facing keyboard listener state: the host reports the band height through the runtime. */
105
+ onKeyboard?: (height: number) => void
106
+ /** The band the on-screen keyboard takes while an input is focused, in logical px. Absent = a
107
+ * simulated one (a fixed fraction of the height: the headless host, where nothing else tells);
108
+ * a browser page answers with what its window really loses. */
109
+ keyboardHeight?: () => number
110
+ /** A toast to show (the headless host records it, a browser mount paints one). */
111
+ onToast?: (message: string, duration: number) => void
112
+ }
113
+
114
+ export class TreeUI implements WasmHostUI {
115
+ readonly nodes = new Map<number, HNode>()
116
+ /** The presented screen root (TREE_DEST_SCREEN), null while a scene / nothing is presented. */
117
+ root: HNode | null = null
118
+ /** The screen that LEFT and is still shown: the runtime moves it and the incoming one through
119
+ * their own transform / opacity / dim (a transition is tween tracks on the roots — a host plays
120
+ * none of its own) and drops it when the transition lands (dropView). Painted only. */
121
+ leaving: HNode | null = null
122
+ /** …over the current screen, else under it. */
123
+ leavingAbove = false
124
+ /** Per pager: the page its current tab shows, and the one that left that place and is still
125
+ * shown — the runtime moves both through their own transform / opacity / dim and says when the
126
+ * one that left goes (HostUI.pagerShowPage / pagerDropPage). `above` = over the page shown. */
127
+ private pagerShown = new Map<number, HNode>()
128
+ private pagerLeaving = new Map<number, { page: HNode, above: boolean }>()
129
+ /** TREE_DEST_* of the current destination, -1 when none. */
130
+ destKind = -1
131
+ readonly widgetRoots: HNode[] = []
132
+ width = 0
133
+ height = 0
134
+ private focused: HNode | null = null
135
+ private readonly pressed = new Set<number>()
136
+ private readonly gestures = new Map<number, Gesture>()
137
+ private readonly vlists = new Set<HNode>()
138
+ private frameCache: Layer[] | null = null
139
+ private painted: Layer[] | null = null
140
+ private fontsPending = 0
141
+ private lastToast: string | null = null
142
+ /** Bumped by every mutation; the render tree is rebuilt when it changed. */
143
+ private dirty = true
144
+ /** The host's hook on the destination change (TREE_DEST_*; -1 = none) — `id` is the sceneId of a
145
+ * scene destination: it shows / hides its scene canvases. */
146
+ onDestination: ((kind: number, id: number) => void) | null = null
147
+ /** The destination that left was dropped (HostUI.dropView): the host takes a surface away here,
148
+ * not when the next destination opens — a screen may still be coming in over it. */
149
+ onDropped: ((kind: number, id: number) => void) | null = null
150
+
151
+ constructor(readonly M: WasmModule, readonly host: Host, private readonly options: TreeUIOptions = {}) {}
152
+
153
+ // ---- setup ------------------------------------------------------------------------------------
154
+ /** Register the measure function with creator-ui (once, before any node). Style needs no
155
+ * registration: the runtime keeps every node's paint record, this renderer reads it (paint-read.ts). */
156
+ install(): void {
157
+ const view = new DataView(new ArrayBuffer(8))
158
+ const measure = (_screenId: number, nodePtr: number, width: number, _wType: number, height: number, _hType: number): bigint => {
159
+ const r = this.measure(nodePtr, width, height)
160
+ view.setFloat32(4, r.width, true)
161
+ view.setFloat32(0, r.height, true)
162
+ return view.getBigUint64(0, true)
163
+ }
164
+ this.M._setMeasureFunc(this.M.addFunction(measure, "jiififi"))
165
+ }
166
+
167
+ private measure(nodeId: number, width: number, height: number): { width: number, height: number } {
168
+ const n = this.nodes.get(nodeId)
169
+ if (!n) return { width: 0, height: 0 }
170
+ // A layout the runtime runs before this frame's sync (it lays a screen out when it presents
171
+ // it) must still measure with the font the record holds NOW.
172
+ const P = this.M as unknown as PaintModule
173
+ if (paintPending(P, nodeId)) readPaintOf(P, nodeId, n.style)
174
+ const sn = n as unknown as SceneNode
175
+ if (n.type === "text") return measureTextNode(sn, this.host, width)
176
+ if (n.type === "image") return measureImageNode(sn, this.host, width, height)
177
+ if (n.editable) return measureEditableNode(sn, this.host, width)
178
+ return { width: 0, height: 0 }
179
+ }
180
+
181
+ /** The viewport in logical px; safe-area insets in CSS order + the system-bar edges. */
182
+ resize(width: number, height: number): void {
183
+ this.width = width
184
+ this.height = height
185
+ this.M._creatorWebResize(width, height)
186
+ this.M._setConditionValue(width, height)
187
+ this.dirty = true
188
+ }
189
+ setSafeArea(area: [number, number, number, number] | null, bars: readonly ("top" | "right" | "bottom" | "left")[] = []): void {
190
+ const [t, r, b, l] = area ?? (this.width > this.height ? [0, 8, 0, 20] : [20, 0, 8, 0])
191
+ const BIT = { top: 1, right: 2, bottom: 4, left: 8 }
192
+ this.M._setSafeAreaBars(bars.reduce((m, e) => m | BIT[e], 0))
193
+ this.M._setSafePaddings(t, r, b, l, 0)
194
+ this.dirty = true
195
+ }
196
+
197
+ // ---- the frame ---------------------------------------------------------------------------------
198
+ /** After a runtime tick: lay every live root out (the runtime's pass), read the boxes, settle the
199
+ * virtualized lists, report onLayout to the nodes that listen. */
200
+ layoutFrame(): void {
201
+ const M = this.M
202
+ // Every style write since the last frame sits in the core's dirty paint records: take them
203
+ // BEFORE layout (measure reads the font) — a non-empty batch is a mutation of the render tree.
204
+ const changed = syncPaint(M as unknown as PaintModule, (ptr, read) => {
205
+ const node = this.nodes.get(ptr)
206
+ if (node) read(node.style)
207
+ })
208
+ if (changed > 0) this.dirty = true
209
+ const n = M._creatorWebLayoutFrame(this.width, this.height)
210
+ if (n > 0) this.readLayoutBatch(n)
211
+ for (const v of this.vlists) {
212
+ if (!M._vlistExists(v.id)) continue
213
+ M._vlistUpdateLayout(v.id)
214
+ // the mounted items are detached core roots the batch never visits: read their boxes here
215
+ for (const item of v.children) this.deliverSubtree(item)
216
+ }
217
+ }
218
+ /** Whether the render tree changed since the last `frame()` (a mount paints only then). */
219
+ /** Whether the picture is behind the tree: something changed since the frame that was PAINTED.
220
+ * Not since the frame that was last built — a hit test or a hover builds one between a change
221
+ * and the paint, and the change is still owed its picture. */
222
+ needsPaint(): boolean { return this.dirty || this.frameCache === null || this.frameCache !== this.painted }
223
+ /** Consume a subtree's new layouts (getLayoutPointer: one-shot per node) into the cached boxes. */
224
+ private deliverSubtree(node: HNode): void {
225
+ const M = this.M
226
+ if (!this.layoutPtr) this.layoutPtr = M._malloc(16)
227
+ if (M._getLayoutPointer(node.id, this.layoutPtr)) {
228
+ const i = this.layoutPtr >> 2
229
+ const rect = { x: M.HEAPF32[i], y: M.HEAPF32[i + 1], width: M.HEAPF32[i + 2], height: M.HEAPF32[i + 3] }
230
+ const old = node.layoutRect
231
+ const changed = !old || old.x !== rect.x || old.y !== rect.y || old.width !== rect.width || old.height !== rect.height
232
+ node.layoutRect = rect
233
+ if (changed) this.dirty = true
234
+ if (changed && node.has(TREE_FLAG_LAYOUT)) M._creatorWebNodeLayout(node.id, rect.x, rect.y, rect.width, rect.height)
235
+ }
236
+ for (const c of node.children) this.deliverSubtree(c)
237
+ }
238
+ private layoutPtr = 0
239
+ private readLayoutBatch(n: number): void {
240
+ const M = this.M
241
+ const nodesPtr = M._collectNodes() >>> 2
242
+ const rectsPtr = M._collectRects() >>> 2
243
+ const u32 = M.HEAPU32, f32 = M.HEAPF32
244
+ for (let i = 0; i < n; i++) {
245
+ const ptr = u32[nodesPtr + i]
246
+ const node = this.nodes.get(ptr)
247
+ if (!node) continue
248
+ const l = f32[rectsPtr + i * 4], t = f32[rectsPtr + i * 4 + 1], r = f32[rectsPtr + i * 4 + 2], b = f32[rectsPtr + i * 4 + 3]
249
+ const rect = { x: l, y: t, width: r - l, height: b - t }
250
+ const old = node.layoutRect
251
+ const changed = !old || old.x !== rect.x || old.y !== rect.y || old.width !== rect.width || old.height !== rect.height
252
+ node.layoutRect = rect
253
+ if (changed) this.dirty = true
254
+ if (changed && node.has(TREE_FLAG_LAYOUT)) M._creatorWebNodeLayout(ptr, l, t, rect.width, rect.height)
255
+ }
256
+ }
257
+
258
+ /** Every compositing layer, bottom-to-top: the screen — and the one that left, under or over it
259
+ * — then the widgets in show order. */
260
+ frame(): Layer[] {
261
+ if (this.frameCache && !this.dirty) return this.frameCache
262
+ const layers: Layer[] = []
263
+ const viewport = { x: 0, y: 0, width: this.width, height: this.height }
264
+ const left = this.leaving ? this.build(this.leaving, viewport) : null
265
+ if (left && !this.leavingAbove) layers.push({ tree: left, leaving: true })
266
+ if (this.root) {
267
+ const tree = this.build(this.root, viewport)
268
+ if (tree) layers.push({ tree })
269
+ }
270
+ if (left && this.leavingAbove) layers.push({ tree: left, leaving: true })
271
+ for (const w of this.widgetRoots) {
272
+ const box = w.layoutRect ?? { x: 0, y: 0, width: this.width, height: this.height }
273
+ const tree = this.build(w, box)
274
+ if (!tree) continue
275
+ const color = tree.style.overlayColor
276
+ layers.push({ tree, widget: true, backdrop: color !== undefined ? { color, jsNode: w.jsNode as WorkerNode } : undefined })
277
+ }
278
+ this.frameCache = layers
279
+ this.dirty = false
280
+ return layers
281
+ }
282
+ renderTree(): RenderNode | null { return this.frame().find((l) => !l.widget && !l.leaving)?.tree ?? null }
283
+ widgetTrees(): RenderNode[] { return this.frame().filter((l) => l.widget).map((l) => l.tree) }
284
+ /** The widget overlay trees, in show order (the scenario engine reads them). */
285
+ widgets(): RenderNode[] { return this.widgetTrees() }
286
+ serialize(): unknown { return serializeFrame(this.renderTree(), this.widgetTrees()) }
287
+ toText(): string { return frameToText(this.renderTree(), this.widgetTrees()) }
288
+ paint(painter: Painter, options?: PaintOptions): void {
289
+ const frame = this.frame()
290
+ paintFrame(frame, painter, this.host, this.width, this.height, undefined, options)
291
+ this.painted = frame
292
+ }
293
+
294
+ /** The absolute, fully-resolved render tree of a root (the web-canvas builder, over the runtime's
295
+ * boxes: a pager shows its selected page, a vlist places its mounted items at the core's offsets). */
296
+ private build(root: HNode, rootRect: Rect): RenderNode | null {
297
+ const M = this.M
298
+ const walk = (node: HNode, px: number, py: number, isRoot: boolean): RenderNode | null => {
299
+ if (node.style.display === "none") return null
300
+ let x: number, y: number, width: number, height: number
301
+ if (isRoot) { x = rootRect.x; y = rootRect.y; width = rootRect.width; height = rootRect.height }
302
+ else {
303
+ const rel = node.layoutRect
304
+ if (!rel) return null
305
+ x = px + rel.x; y = py + rel.y; width = rel.width; height = rel.height
306
+ }
307
+ const out: RenderNode = { id: node.id, type: node.type, rect: { x, y, width, height }, style: node.style, children: [] }
308
+ if (node.name) out.name = node.name
309
+ const sn = node as unknown as SceneNode
310
+ if (node.type === "text") {
311
+ const t = resolveTextStyle(sn)
312
+ const font = toFontSpec(t)
313
+ out.text = node.text
314
+ const pad = this.padding(node.id)
315
+ if (pad[0] || pad[1] || pad[2] || pad[3]) out.padding = pad
316
+ const contentW = width - pad[0] - pad[2]
317
+ const key = `${contentW}|${font.size}|${font.weight}|${font.italic ? 1 : 0}|${font.letterSpacing}|${font.family}|${node.style.lineClamp ?? 0}|${node.style.textOverflow ?? ""}|${node.text}`
318
+ if (node.wrapCache?.key === key) out.lines = node.wrapCache.lines
319
+ else {
320
+ const measure = (s: string) => this.host.measureText(s, font)
321
+ const lines = clampLines(wrapText(node.text, contentW, measure).lines, node.style, contentW, measure)
322
+ node.wrapCache = { key, lines }
323
+ out.lines = lines
324
+ }
325
+ out.style = { ...node.style, color: t.color, fontSize: t.fontSize, fontFamily: t.fontFamily, fontWeight: t.fontWeight, fontStyle: t.fontStyle, lineHeight: t.lineHeight, letterSpacing: t.letterSpacing, textAlign: t.textAlign, textDecoration: t.textDecoration }
326
+ } else if (node.type === "image") {
327
+ const src = this.host.resolveSrc(node.imageSrcRaw)
328
+ const info = src ? this.host.getImage(src) : undefined
329
+ out.image = info ?? (src ? { src, naturalWidth: 0, naturalHeight: 0 } : undefined)
330
+ if (node.sourceRect) out.sourceRect = node.sourceRect
331
+ } else if (node.editable) {
332
+ out.value = node.value
333
+ const pad = this.padding(node.id)
334
+ if (pad[0] || pad[1] || pad[2] || pad[3]) out.padding = pad
335
+ const t = resolveTextStyle(sn)
336
+ out.style = { ...node.style, color: t.color, fontSize: t.fontSize, fontFamily: t.fontFamily, fontWeight: t.fontWeight, fontStyle: t.fontStyle, lineHeight: t.lineHeight, letterSpacing: t.letterSpacing, textAlign: t.textAlign, textDecoration: t.textDecoration }
337
+ }
338
+ if (node.interactive) out.state = { pressed: this.pressed.has(node.id) }
339
+ if (node.editable && this.focused === node) out.state = { ...out.state, focused: true }
340
+ if (node.style.overflow === "hidden") out.clip = true
341
+ let childX = x, childY = y
342
+ if (node.scrollable) {
343
+ const axis = node.scrollAxis
344
+ const content = axis === "x" ? M._getContentWidth(node.id) : M._getContentHeight(node.id)
345
+ out.clip = true
346
+ out.scroll = { offset: node.scrollOffset, content, axis }
347
+ if (axis === "x") childX = x - node.scrollOffset
348
+ else childY = y - node.scrollOffset
349
+ }
350
+ if (node.type === "pager") {
351
+ // the selected tab's top page: an independent root laid out at the pager's box
352
+ const tab = M._creatorWebPagerSelectedIndex(node.id)
353
+ const len = M._creatorWebPagerStackLen(node.id, tab)
354
+ const pageId = len > 0 ? M._creatorWebPagerPageNode(node.id, tab, len - 1) : 0
355
+ const page = pageId ? this.nodes.get(pageId) : undefined
356
+ if (page) this.pagerShown.set(node.id, page); else this.pagerShown.delete(node.id)
357
+ const left = this.pagerLeaving.get(node.id)
358
+ for (const p of left && left.page !== page ? (left.above ? [page, left.page] : [left.page, page]) : [page]) {
359
+ const r = p ? walk(p, x, y, false) : null
360
+ if (r) out.children.push(r)
361
+ }
362
+ return out
363
+ }
364
+ if (node.type === "vlist") {
365
+ // mounted items: each a detached core root at the offset the window math gave it
366
+ for (const child of node.children) {
367
+ const itemRoot = M._getParentNode(child.id)
368
+ if (!itemRoot) continue
369
+ const off = M._vlistItemOffsetOf(node.id, itemRoot)
370
+ if (Number.isNaN(off)) continue
371
+ const rel = child.layoutRect ?? { x: 0, y: 0, width: 0, height: 0 }
372
+ const r = walk(child, x - rel.x, y + off - node.scrollOffset - rel.y, false)
373
+ if (r) out.children.push(r)
374
+ }
375
+ return out
376
+ }
377
+ for (const child of node.children) { const r = walk(child, childX, childY, false); if (r) out.children.push(r) }
378
+ return out
379
+ }
380
+ return walk(root, 0, 0, true)
381
+ }
382
+ /** A node's resolved padding, [left, top, right, bottom] (an inspector's). */
383
+ paddingOf(nodeId: number): [number, number, number, number] { return this.padding(nodeId) }
384
+ private padding(nodeId: number): [number, number, number, number] {
385
+ const M = this.M
386
+ if (!this.padPtr) this.padPtr = M._malloc(16)
387
+ M._getPaddingPointer(nodeId, this.padPtr)
388
+ const i = this.padPtr >> 2
389
+ return [M.HEAPF32[i], M.HEAPF32[i + 1], M.HEAPF32[i + 2], M.HEAPF32[i + 3]]
390
+ }
391
+ private padPtr = 0
392
+ /** Call an export that takes a C string. */
393
+ private withStr<T>(s: string, fn: (ptr: number) => T): T {
394
+ const p = this.M.stringToNewUTF8(s)
395
+ try { return fn(p) } finally { this.M._free(p) }
396
+ }
397
+
398
+ // ---- lookups ---------------------------------------------------------------------------------------
399
+ get(id: number): HNode | undefined { return this.nodes.get(id) }
400
+ /** Rendered nodes by their author-given `name`, the screen first, then the widgets. */
401
+ findAll(name: string): RenderNode[] {
402
+ const out: RenderNode[] = []
403
+ const walk = (n: RenderNode) => { if (n.name === name) out.push(n); for (const c of n.children) walk(c) }
404
+ for (const l of this.frame()) walk(l.tree)
405
+ return out
406
+ }
407
+ /** The absolute rect of a rendered node, null when it is not on screen. */
408
+ findRect(id: number): Rect | null {
409
+ const walk = (n: RenderNode): Rect | null => {
410
+ if (n.id === id) return n.rect
411
+ for (const c of n.children) { const r = walk(c); if (r) return r }
412
+ return null
413
+ }
414
+ for (const l of this.frame()) { const r = walk(l.tree); if (r) return r }
415
+ return null
416
+ }
417
+ /** The last toast shown (the host paints none). */
418
+ toast(): string | null { return this.lastToast }
419
+ pendingFonts(): number { return this.fontsPending }
420
+ keyboardHeight(): number {
421
+ if (!this.focused) return 0
422
+ return this.options.keyboardHeight ? this.options.keyboardHeight() : Math.round(this.height * KEYBOARD_FRACTION)
423
+ }
424
+ focusedId(): number { return this.focused?.id ?? 0 }
425
+
426
+ // ---- HostUI: the tree ---------------------------------------------------------------------------
427
+ /** The node the runtime made last (an inspector ties the element the SDK is making to it). */
428
+ lastCreated = 0
429
+ nodeFactory(id: number, type: string, parent: number, index: number): void {
430
+ this.lastCreated = id
431
+ const n = new HNode(id, type)
432
+ this.nodes.set(id, n)
433
+ if (type === "vlist") this.vlists.add(n)
434
+ const p = this.nodes.get(parent)
435
+ if (p) this.link(p, n, Math.max(0, index))
436
+ this.dirty = true
437
+ }
438
+ private link(p: HNode, c: HNode, index: number): void {
439
+ if (c.parent) c.parent.children = c.parent.children.filter((x) => x !== c)
440
+ c.parent = p
441
+ p.children.splice(Math.min(index, p.children.length), 0, c)
442
+ }
443
+ insertNode(parent: number, child: number, index: number): void {
444
+ const p = this.nodes.get(parent), c = this.nodes.get(child)
445
+ if (p && c) this.link(p, c, index)
446
+ this.dirty = true
447
+ }
448
+ removeNode(parent: number, child: number): void {
449
+ const p = this.nodes.get(parent), c = this.nodes.get(child)
450
+ if (!p || !c) return
451
+ this.forget(c)
452
+ p.children = p.children.filter((x) => x !== c)
453
+ if (c.parent === p) c.parent = null
454
+ if (this.pagerLeaving.get(parent)?.page === c) this.pagerLeaving.delete(parent)
455
+ if (this.pagerShown.get(parent) === c) this.pagerShown.delete(parent)
456
+ this.dirty = true
457
+ }
458
+ releaseNode(id: number): void {
459
+ const n = this.nodes.get(id)
460
+ if (!n) return
461
+ this.nodes.delete(id)
462
+ this.forget(n)
463
+ if (n.parent) n.parent.children = n.parent.children.filter((x) => x !== n)
464
+ if (this.root === n) this.root = null
465
+ if (this.leaving === n) this.leaving = null
466
+ this.pagerShown.delete(id)
467
+ this.pagerLeaving.delete(id)
468
+ for (const [pager, left] of this.pagerLeaving) if (left.page === n) this.pagerLeaving.delete(pager)
469
+ if (this.focused === n) { this.focused = null; this.M._creatorWebSetShrunkRoot(0, 0); this.emitKeyboard() }
470
+ const wi = this.widgetRoots.indexOf(n)
471
+ if (wi >= 0) this.widgetRoots.splice(wi, 1)
472
+ this.vlists.delete(n)
473
+ this.pressed.delete(id)
474
+ this.dirty = true
475
+ }
476
+ /** A node leaving the tree must leave every in-flight gesture too. */
477
+ private forget(n: HNode): void {
478
+ for (const g of this.gestures.values()) {
479
+ if (g.node === n) g.node = null
480
+ if (g.scroll === n) g.scroll = null
481
+ if (g.backdrop === n) g.backdrop = null
482
+ if (g.edit === n) g.edit = null
483
+ }
484
+ for (const c of n.children) this.forget(c)
485
+ }
486
+
487
+ // ---- HostUI: content -----------------------------------------------------------------------------
488
+ setPropertyString(id: number, prop: string, value: string | null): void {
489
+ const n = this.nodes.get(id)
490
+ if (!n) return
491
+ const v = value ?? ""
492
+ switch (prop) {
493
+ case "text": n.text = v; n.wrapCache = undefined; break
494
+ case "value": n.value = v; break
495
+ case "name": n.name = v || undefined; break
496
+ case "src": {
497
+ n.src = v || undefined
498
+ const src = this.host.resolveSrc(this.srcOf(n))
499
+ if (src && !this.host.getImage(src)) {
500
+ this.host.loadImage(src).then(() => { if (this.nodes.get(id) === n && this.M._nodeHasMeasure(id)) this.M._markNodeDirty(id); this.dirty = true }).catch(() => {})
501
+ }
502
+ break
503
+ }
504
+ case "sourceRect": {
505
+ const p = v.split(" ").map(Number)
506
+ n.sourceRect = p.length === 4 ? [p[0], p[1], p[2], p[3]] : undefined
507
+ break
508
+ }
509
+ case "focus": if (v === "1" || v === "true") this.focusInput(id); else if (this.focused === n) this.blurInput(); break
510
+ case "viewName": n.viewName = v; break
511
+ case "viewParams": n.viewParams = v; break
512
+ default: break // viewParams, placeholder-like props reach the style through creator-ui
513
+ }
514
+ this.dirty = true
515
+ }
516
+ private srcOf(n: HNode): unknown { return wireSrc(n.src) }
517
+ setPropertyInt(id: number, prop: string, value: number): void {
518
+ const n = this.nodes.get(id)
519
+ if (n) { if (prop === "playerId") n.playerId = value; else if (prop === "viewId") n.viewId = value }
520
+ this.dirty = true
521
+ }
522
+ setFlags(id: number, mask: number): void { const n = this.nodes.get(id); if (n) n.flags = mask >>> 0 }
523
+
524
+ // ---- HostUI: presentation -------------------------------------------------------------------------
525
+ /** The screen that was shown stays painted until the runtime drops it — one at a time: the
526
+ * runtime lands a transition before it starts the next one. */
527
+ private leave(above: boolean): void {
528
+ if (!this.root) return
529
+ this.leaving = this.root
530
+ this.leavingAbove = above
531
+ this.root = null
532
+ }
533
+ openView(kind: number, node: number, id: number, _viewName: string, _paramsJson: string, above: boolean): void {
534
+ this.destKind = kind
535
+ const next = kind === TREE_DEST_SCREEN ? this.nodes.get(node) ?? null : null
536
+ if (next !== this.root) this.leave(!above || next === null) // a screen next to a surface is above it
537
+ if (this.leaving === next) this.leaving = null
538
+ this.root = next
539
+ if (this.focused) this.blurInput() // a navigation dismisses the keyboard
540
+ this.dirty = true
541
+ this.onDestination?.(kind, id)
542
+ }
543
+ dropView(kind: number, node: number, id: number): void {
544
+ if (kind === TREE_DEST_SCREEN) {
545
+ const n = this.nodes.get(node) ?? null
546
+ if (n !== null && this.leaving === n) this.leaving = null
547
+ if (n !== null && this.root === n) this.root = null
548
+ }
549
+ this.dirty = true
550
+ this.onDropped?.(kind, id)
551
+ }
552
+ closeView(): void { this.destKind = -1; this.leave(true); this.dirty = true; this.onDestination?.(-1, 0) }
553
+ widgetOpen(node: number, _ownerKind: number, _ownerId: number, _ownerNode: number): void {
554
+ const w = this.nodes.get(node)
555
+ if (w && !this.widgetRoots.includes(w)) this.widgetRoots.push(w)
556
+ this.dirty = true
557
+ }
558
+ widgetClose(node: number): void {
559
+ const w = this.nodes.get(node)
560
+ for (const g of this.gestures.values()) if (g.backdrop === w) g.backdrop = null
561
+ const i = w ? this.widgetRoots.indexOf(w) : -1
562
+ if (i >= 0) this.widgetRoots.splice(i, 1)
563
+ this.dirty = true
564
+ }
565
+ registerFont(url: string, fontFamily: string, weight: number, style: number, onComplete: () => void, onReject: (err: string) => void): void {
566
+ this.fontsPending++
567
+ this.host.loadFont(fontFamily, url, { weight, style: style === 2 ? "italic" : "normal" })
568
+ .then(() => { this.invalidateText(); onComplete() }, (e) => onReject(String(e)))
569
+ .finally(() => { this.fontsPending-- })
570
+ }
571
+ /** A late face changes metrics without touching any style: re-measure every text leaf. */
572
+ private invalidateText(): void {
573
+ for (const n of this.nodes.values()) {
574
+ n.wrapCache = undefined
575
+ if (n.measured && n.type !== "image" && this.M._nodeHasMeasure(n.id)) this.M._markNodeDirty(n.id)
576
+ }
577
+ this.dirty = true
578
+ }
579
+ showToast(message: string, duration: number): void { this.lastToast = message; this.options.onToast?.(message, duration) }
580
+ getButtonPressed(node: number): boolean { return this.pressed.has(node) }
581
+ getTextValue(node: number): string { return this.nodes.get(node)?.value ?? "" }
582
+ getBoundingRect(node: number): ArrayLike<number> | null {
583
+ const r = this.findRect(node)
584
+ return r ? [r.x, r.y, r.width, r.height] : null
585
+ }
586
+ longPressHandler(pointerId: number, _node: number): void {
587
+ const g = this.gestures.get(pointerId)
588
+ if (g) g.swallowClick = true
589
+ }
590
+ vlistScroll(node: number, scrollY: number, _animated: boolean): void {
591
+ const n = this.nodes.get(node)
592
+ if (n) n.scrollOffset = scrollY
593
+ this.dirty = true
594
+ }
595
+ /** "select" / "content": the pages are re-read from the runtime's pulls when the frame is built;
596
+ * nothing is in flight then (the runtime lands a pager's transition before either). */
597
+ pagerCommand(node: number, _cmd: string, _animated: boolean): void {
598
+ this.pagerLeaving.delete(node)
599
+ this.dirty = true
600
+ }
601
+ /** `page` is the top of the current tab now: the page shown there stays shown, under it or over
602
+ * it, until the runtime drops it. */
603
+ pagerShowPage(node: number, page: number, above: boolean): void {
604
+ const next = this.nodes.get(page), shown = this.pagerShown.get(node)
605
+ if (next) this.pagerShown.set(node, next)
606
+ if (shown && shown !== next) this.pagerLeaving.set(node, { page: shown, above: !above })
607
+ this.dirty = true
608
+ }
609
+ pagerDropPage(node: number, page: number): void {
610
+ if (this.pagerLeaving.get(node)?.page.id === page) this.pagerLeaving.delete(node)
611
+ this.dirty = true
612
+ }
613
+ pagerPageMounted(_page: number, _mounted: boolean): void { this.dirty = true }
614
+ refreshDone(_node: number): void {}
615
+
616
+ // ---- input: focus + values --------------------------------------------------------------------------
617
+ private rootOf(n: HNode): HNode { let r = n; while (r.parent) r = r.parent; return r }
618
+ private emitKeyboard(): void {
619
+ const h = this.keyboardHeight()
620
+ this.options.onKeyboard?.(h)
621
+ this.M._creatorWebKeyboardEvent(h, 0)
622
+ }
623
+ focusInput(id: number): void {
624
+ const n = this.nodes.get(id)
625
+ if (!n || !n.editable || this.focused === n) return
626
+ if (this.focused) this.blurInput()
627
+ this.focused = n
628
+ this.M._setNodeFocused(id, 1)
629
+ if (n.has(TREE_FLAG_FOCUS)) this.M._creatorWebNodeEvent(id, TREE_EVENT_FOCUS)
630
+ // the focused-root rule: only the root that owns the input re-flows above the keyboard band
631
+ this.M._creatorWebSetShrunkRoot(this.rootOf(n).id, this.keyboardHeight())
632
+ this.emitKeyboard()
633
+ this.dirty = true
634
+ }
635
+ blurInput(): void {
636
+ const n = this.focused
637
+ if (!n) return
638
+ this.focused = null
639
+ this.M._setNodeFocused(n.id, 0)
640
+ if (n.has(TREE_FLAG_BLUR)) this.M._creatorWebNodeEvent(n.id, TREE_EVENT_BLUR)
641
+ this.M._creatorWebSetShrunkRoot(0, 0)
642
+ this.emitKeyboard()
643
+ this.dirty = true
644
+ }
645
+ /** A value typed by the user: onChange fires (a code-set value comes through setPropertyString). */
646
+ setInputValue(id: number, value: string): void {
647
+ const n = this.nodes.get(id)
648
+ if (!n) return
649
+ n.value = value
650
+ if (n.has(TREE_FLAG_CHANGE)) this.withStr(value, (p) => this.M._creatorWebNodeEventStr(id, TREE_EVENT_CHANGE, p))
651
+ if (this.M._nodeHasMeasure(id)) this.M._markNodeDirty(id)
652
+ this.dirty = true
653
+ }
654
+ /** The return key on the focused input: onSubmit with its value, then the keyboard goes. */
655
+ submitInput(): boolean {
656
+ const n = this.focused
657
+ if (!n) return false
658
+ if (n.has(TREE_FLAG_SUBMIT)) this.withStr(n.value, (p) => this.M._creatorWebNodeEventStr(n.id, TREE_EVENT_SUBMIT, p))
659
+ if (this.focused === n) this.blurInput()
660
+ return true
661
+ }
662
+
663
+ // ---- input: scrolling ---------------------------------------------------------------------------------
664
+ private scrollMax(n: HNode): number {
665
+ const rect = this.findRect(n.id) ?? n.layoutRect ?? { width: 0, height: 0 }
666
+ const viewport = n.scrollAxis === "x" ? rect.width : rect.height
667
+ const content = n.scrollAxis === "x" ? this.M._getContentWidth(n.id) : this.M._getContentHeight(n.id)
668
+ return Math.max(0, content - viewport)
669
+ }
670
+ /** Scroll a scrollable by `delta` px along its axis (clamped); onScroll / onOverscroll fire. */
671
+ scrollBy(id: number, delta: number): boolean {
672
+ const n = this.nodes.get(id)
673
+ if (!n || !n.scrollable) return false
674
+ const max = this.scrollMax(n)
675
+ const desired = n.scrollOffset + delta
676
+ const next = Math.min(max, Math.max(0, desired))
677
+ const over = desired < 0 ? desired : desired > max ? desired - max : 0
678
+ if (over !== 0 && n.has(TREE_FLAG_OVERSCROLL)) this.M._creatorWebNodeEventNum(id, TREE_EVENT_OVERSCROLL, over)
679
+ if (next === n.scrollOffset) return false
680
+ n.scrollOffset = next
681
+ if (n.type === "vlist" && this.M._vlistExists(id)) this.M._vlistOnScroll(id, next)
682
+ if (n.has(TREE_FLAG_SCROLL)) this.M._creatorWebNodeScroll(id, next)
683
+ this.dirty = true
684
+ return true
685
+ }
686
+
687
+ // ---- input: pointers (the tgfx renderer's routing, over the render tree) -----------------------------------
688
+ private topInteractive(x: number, y: number): HNode | null {
689
+ const layers = this.frame()
690
+ for (let i = layers.length - 1; i >= 0; i--) {
691
+ const hit = hitTest(layers[i].tree, x, y, (n) => this.nodes.get(n.id)?.interactive ?? false)
692
+ if (hit) return this.nodes.get(hit.id) ?? null
693
+ if (layers[i].backdrop) return null
694
+ }
695
+ return null
696
+ }
697
+ private setPressed(n: HNode, pressed: boolean): void {
698
+ if (pressed) this.pressed.add(n.id); else this.pressed.delete(n.id)
699
+ if (this.nodes.get(n.id) === n) this.M._setNodePressed(n.id, pressed ? 1 : 0)
700
+ this.dirty = true
701
+ }
702
+ // ---- input: the mouse cursor (hover) — the tgfx renderer's tickHover over the render tree ----------------
703
+ private hoverX = 0
704
+ private hoverY = 0
705
+ private hoverInside = false
706
+ private hoverMoved = false
707
+ /** The cursor's position (logical px), button or not. Only stored: `tickHover` resolves the node
708
+ * under it once per frame and hands it to the runtime (emitHover → `$hovered`, onMouseEnter). */
709
+ pointerHover(x: number, y: number): void { this.hoverX = x; this.hoverY = y; this.hoverInside = true; this.hoverMoved = true }
710
+ /** The cursor left the surface: nothing is hovered. */
711
+ pointerLeave(): void { if (!this.hoverInside) return; this.hoverInside = false; this.hoverMoved = true }
712
+ /** Whether this runtime module routes hover (the export landed with the 2026-09-27 rebuild). */
713
+ get hasHover(): boolean { return typeof this.M._creatorWebHover === "function" }
714
+ /** Once per frame, after layout: the topmost interactive node under the cursor → the runtime.
715
+ * Resolved when the cursor moved or the frame changed under a still cursor, never otherwise. */
716
+ tickHover(): void {
717
+ if (!this.hasHover) return
718
+ if (!this.hoverMoved && !this.dirty) return
719
+ this.hoverMoved = false
720
+ const node = this.hoverInside ? this.topInteractive(this.hoverX, this.hoverY) : null
721
+ this.M._creatorWebHover(node?.id ?? 0, this.hoverX, this.hoverY)
722
+ }
723
+ pointerDown(fingerId: number, x: number, y: number): void {
724
+ const M = this.M
725
+ if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(0, fingerId, x, y)
726
+ const g: Gesture = { node: null, scroll: null, backdrop: null, edit: null, global: false, dismiss: true, lastX: x, lastY: y, acc: 0, scrolling: false, swallowClick: false }
727
+ this.gestures.set(fingerId, g)
728
+ const layers = this.frame()
729
+ // an editable takes the press (focus), a modal backdrop blocks below
730
+ for (let i = layers.length - 1; i >= 0; i--) {
731
+ const e = hitTest(layers[i].tree, x, y, (n) => this.nodes.get(n.id)?.editable ?? false)
732
+ if (e) { const n = this.nodes.get(e.id)!; this.focusInput(n.id); g.edit = n; g.dismiss = false; return }
733
+ if (layers[i].backdrop) break
734
+ }
735
+ for (let i = layers.length - 1; i >= 0; i--) {
736
+ const layer = layers[i]
737
+ const inter = hitTest(layer.tree, x, y, (n) => this.nodes.get(n.id)?.interactive ?? false)
738
+ const scrollHit = hitTest(layer.tree, x, y, (n) => !!n.scroll && n.scroll.content > (n.scroll.axis === "x" ? n.rect.width : n.rect.height) + 0.5)
739
+ const scroll = scrollHit ? this.nodes.get(scrollHit.id) ?? null : null
740
+ if (inter) {
741
+ const n = this.nodes.get(inter.id)!
742
+ g.node = n
743
+ g.scroll = scroll
744
+ g.dismiss = n.type !== "button" // a control keeps the keyboard up
745
+ this.setPressed(n, true)
746
+ M._creatorWebNodeTouchStart(n.id, fingerId, x, y)
747
+ return
748
+ }
749
+ if (scroll) { g.scroll = scroll; return }
750
+ if (layer.backdrop) {
751
+ const r = layer.tree.rect
752
+ const insideBody = x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
753
+ g.backdrop = insideBody ? null : this.nodes.get(layer.tree.id) ?? null
754
+ return
755
+ }
756
+ }
757
+ g.global = true
758
+ M._creatorWebSceneTouchStart(fingerId, x, y)
759
+ }
760
+ pointerMove(fingerId: number, x: number, y: number): void {
761
+ const g = this.gestures.get(fingerId)
762
+ if (!g) return
763
+ const M = this.M
764
+ if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(1, fingerId, x, y)
765
+ const dx = x - g.lastX, dy = y - g.lastY
766
+ g.lastX = x; g.lastY = y
767
+ g.acc += Math.abs(dx) + Math.abs(dy)
768
+ if (g.edit) return
769
+ if (!g.scrolling && g.scroll && g.acc > SCROLL_THRESHOLD) {
770
+ g.scrolling = true
771
+ if (g.node) this.setPressed(g.node, false)
772
+ }
773
+ if (g.scrolling && g.scroll) { this.scrollBy(g.scroll.id, g.scroll.scrollAxis === "x" ? -dx : -dy); return }
774
+ M._creatorWebTouchMove(fingerId, x, y, dx, dy)
775
+ }
776
+ pointerUp(fingerId: number, x: number, y: number): void {
777
+ const g = this.gestures.get(fingerId)
778
+ if (!g) return
779
+ this.gestures.delete(fingerId)
780
+ const M = this.M
781
+ if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(2, fingerId, x, y)
782
+ const dx = x - g.lastX, dy = y - g.lastY
783
+ M._creatorWebTouchEnd(fingerId, x, y, dx, dy)
784
+ if (g.edit) return
785
+ if (g.scrolling && g.scroll && g.scroll.has(TREE_FLAG_SCROLL_RELEASE)) M._creatorWebNodeEvent(g.scroll.id, TREE_EVENT_SCROLL_RELEASE)
786
+ if (g.dismiss && !g.scrolling && this.focused) this.blurInput()
787
+ if (g.node) {
788
+ this.setPressed(g.node, false)
789
+ if (!g.scrolling && !g.swallowClick && this.topInteractive(x, y) === g.node && g.node.has(TREE_FLAG_CLICK)) M._creatorWebNodeClick(g.node.id, fingerId, x, y)
790
+ } else if (g.backdrop) {
791
+ if (!g.scrolling && g.backdrop.has(TREE_FLAG_OVERLAY_TAP)) M._creatorWebNodeEvent(g.backdrop.id, TREE_EVENT_OVERLAY_TAP)
792
+ } else if (g.global) {
793
+ if (!g.scrolling) M._creatorWebSceneTouchClick(fingerId, x, y)
794
+ }
795
+ }
796
+ /** The pointer left without a release (the browser's pointercancel): every gesture state goes. */
797
+ pointerCancel(fingerId: number): void {
798
+ const g = this.gestures.get(fingerId)
799
+ if (!g) return
800
+ this.gestures.delete(fingerId)
801
+ const M = this.M
802
+ if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(3, fingerId, g.lastX, g.lastY)
803
+ if (g.node) this.setPressed(g.node, false)
804
+ M._creatorWebTouchCancel(fingerId)
805
+ }
806
+ /** A wheel notch over (x, y): scrolls the overflowing scrollable under it (a mouse host). */
807
+ wheel(x: number, y: number, dx: number, dy: number): boolean {
808
+ const layers = this.frame()
809
+ for (let i = layers.length - 1; i >= 0; i--) {
810
+ const hit = hitTest(layers[i].tree, x, y, (n) => !!n.scroll && n.scroll.content > (n.scroll.axis === "x" ? n.rect.width : n.rect.height) + 0.5)
811
+ if (hit) { const n = this.nodes.get(hit.id); return n ? this.scrollBy(n.id, n.scrollAxis === "x" ? dx : dy) : false }
812
+ if (layers[i].backdrop) return false
813
+ }
814
+ return false
815
+ }
816
+ /** A long press: the runtime asks the node's listener; a handled one swallows the release's click. */
817
+ longPress(fingerId: number, x: number, y: number): void {
818
+ const g = this.gestures.get(fingerId)
819
+ if (g?.node && g.node.has(TREE_FLAG_LONG_PRESS)) this.M._creatorWebNodeLongPress(g.node.id, fingerId, x, y)
820
+ }
821
+ isVlist(n: HNode): boolean { return this.vlists.has(n) }
822
+ }