@typecad/ui 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1443 -0
  3. package/dist/engine-index.d.ts +7 -0
  4. package/dist/engine-index.js +41 -0
  5. package/dist/index.d.ts +165 -0
  6. package/dist/index.js +42 -0
  7. package/dist/preview/build-program.d.ts +7 -0
  8. package/dist/preview/build-program.js +665 -0
  9. package/dist/preview/host-gfx.d.ts +90 -0
  10. package/dist/preview/host-gfx.js +552 -0
  11. package/dist/preview/host-ui-runtime.d.ts +241 -0
  12. package/dist/preview/host-ui-runtime.js +3236 -0
  13. package/dist/preview/types.d.ts +83 -0
  14. package/dist/preview/types.js +1 -0
  15. package/dist/types.d.ts +108 -0
  16. package/dist/types.js +10 -0
  17. package/dist/ui-engine/block-layout.d.ts +8 -0
  18. package/dist/ui-engine/block-layout.js +95 -0
  19. package/dist/ui-engine/color.d.ts +49 -0
  20. package/dist/ui-engine/color.js +273 -0
  21. package/dist/ui-engine/css-parser.d.ts +155 -0
  22. package/dist/ui-engine/css-parser.js +1058 -0
  23. package/dist/ui-engine/default-keyboards.d.ts +3 -0
  24. package/dist/ui-engine/default-keyboards.js +53 -0
  25. package/dist/ui-engine/easing.d.ts +12 -0
  26. package/dist/ui-engine/easing.js +76 -0
  27. package/dist/ui-engine/font-assets.d.ts +50 -0
  28. package/dist/ui-engine/font-assets.js +431 -0
  29. package/dist/ui-engine/html-parser.d.ts +116 -0
  30. package/dist/ui-engine/html-parser.js +284 -0
  31. package/dist/ui-engine/image-assets.d.ts +21 -0
  32. package/dist/ui-engine/image-assets.js +125 -0
  33. package/dist/ui-engine/inline-parser.d.ts +30 -0
  34. package/dist/ui-engine/inline-parser.js +66 -0
  35. package/dist/ui-engine/keyframes.d.ts +3 -0
  36. package/dist/ui-engine/keyframes.js +39 -0
  37. package/dist/ui-engine/layout-engine.d.ts +35 -0
  38. package/dist/ui-engine/layout-engine.js +203 -0
  39. package/dist/ui-engine/model.d.ts +239 -0
  40. package/dist/ui-engine/model.js +987 -0
  41. package/dist/ui-engine/rich-layout.d.ts +51 -0
  42. package/dist/ui-engine/rich-layout.js +220 -0
  43. package/dist/ui-engine/run-types.d.ts +13 -0
  44. package/dist/ui-engine/run-types.js +1 -0
  45. package/dist/ui-engine/runtime-header/antialiasing.d.ts +1 -0
  46. package/dist/ui-engine/runtime-header/antialiasing.js +141 -0
  47. package/dist/ui-engine/runtime-header/blend-bodies.d.ts +1 -0
  48. package/dist/ui-engine/runtime-header/blend-bodies.js +39 -0
  49. package/dist/ui-engine/runtime-header/canvas-helpers.d.ts +1 -0
  50. package/dist/ui-engine/runtime-header/canvas-helpers.js +155 -0
  51. package/dist/ui-engine/runtime-header/canvas-scrollbar.d.ts +1 -0
  52. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +105 -0
  53. package/dist/ui-engine/runtime-header/color-mono-refresh.d.ts +1 -0
  54. package/dist/ui-engine/runtime-header/color-mono-refresh.js +154 -0
  55. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.d.ts +1 -0
  56. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +169 -0
  57. package/dist/ui-engine/runtime-header/display-shim.d.ts +1 -0
  58. package/dist/ui-engine/runtime-header/display-shim.js +104 -0
  59. package/dist/ui-engine/runtime-header/forward-decls.d.ts +1 -0
  60. package/dist/ui-engine/runtime-header/forward-decls.js +137 -0
  61. package/dist/ui-engine/runtime-header/guard-close.d.ts +1 -0
  62. package/dist/ui-engine/runtime-header/guard-close.js +8 -0
  63. package/dist/ui-engine/runtime-header/image-drawing.d.ts +1 -0
  64. package/dist/ui-engine/runtime-header/image-drawing.js +300 -0
  65. package/dist/ui-engine/runtime-header/init-press-input.d.ts +1 -0
  66. package/dist/ui-engine/runtime-header/init-press-input.js +126 -0
  67. package/dist/ui-engine/runtime-header/keyboard.d.ts +1 -0
  68. package/dist/ui-engine/runtime-header/keyboard.js +342 -0
  69. package/dist/ui-engine/runtime-header/node-decoration.d.ts +1 -0
  70. package/dist/ui-engine/runtime-header/node-decoration.js +212 -0
  71. package/dist/ui-engine/runtime-header/paint-order-coords.d.ts +1 -0
  72. package/dist/ui-engine/runtime-header/paint-order-coords.js +190 -0
  73. package/dist/ui-engine/runtime-header/paint-rects-repair.d.ts +1 -0
  74. package/dist/ui-engine/runtime-header/paint-rects-repair.js +553 -0
  75. package/dist/ui-engine/runtime-header/scroll-physics.d.ts +1 -0
  76. package/dist/ui-engine/runtime-header/scroll-physics.js +161 -0
  77. package/dist/ui-engine/runtime-header/state-bindings-nav.d.ts +1 -0
  78. package/dist/ui-engine/runtime-header/state-bindings-nav.js +176 -0
  79. package/dist/ui-engine/runtime-header/structs.d.ts +1 -0
  80. package/dist/ui-engine/runtime-header/structs.js +220 -0
  81. package/dist/ui-engine/runtime-header/text-rendering.d.ts +1 -0
  82. package/dist/ui-engine/runtime-header/text-rendering.js +715 -0
  83. package/dist/ui-engine/runtime-header/tick/bindings-phase.d.ts +1 -0
  84. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +103 -0
  85. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.d.ts +1 -0
  86. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +1051 -0
  87. package/dist/ui-engine/runtime-header/tick/flush-phase.d.ts +1 -0
  88. package/dist/ui-engine/runtime-header/tick/flush-phase.js +17 -0
  89. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.d.ts +1 -0
  90. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +15 -0
  91. package/dist/ui-engine/runtime-header/tick/transitions-phase.d.ts +1 -0
  92. package/dist/ui-engine/runtime-header/tick/transitions-phase.js +213 -0
  93. package/dist/ui-engine/runtime-header/tick-motion-helpers.d.ts +1 -0
  94. package/dist/ui-engine/runtime-header/tick-motion-helpers.js +26 -0
  95. package/dist/ui-engine/runtime-header/tick.d.ts +1 -0
  96. package/dist/ui-engine/runtime-header/tick.js +17 -0
  97. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.d.ts +1 -0
  98. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +406 -0
  99. package/dist/ui-engine/runtime-header/types-defines.d.ts +1 -0
  100. package/dist/ui-engine/runtime-header/types-defines.js +79 -0
  101. package/dist/ui-engine/runtime-header.d.ts +1 -0
  102. package/dist/ui-engine/runtime-header.js +69 -0
  103. package/dist/ui-engine/scroll-memory-diagnostics.d.ts +16 -0
  104. package/dist/ui-engine/scroll-memory-diagnostics.js +58 -0
  105. package/dist/ui-engine/select-engine.d.ts +4 -0
  106. package/dist/ui-engine/select-engine.js +19 -0
  107. package/dist/ui-engine/style-resolver.d.ts +77 -0
  108. package/dist/ui-engine/style-resolver.js +341 -0
  109. package/dist/ui-engine/text-layout.d.ts +19 -0
  110. package/dist/ui-engine/text-layout.js +92 -0
  111. package/dist/ui-engine/transpile-ui.d.ts +11 -0
  112. package/dist/ui-engine/transpile-ui.js +33 -0
  113. package/dist/ui-engine/ua-stylesheet.d.ts +3 -0
  114. package/dist/ui-engine/ua-stylesheet.js +56 -0
  115. package/dist/ui-engine/ui-file-splitter.d.ts +14 -0
  116. package/dist/ui-engine/ui-file-splitter.js +47 -0
  117. package/dist/ui-engine/ui-lowering.d.ts +33 -0
  118. package/dist/ui-engine/ui-lowering.js +390 -0
  119. package/dist/ui-engine/ui-registry.d.ts +81 -0
  120. package/dist/ui-engine/ui-registry.js +429 -0
  121. package/dist/ui-engine/yoga-layout.d.ts +12 -0
  122. package/dist/ui-engine/yoga-layout.js +314 -0
  123. package/package.json +86 -0
  124. package/src/engine-index.ts +51 -0
  125. package/src/index.ts +232 -0
  126. package/src/preview/build-program.ts +734 -0
  127. package/src/preview/host-gfx.ts +580 -0
  128. package/src/preview/host-ui-runtime.ts +3245 -0
  129. package/src/preview/types.ts +96 -0
  130. package/src/types.ts +127 -0
  131. package/src/ui-engine/block-layout.ts +104 -0
  132. package/src/ui-engine/color.ts +263 -0
  133. package/src/ui-engine/css-parser.ts +1003 -0
  134. package/src/ui-engine/default-keyboards.ts +58 -0
  135. package/src/ui-engine/easing.ts +53 -0
  136. package/src/ui-engine/font-assets.ts +512 -0
  137. package/src/ui-engine/html-parser.ts +397 -0
  138. package/src/ui-engine/image-assets.ts +170 -0
  139. package/src/ui-engine/inline-parser.ts +78 -0
  140. package/src/ui-engine/keyframes.ts +47 -0
  141. package/src/ui-engine/layout-engine.ts +228 -0
  142. package/src/ui-engine/model.ts +1225 -0
  143. package/src/ui-engine/rich-layout.ts +257 -0
  144. package/src/ui-engine/run-types.ts +14 -0
  145. package/src/ui-engine/runtime-header/antialiasing.ts +141 -0
  146. package/src/ui-engine/runtime-header/blend-bodies.ts +39 -0
  147. package/src/ui-engine/runtime-header/canvas-helpers.ts +155 -0
  148. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +105 -0
  149. package/src/ui-engine/runtime-header/color-mono-refresh.ts +154 -0
  150. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +169 -0
  151. package/src/ui-engine/runtime-header/display-shim.ts +104 -0
  152. package/src/ui-engine/runtime-header/forward-decls.ts +137 -0
  153. package/src/ui-engine/runtime-header/guard-close.ts +8 -0
  154. package/src/ui-engine/runtime-header/image-drawing.ts +300 -0
  155. package/src/ui-engine/runtime-header/init-press-input.ts +126 -0
  156. package/src/ui-engine/runtime-header/keyboard.ts +342 -0
  157. package/src/ui-engine/runtime-header/node-decoration.ts +212 -0
  158. package/src/ui-engine/runtime-header/paint-order-coords.ts +190 -0
  159. package/src/ui-engine/runtime-header/paint-rects-repair.ts +553 -0
  160. package/src/ui-engine/runtime-header/scroll-physics.ts +161 -0
  161. package/src/ui-engine/runtime-header/state-bindings-nav.ts +176 -0
  162. package/src/ui-engine/runtime-header/structs.ts +220 -0
  163. package/src/ui-engine/runtime-header/text-rendering.ts +715 -0
  164. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +103 -0
  165. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +1051 -0
  166. package/src/ui-engine/runtime-header/tick/flush-phase.ts +17 -0
  167. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +15 -0
  168. package/src/ui-engine/runtime-header/tick/transitions-phase.ts +213 -0
  169. package/src/ui-engine/runtime-header/tick-motion-helpers.ts +26 -0
  170. package/src/ui-engine/runtime-header/tick.ts +18 -0
  171. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +406 -0
  172. package/src/ui-engine/runtime-header/types-defines.ts +79 -0
  173. package/src/ui-engine/runtime-header.ts +71 -0
  174. package/src/ui-engine/scroll-memory-diagnostics.ts +79 -0
  175. package/src/ui-engine/select-engine.ts +21 -0
  176. package/src/ui-engine/style-resolver.ts +416 -0
  177. package/src/ui-engine/text-layout.ts +114 -0
  178. package/src/ui-engine/transpile-ui.ts +45 -0
  179. package/src/ui-engine/ua-stylesheet.ts +60 -0
  180. package/src/ui-engine/ui-file-splitter.ts +59 -0
  181. package/src/ui-engine/ui-lowering.ts +459 -0
  182. package/src/ui-engine/ui-registry.ts +530 -0
  183. package/src/ui-engine/yoga-layout.ts +309 -0
@@ -0,0 +1,3236 @@
1
+ import { resolveColor, resolveColor888 } from "../ui-engine/color.js";
2
+ import { DEFAULT_ALPHA_KEYBOARD, DEFAULT_NUMBER_KEYBOARD } from "../ui-engine/default-keyboards.js";
3
+ import { resolveScrollConfig } from "@typecad/cuttlefish/api/shared";
4
+ import { easeCurveLerpK } from "../ui-engine/easing.js";
5
+ import { layoutText } from "../ui-engine/text-layout.js";
6
+ import { blendRgb565, blendRgb888, HostAdafruitGFX } from "./host-gfx.js";
7
+ const UI_TEXT_BUF = 32;
8
+ const UI_TOUCH_DEBOUNCE_MS = 50;
9
+ const UI_TOUCH_HOLD_MS = 600;
10
+ const UI_DRAG_THRESHOLD = 10;
11
+ const UI_SCROLL_EDGE_SNAP_PX = 12;
12
+ // Scroll physics (preview = capacitive + full-render tier; mirrors the C++ engine).
13
+ const UI_SCROLL_MAX_OVERSCROLL = 40;
14
+ const UI_SCROLL_STIFFNESS = 0.5;
15
+ const UI_SCROLL_SETTLE_MS = 180;
16
+ const UI_KB_REPEAT_MS = 100;
17
+ const UI_KB_TEXT_H = 24;
18
+ const UI_TRANSITION_SNAP_MS = 100;
19
+ const UI_MAX_BUFFERED_PAINT_PIXELS = 20000;
20
+ const DEFAULT_KEY_BG = 0x4208;
21
+ const DEFAULT_KEY_FG = 0xffff;
22
+ const DEFAULT_KEY_BORDER = 0xffff;
23
+ const DEFAULT_KB_BG = 0x0000;
24
+ function clampText(text) {
25
+ // Booleans must stringify as 1/0, not "true"/"false": hardware lowers bool
26
+ // signals in text bindings via snprintf("%d") (numericFormat → %d for bool),
27
+ // so the device prints the integer. JS String(true) gives "true", which would
28
+ // diverge from the device for any bool reaching a text buffer (binding value
29
+ // or {expr} interpolation). Coerce here so every text-buffer write agrees.
30
+ if (text === true)
31
+ return "1".slice(0, UI_TEXT_BUF);
32
+ if (text === false)
33
+ return "0".slice(0, UI_TEXT_BUF);
34
+ return String(text ?? "").slice(0, UI_TEXT_BUF);
35
+ }
36
+ /** If `text` is a `ui.signal(<arg>)` initializer, return the source text of
37
+ * `<arg>` (balanced-paren scanned, so nested calls like `ui.signal(other())`
38
+ * are preserved). Returns undefined for any other initializer shape. Mirrors
39
+ * the ir/transformers/variables.ts detection that lowers `const X = ui.signal(v)`
40
+ * to `int X = v;`. */
41
+ function matchSignalInitializer(text) {
42
+ if (!text)
43
+ return undefined;
44
+ const m = text.match(/^\s*ui\.signal\s*\(/);
45
+ if (!m)
46
+ return undefined;
47
+ const argStart = m[0].length; // index just past `ui.signal(`
48
+ const argEnd = findMatchingCloseParen(text, argStart);
49
+ if (argEnd < 0)
50
+ return undefined;
51
+ // Require the close paren to be the last non-whitespace token so `ui.signal(0) + 1`
52
+ // isn't misread as a plain signal decl.
53
+ if (text.slice(argEnd + 1).trim() !== "")
54
+ return undefined;
55
+ return text.slice(argStart, argEnd).trim();
56
+ }
57
+ /** Given `text` and an index `openIdx` pointing at or just after an opening
58
+ * paren, return the index of the matching `)`. If `text[openIdx]` is already
59
+ * `(`, scanning starts there; otherwise `openIdx-1` must be `(`. Returns -1 if
60
+ * no matching close paren is found. */
61
+ function findMatchingCloseParen(text, openIdx) {
62
+ const start = text[openIdx] === "(" ? openIdx : openIdx - 1;
63
+ if (text[start] !== "(")
64
+ return -1;
65
+ let depth = 0;
66
+ for (let i = start; i < text.length; i++) {
67
+ const ch = text[i];
68
+ if (ch === "(")
69
+ depth++;
70
+ else if (ch === ")") {
71
+ depth--;
72
+ if (depth === 0)
73
+ return i;
74
+ }
75
+ }
76
+ return -1;
77
+ }
78
+ function lerpColor(a, b, k100) {
79
+ if (k100 >= 100)
80
+ return b & 0xffff;
81
+ const ar = (a >> 11) & 0x1f;
82
+ const ag = (a >> 5) & 0x3f;
83
+ const ab = a & 0x1f;
84
+ const br = (b >> 11) & 0x1f;
85
+ const bg = (b >> 5) & 0x3f;
86
+ const bb = b & 0x1f;
87
+ const r = ar + Math.trunc(((br - ar) * k100) / 100);
88
+ const g = ag + Math.trunc(((bg - ag) * k100) / 100);
89
+ const bl = ab + Math.trunc(((bb - ab) * k100) / 100);
90
+ return ((r & 0x1f) << 11) | ((g & 0x3f) << 5) | (bl & 0x1f);
91
+ }
92
+ // Module-level current color format, seeded by PreviewUIRuntime's constructor.
93
+ // Lets the free resolveRuntimeColor helper resolve at the target's depth without
94
+ // `this` access (keyboard + binding callbacks are module-scope functions).
95
+ // rgb666 → 888 (blends keep precision, quantize at the canvas push); else 565.
96
+ let runtimeColorFormat = "rgb565";
97
+ function resolveRuntimeColor(value) {
98
+ // rgb666 and rgb888 both store full 888 internally (quantization happens at
99
+ // the push boundary for rgb666; rgb888 carries 888 to the surface).
100
+ const is888 = runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888";
101
+ const mask = is888 ? 0xffffff : 0xffff;
102
+ if (typeof value === "number")
103
+ return value & mask;
104
+ if (typeof value === "string") {
105
+ return (is888 ? resolveColor888(value) : resolveColor(value, "rgb565")) & mask;
106
+ }
107
+ return 0;
108
+ }
109
+ /** Blend by opacity in the active color depth (888 for rgb666/rgb888, 565 otherwise).
110
+ * Mirrors the device's UI_COLOR_DEPTH-driven ui_blend macro — the value depth
111
+ * and the blend math switch together (see Phase 1 counterexample). */
112
+ function blendRuntime(fg, bg, opacity) {
113
+ return (runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888")
114
+ ? blendRgb888(fg, bg, opacity)
115
+ : blendRgb565(fg, bg, opacity);
116
+ }
117
+ function mergeClassRules(classes, rules) {
118
+ const merged = {};
119
+ const classSet = new Set(classes ?? []);
120
+ for (const rule of rules) {
121
+ // Only match single-compound selectors where all simples are classes in the set.
122
+ if (rule.selector.compounds.length !== 1)
123
+ continue;
124
+ const compound = rule.selector.compounds[0];
125
+ let ok = true;
126
+ for (const s of compound) {
127
+ if (s.kind !== "class" || !classSet.has(s.name)) {
128
+ ok = false;
129
+ break;
130
+ }
131
+ }
132
+ if (ok)
133
+ Object.assign(merged, rule.properties);
134
+ }
135
+ return merged;
136
+ }
137
+ function resolveKeyboardBackground(template, rules) {
138
+ const style = mergeClassRules(template.classes, rules);
139
+ return style.background ? resolveRuntimeColor(style.background) : DEFAULT_KB_BG;
140
+ }
141
+ function resolveKeyStyle(key, template, rules) {
142
+ const style = mergeClassRules([...(template.classes ?? []), ...(key.classes ?? [])], rules);
143
+ return {
144
+ bg: style.background ? resolveRuntimeColor(style.background) : DEFAULT_KEY_BG,
145
+ fg: style.color ? resolveRuntimeColor(style.color) : DEFAULT_KEY_FG,
146
+ borderColor: style.borderColor ? resolveRuntimeColor(style.borderColor) : DEFAULT_KEY_BORDER,
147
+ };
148
+ }
149
+ const KF_BG = 1;
150
+ const KF_FG = 2;
151
+ const KF_OPACITY = 4;
152
+ const KF_TRANSFORM = 8;
153
+ const KF_SIZE = 16;
154
+ function cloneProgram(program) {
155
+ return {
156
+ nodes: program.nodes.map((node) => ({
157
+ ...node,
158
+ box: { ...node.box },
159
+ classes: [...node.classes],
160
+ options: node.options?.map((option) => ({ ...option })),
161
+ screenId: node.screenId ?? 0,
162
+ dirty: false,
163
+ textBuffer: "",
164
+ hasTextBinding: false,
165
+ lastTextWidth: node.kind === "progress" || node.kind === "range" ? -1 : 0,
166
+ lastTextHeight: 0,
167
+ overscrollPx: 0,
168
+ settling: false,
169
+ lastPaintedScrollY: 0,
170
+ lineHeight: node.lineHeight || 0,
171
+ whiteSpaceMode: node.whiteSpaceMode ?? (node.nowrap ? 1 : 0),
172
+ zIndex: node.zIndex ?? 0,
173
+ value: node.value,
174
+ })),
175
+ transitions: (program.transitions ?? []).map((transition) => ({ ...transition, active: false, elapsed: 0 })),
176
+ animations: (program.animations ?? []).map((animation) => ({
177
+ ...animation,
178
+ elapsed: 0,
179
+ active: true,
180
+ lastUpdateMs: 0,
181
+ })),
182
+ };
183
+ }
184
+ export class PreviewUIRuntime {
185
+ snapshot;
186
+ gfx;
187
+ screen = {};
188
+ nodes;
189
+ transitions;
190
+ keyframeSets;
191
+ animations;
192
+ fontAssets;
193
+ imageAssets;
194
+ bindings;
195
+ listStates;
196
+ callbacks;
197
+ pinControls;
198
+ canvasBindings;
199
+ intervals;
200
+ initialAssignments;
201
+ /** Module-scoped `let`/`const`/`var` bindings, seeded once and shared (mutably)
202
+ * across every callback body — mirrors the device hoisting them to globals. */
203
+ moduleScope = {};
204
+ /** Names declared via `const X = ui.signal(...)` — lowered to plain device
205
+ * variables on hardware, so the preview stores the bare value (not the `ui`
206
+ * facade's getter) and rewrites `X()`/`X.set(v)` reads/writes accordingly
207
+ * (see normalizeScript). Mirrors ir/transformers/variables.ts + ui-callback-
208
+ * lowering.ts so template interpolations and callbacks see the same slot. */
209
+ signalNames = new Set();
210
+ onFrame;
211
+ onDiagnostics;
212
+ screenCount;
213
+ timers = [];
214
+ activeScreen = 0;
215
+ touchState = 0;
216
+ touchNode = -1;
217
+ // Awaitable tap source (mirrors the device __ui_tap_seq / __ui_tap_node).
218
+ // Incremented on every completed tap (after click/release dispatch) so an
219
+ // `await ui.onTap()` Promise can resolve by polling tapSeq. tapNode records
220
+ // the hit node (-1 = empty space) for per-element awaiters. Public so the
221
+ // onTap shim in build-program.ts can read them.
222
+ tapSeq = 0;
223
+ tapNode = -1;
224
+ // Modeled GPIO levels for ui.watchPin pins. Pins default to HIGH (the device
225
+ // pulls them up via INPUT_PULLUP semantics); watchPin fires on HIGH→LOW. This
226
+ // map lets the autonomous poller AND a deterministic setPinLevel() test hook
227
+ // observe the same state. Public so tests can drive edges without wall-clock
228
+ // waits; the real ~20ms poller in start() mirrors the device's microtask pump.
229
+ pinLevels = new Map();
230
+ touchDownTime = 0;
231
+ lastTouchTime = -UI_TOUCH_DEBOUNCE_MS;
232
+ lastReleaseTime = -UI_TOUCH_DEBOUNCE_MS;
233
+ lastTickTime = Date.now();
234
+ dragStartX = 0;
235
+ dragStartY = 0;
236
+ lastTouchX = 0;
237
+ lastTouchY = 0;
238
+ isDragging = false;
239
+ // Unified scroll gesture: one owner per gesture (single hit-scan; lists are
240
+ // scrollable and found by the same scan). Settle-animation state lives here.
241
+ scrollNode = -1;
242
+ settleStartMs = 0;
243
+ settleFromOverscroll = 0; // settle start value (bounce-back; +top/-bottom)
244
+ settleFromScrollY = 0; // settle start value (edge snap; +toward 0, -toward max)
245
+ rangeNode = -1;
246
+ keyboardVisible = false;
247
+ keyboardDirty = 0;
248
+ keyboardTarget = -1;
249
+ keyboardKeys = [];
250
+ keyboardRows = 0;
251
+ keyboardCols = 0;
252
+ keyboardBg = DEFAULT_KB_BG;
253
+ keyboardBox = { x: 0, y: 0, w: 0, h: 0 };
254
+ keyboardBuffer = "";
255
+ keyboardMaxLen = UI_TEXT_BUF;
256
+ keyboardShift = false;
257
+ keyboardBackspaceHeld = false;
258
+ keyboardBackspaceRepeat = 0;
259
+ keyboardPressedKey = -1;
260
+ keyboardRepaintKey = -1;
261
+ directFrameChanged = false;
262
+ scrollDragScale;
263
+ constructor(snapshot, options = {}) {
264
+ this.snapshot = snapshot;
265
+ const { nodes, transitions, animations } = cloneProgram(snapshot.program);
266
+ // Seed the module-level format so the free resolveRuntimeColor helper (used
267
+ // by keyboard + binding callbacks without `this` access) resolves in the
268
+ // target's depth: 888 for rgb666 (blends keep precision), 565 otherwise.
269
+ runtimeColorFormat = snapshot.program.colorFormat;
270
+ this.nodes = nodes;
271
+ this.transitions = transitions;
272
+ this.keyframeSets = snapshot.program.keyframeSets ?? [];
273
+ this.animations = animations;
274
+ this.fontAssets = snapshot.program.fontAssets ?? [];
275
+ this.imageAssets = snapshot.program.imageAssets ?? [];
276
+ this.bindings = snapshot.bindings;
277
+ this.listStates = (snapshot.listBindings ?? []).map((binding) => ({
278
+ ...binding,
279
+ itemCount: 0,
280
+ itemHeight: 24,
281
+ contentHeight: 0,
282
+ }));
283
+ this.callbacks = snapshot.callbacks;
284
+ this.pinControls = snapshot.pinControls;
285
+ this.canvasBindings = snapshot.canvasBindings ?? [];
286
+ this.intervals = snapshot.intervals;
287
+ this.initialAssignments = snapshot.initialAssignments;
288
+ this.screenCount = Math.max(1, ...this.nodes.map((node) => (node.screenId ?? 0) + 1));
289
+ this.scrollDragScale = resolveScrollConfig(snapshot.program.display ?? {}).dragScale;
290
+ this.onFrame = options.onFrame;
291
+ this.onDiagnostics = options.onDiagnostics;
292
+ this.gfx = new HostAdafruitGFX(snapshot.program.width, snapshot.program.height, new Uint8Array(snapshot.font));
293
+ // Snap draw colors to black/white for mono/e-ink targets (parity with the
294
+ // device's UI_NATIVE_MONO draw-path snap). No-op for color targets.
295
+ this.gfx.setMonoSnap(snapshot.program.colorFormat === "mono");
296
+ this.createScreenProxy();
297
+ }
298
+ start() {
299
+ this.gfx.begin();
300
+ this.gfx.setRotation(this.snapshot.program.display?.rotation ?? 1);
301
+ this.gfx.fillScreen(0x0000);
302
+ this.seedModuleScope();
303
+ this.applyInitialAssignments();
304
+ this.uiInit();
305
+ this.tick(16);
306
+ for (const interval of this.intervals) {
307
+ this.timers.push(setInterval(() => {
308
+ this.runBody(interval.body);
309
+ this.tick();
310
+ }, interval.delayMs));
311
+ }
312
+ // ui.watchPin: poll each watched pin every ~20ms for HIGH→LOW edges,
313
+ // mirroring the device's microtask-pump watcher (no ISRs; natural debounce
314
+ // from the poll interval). Pin levels default to HIGH (INPUT_PULLUP). The
315
+ // same edge check is exposed synchronously via setPinLevel() for tests.
316
+ for (const control of this.pinControls) {
317
+ if (control.kind !== "watch")
318
+ continue;
319
+ this.pinLevels.set(control.pin, 1);
320
+ this.timers.push(setInterval(() => {
321
+ this.pollWatchPin(control.pin, control.body);
322
+ }, 20));
323
+ }
324
+ }
325
+ /** Drive a GPIO level for a watched pin and synchronously fire the watch
326
+ * callback on a HIGH→LOW edge. Test hook so edge behavior is deterministic
327
+ * without wall-clock waits; the real ~20ms poller in start() shares this
328
+ * path. Mirrors the device's falling-edge contract. */
329
+ setPinLevel(pin, level) {
330
+ this.pinLevels.set(pin, level);
331
+ const control = this.pinControls.find((c) => c.kind === "watch" && c.pin === pin);
332
+ if (control)
333
+ this.pollWatchPin(pin, control.body);
334
+ }
335
+ pollWatchPin(pin, body) {
336
+ if (!body)
337
+ return;
338
+ // Edge detection lives in the level setter: we only fire when the previous
339
+ // level was HIGH and the current is LOW. Track the "previous" level in a
340
+ // second slot so repeated polls at LOW don't refire.
341
+ const prev = this.pinLevels.get(`__prev_${pin}`) ?? 1;
342
+ const curr = this.pinLevels.get(pin) ?? 1;
343
+ this.pinLevels.set(`__prev_${pin}`, curr);
344
+ if (prev === 1 && curr === 0) {
345
+ this.runBody(body);
346
+ this.tick();
347
+ }
348
+ }
349
+ stop() {
350
+ for (const timer of this.timers)
351
+ clearInterval(timer);
352
+ this.timers.length = 0;
353
+ }
354
+ tick(deltaMs) {
355
+ const now = Date.now();
356
+ const delta = deltaMs ?? Math.max(0, now - this.lastTickTime);
357
+ this.lastTickTime = now;
358
+ this.evaluateBindings();
359
+ this.advanceTransitions(delta);
360
+ this.advanceAnimations(delta);
361
+ // Advance any in-flight scroll settle animation (bounce-back / edge-snap).
362
+ for (let i = 0; i < this.nodes.length; i++) {
363
+ if (this.nodes[i].settling)
364
+ this.advanceScrollSettle(i);
365
+ }
366
+ const changed = this.drawDirty() || this.directFrameChanged;
367
+ this.directFrameChanged = false;
368
+ if (changed) {
369
+ this.onFrame?.(this.gfx.toRgbaBytes());
370
+ }
371
+ }
372
+ pointerDown(x, y) {
373
+ this.handleTouch(x, y);
374
+ this.tick();
375
+ }
376
+ pointerMove(x, y) {
377
+ if (this.touchState !== 0) {
378
+ this.handleTouch(x, y);
379
+ this.tick();
380
+ }
381
+ }
382
+ pointerUp() {
383
+ this.handleNoTouch();
384
+ this.tick();
385
+ }
386
+ triggerPin(control) {
387
+ if (control.kind === "toggle" && control.nodeIndex !== undefined) {
388
+ const node = this.nodes[control.nodeIndex];
389
+ node.value = node.value ? 0 : 1;
390
+ this.markDirty(control.nodeIndex);
391
+ this.runBody(control.body);
392
+ }
393
+ else if (control.kind === "change" && control.nodeIndex !== undefined) {
394
+ const node = this.nodes[control.nodeIndex];
395
+ const count = Math.max(1, control.optionCount ?? 2);
396
+ node.value = (node.value + 1) % count;
397
+ this.markDirty(control.nodeIndex);
398
+ this.runBody(control.body);
399
+ }
400
+ else if (control.kind === "press" && control.nodeIndex !== undefined) {
401
+ this.uiOnPress(control.nodeIndex);
402
+ }
403
+ else if (control.kind === "release" && control.nodeIndex !== undefined) {
404
+ this.uiOnRelease(control.nodeIndex);
405
+ }
406
+ else if (control.kind === "watch") {
407
+ this.runBody(control.body);
408
+ }
409
+ this.tick();
410
+ }
411
+ createScreenProxy() {
412
+ for (const node of this.nodes) {
413
+ if (!node.id)
414
+ continue;
415
+ const proxy = {
416
+ get value() {
417
+ return node.value;
418
+ },
419
+ set value(v) {
420
+ node.value = Number(v) || 0;
421
+ node.dirty = true;
422
+ },
423
+ onClick: () => undefined,
424
+ onHold: () => undefined,
425
+ onRelease: () => undefined,
426
+ };
427
+ if (node.kind === "input") {
428
+ Object.defineProperty(proxy, "text", {
429
+ enumerable: true,
430
+ get() {
431
+ return node.textBuffer;
432
+ },
433
+ set(v) {
434
+ node.textBuffer = clampText(v);
435
+ node.dirty = true;
436
+ },
437
+ });
438
+ }
439
+ Object.defineProperty(this.screen, node.id, {
440
+ enumerable: true,
441
+ value: proxy,
442
+ });
443
+ }
444
+ }
445
+ applyInitialAssignments() {
446
+ for (const assignment of this.initialAssignments) {
447
+ const value = this.evaluateExpression(assignment.expression);
448
+ this.nodes[assignment.nodeIndex].value = Number(value) || 0;
449
+ this.markDirty(assignment.nodeIndex);
450
+ }
451
+ }
452
+ /** Seed module-scoped variables from their initializers, evaluated once. These
453
+ * live for the lifetime of the runtime and are shared mutably with callback
454
+ * bodies via runBody/evaluateExpression (see moduleVarNames()). */
455
+ seedModuleScope() {
456
+ for (const v of this.snapshot.moduleVars ?? []) {
457
+ if (!/^[$A-Z_a-z][$\w]*$/.test(v.name))
458
+ continue;
459
+ // `const X = ui.signal(value)` lowers to a plain device variable on
460
+ // hardware (variables.ts rewrites it to `int X = value;`). Mirror that
461
+ // here: store the bare initial value, not the `ui` facade's getter, and
462
+ // record the name so normalizeScript can rewrite X()/X.set(v) reads/writes
463
+ // into direct variable access. Otherwise a template `${X}` stringifies the
464
+ // getter source ("() => value") instead of the value.
465
+ const signalArg = matchSignalInitializer(v.initializer);
466
+ if (signalArg !== undefined) {
467
+ this.signalNames.add(v.name);
468
+ this.moduleScope[v.name] = signalArg === "" ? 0 : this.evaluateExpression(signalArg);
469
+ }
470
+ else {
471
+ this.moduleScope[v.name] = v.initializer !== undefined
472
+ ? this.evaluateExpression(v.initializer)
473
+ : undefined;
474
+ }
475
+ }
476
+ }
477
+ moduleVarNames() {
478
+ return (this.snapshot.moduleVars ?? [])
479
+ .map((v) => v.name)
480
+ .filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name));
481
+ }
482
+ uiInit() {
483
+ for (const node of this.nodes)
484
+ node.dirty = true;
485
+ for (const binding of this.bindings) {
486
+ if (binding.property !== "text")
487
+ continue;
488
+ const node = this.nodes[binding.nodeIndex];
489
+ node.hasTextBinding = true;
490
+ node.textBuffer = clampText(node.text ?? "");
491
+ }
492
+ for (const node of this.nodes) {
493
+ if (node.tag === "select" && node.options && node.options.length > 0) {
494
+ node.hasTextBinding = true;
495
+ node.textBuffer = clampText(node.options[0]?.text ?? node.text ?? "");
496
+ }
497
+ }
498
+ for (const list of this.listStates) {
499
+ this.refreshListState(list);
500
+ }
501
+ }
502
+ isActiveNode(node) {
503
+ return (node.screenId ?? 0) === this.activeScreen;
504
+ }
505
+ isEffectivelyVisible(node) {
506
+ if (!node.visible)
507
+ return false;
508
+ let parent = node.parentIndex;
509
+ while (parent >= 0 && this.nodes[parent]) {
510
+ if (!this.nodes[parent].visible)
511
+ return false;
512
+ parent = this.nodes[parent].parentIndex;
513
+ }
514
+ return true;
515
+ }
516
+ drawsBefore(a, b) {
517
+ const az = Math.trunc(a.zIndex ?? 0);
518
+ const bz = Math.trunc(b.zIndex ?? 0);
519
+ if (az !== bz)
520
+ return az < bz;
521
+ return a.index < b.index;
522
+ }
523
+ compareDrawOrder(a, b) {
524
+ const az = Math.trunc(a.zIndex ?? 0);
525
+ const bz = Math.trunc(b.zIndex ?? 0);
526
+ if (az !== bz)
527
+ return az - bz;
528
+ return a.index - b.index;
529
+ }
530
+ navigate(screenIdx) {
531
+ const next = Math.trunc(Number(screenIdx));
532
+ if (!Number.isFinite(next) || next < 0 || next >= this.screenCount || next === this.activeScreen)
533
+ return;
534
+ this.activeScreen = next;
535
+ if (this.keyboardVisible) {
536
+ this.keyboardVisible = false;
537
+ this.keyboardDirty = 0;
538
+ this.keyboardTarget = -1;
539
+ this.keyboardPressedKey = -1;
540
+ this.keyboardBackspaceHeld = false;
541
+ }
542
+ this.gfx.fillScreen(0x0000);
543
+ for (const node of this.nodes) {
544
+ node.dirty = true;
545
+ if (node.kind === "progress" || node.kind === "range")
546
+ node.lastTextWidth = -1;
547
+ else
548
+ node.lastTextWidth = 0;
549
+ node.lastTextHeight = 0;
550
+ }
551
+ }
552
+ evaluateBindings() {
553
+ for (const binding of this.bindings) {
554
+ const node = this.nodes[binding.nodeIndex];
555
+ const value = this.evaluateExpression(binding.expression);
556
+ if (binding.property === "text") {
557
+ const text = clampText(value);
558
+ if (text !== node.textBuffer) {
559
+ node.textBuffer = text;
560
+ this.markDirty(binding.nodeIndex);
561
+ }
562
+ }
563
+ else if (binding.property === "background") {
564
+ const next = resolveRuntimeColor(value);
565
+ if (next !== node.bg) {
566
+ node.bg = next;
567
+ node.hasBg = true;
568
+ this.markDirty(binding.nodeIndex);
569
+ }
570
+ }
571
+ else if (binding.property === "color") {
572
+ const next = resolveRuntimeColor(value);
573
+ if (next !== node.fg) {
574
+ node.fg = next;
575
+ this.markDirty(binding.nodeIndex);
576
+ }
577
+ }
578
+ else if (binding.property === "borderColor") {
579
+ const next = resolveRuntimeColor(value);
580
+ if (next !== node.borderColor) {
581
+ node.borderColor = next;
582
+ this.markDirty(binding.nodeIndex);
583
+ }
584
+ }
585
+ else if (binding.property === "visible") {
586
+ const next = Boolean(value);
587
+ if (next !== node.visible) {
588
+ this.setVisible(binding.nodeIndex, next);
589
+ }
590
+ }
591
+ else if (binding.property === "value") {
592
+ const next = Math.trunc(Number(value) || 0);
593
+ if (next !== node.value) {
594
+ node.value = next;
595
+ this.markDirty(binding.nodeIndex);
596
+ }
597
+ }
598
+ }
599
+ for (const node of this.nodes) {
600
+ if (node.tag !== "select" || !node.options || node.options.length === 0)
601
+ continue;
602
+ const next = clampText(node.options[node.value]?.text ?? node.options[0]?.text ?? "");
603
+ if (next !== node.textBuffer) {
604
+ node.textBuffer = next;
605
+ this.markDirty(node.index);
606
+ }
607
+ }
608
+ for (const list of this.listStates) {
609
+ this.refreshListState(list);
610
+ }
611
+ }
612
+ refreshListState(list) {
613
+ const node = this.nodes[list.nodeIndex];
614
+ if (!node)
615
+ return;
616
+ const countValue = this.evaluateExpression(list.countExpression);
617
+ const itemCount = Math.max(0, Math.trunc(Number(countValue) || 0));
618
+ const itemHeight = Math.max(1, Math.trunc(node.listItemHeight || list.itemHeight || 24));
619
+ const contentHeight = itemCount * itemHeight;
620
+ const maxScroll = Math.max(0, contentHeight - node.box.h);
621
+ // Scroll lives on the node now; keep it clamped as the content size changes.
622
+ const nextScrollY = Math.max(0, Math.min(maxScroll, node.scrollY));
623
+ const changed = itemCount !== list.itemCount ||
624
+ itemHeight !== list.itemHeight ||
625
+ contentHeight !== list.contentHeight ||
626
+ nextScrollY !== node.scrollY ||
627
+ node.contentHeight !== contentHeight;
628
+ list.itemCount = itemCount;
629
+ list.itemHeight = itemHeight;
630
+ list.contentHeight = contentHeight;
631
+ node.scrollY = nextScrollY;
632
+ node.contentHeight = contentHeight;
633
+ if (changed) {
634
+ node.lastPaintedScrollY = node.scrollY - Math.max(1, node.box.h);
635
+ this.markDirty(list.nodeIndex);
636
+ }
637
+ }
638
+ advanceTransitions(deltaMs) {
639
+ for (const transition of this.transitions) {
640
+ if (!transition.active)
641
+ continue;
642
+ transition.elapsed += deltaMs;
643
+ const k = transition.durationMs <= 0
644
+ ? 100
645
+ : Math.trunc((transition.elapsed * 100) / transition.durationMs);
646
+ const drawK = transition.durationMs > 0 && transition.durationMs <= UI_TRANSITION_SNAP_MS ? 100 : k;
647
+ const value = lerpColor(transition.prevValue, transition.targetValue, drawK);
648
+ if (transition.prop === "color")
649
+ this.nodes[transition.node].fg = value;
650
+ else
651
+ this.nodes[transition.node].bg = value;
652
+ this.markDirty(transition.node);
653
+ if (drawK >= 100)
654
+ transition.active = false;
655
+ }
656
+ }
657
+ scrollMotionActive() {
658
+ if (this.scrollNode >= 0 && this.isDragging)
659
+ return true;
660
+ return this.nodes.some((node) => this.isActiveNode(node) && !!node.settling);
661
+ }
662
+ keyframeSetHasScrollSensitiveGeometry(setIndex) {
663
+ const set = this.keyframeSets[setIndex];
664
+ return !!set?.stops.some((stop) => (stop.props & (KF_TRANSFORM | KF_SIZE)) !== 0);
665
+ }
666
+ advanceAnimations(deltaMs) {
667
+ const scrollMotionActive = this.scrollMotionActive();
668
+ for (const animation of this.animations) {
669
+ if (!animation.active)
670
+ continue;
671
+ // Mirrors the C++ engine (runtime-header/tick/transitions-phase.ts,
672
+ // the `screenId != __ui_active_screen` guard in ui_tick's keyframe phase):
673
+ // only advance animations whose node is on the active screen. Otherwise advancing a
674
+ // cross-screen node mutates its geometry and clearCurrentNodePaint/markDirty
675
+ // repaint its parent's background into the *active* screen's framebuffer
676
+ // (e.g. the transform-screen dots bleeding onto home).
677
+ const animNode = this.nodes[animation.node];
678
+ if (animNode && !this.isActiveNode(animNode))
679
+ continue;
680
+ if (scrollMotionActive && this.keyframeSetHasScrollSensitiveGeometry(animation.keyframeSet))
681
+ continue;
682
+ animation.elapsed += deltaMs;
683
+ let elapsedNoDelay = animation.elapsed;
684
+ if (elapsedNoDelay < animation.delayMs)
685
+ continue;
686
+ elapsedNoDelay -= animation.delayMs;
687
+ let completing = false;
688
+ if (animation.iterations > 0 && elapsedNoDelay >= animation.iterations * animation.durationMs) {
689
+ elapsedNoDelay = animation.iterations * animation.durationMs;
690
+ completing = true;
691
+ }
692
+ let pct = 100;
693
+ if (!completing && animation.durationMs > 0) {
694
+ pct = Math.trunc(((elapsedNoDelay % animation.durationMs) * 100) / animation.durationMs);
695
+ }
696
+ const set = this.keyframeSets[animation.keyframeSet];
697
+ const node = this.nodes[animation.node];
698
+ if (!set || !node || set.stops.length === 0)
699
+ continue;
700
+ let lo = 0;
701
+ let hi = set.stops.length - 1;
702
+ for (let i = 0; i < set.stops.length; i++) {
703
+ if (set.stops[i].percent <= pct)
704
+ lo = i;
705
+ if (set.stops[i].percent >= pct) {
706
+ hi = i;
707
+ break;
708
+ }
709
+ }
710
+ const from = set.stops[lo];
711
+ const to = set.stops[hi];
712
+ const range = to.percent - from.percent;
713
+ // Shape the lerp by the animation's timing function (ease-in-out, etc.).
714
+ // Same control points + algorithm as the C++ ui_ease_lerp_k — preview and
715
+ // device must agree on the curve.
716
+ const k = easeCurveLerpK(animation.timingFunction, range > 0 ? Math.trunc(((pct - from.percent) * 100) / range) : 0);
717
+ let changed = false;
718
+ if ((from.props & KF_BG) && (to.props & KF_BG)) {
719
+ const next = range > 0 ? lerpColor(from.bg, to.bg, k) : from.bg;
720
+ if (next !== node.bg) {
721
+ node.bg = next;
722
+ node.hasBg = true;
723
+ changed = true;
724
+ }
725
+ }
726
+ if ((from.props & KF_FG) && (to.props & KF_FG)) {
727
+ const next = range > 0 ? lerpColor(from.fg, to.fg, k) : from.fg;
728
+ if (next !== node.fg) {
729
+ node.fg = next;
730
+ changed = true;
731
+ }
732
+ }
733
+ if ((from.props & KF_OPACITY) && (to.props & KF_OPACITY)) {
734
+ const next = range > 0 ? Math.trunc(from.opacity + ((to.opacity - from.opacity) * k) / 100) : from.opacity;
735
+ if (next !== node.opacity) {
736
+ node.opacity = next;
737
+ changed = true;
738
+ }
739
+ }
740
+ let nextTransformX = node.transformOffsetX ?? 0;
741
+ let nextTransformY = node.transformOffsetY ?? 0;
742
+ let nextRotateDeg = node.rotateDeg ?? 0;
743
+ let nextWidth = node.box.w;
744
+ let nextHeight = node.box.h;
745
+ let geometryChanged = false;
746
+ const hasSizeFrame = (from.props & KF_SIZE) && (to.props & KF_SIZE);
747
+ if (hasSizeFrame) {
748
+ nextWidth = range > 0 ? Math.trunc(from.width + ((to.width - from.width) * k) / 100) : from.width;
749
+ nextHeight = range > 0 ? Math.trunc(from.height + ((to.height - from.height) * k) / 100) : from.height;
750
+ nextWidth = Math.max(0, nextWidth);
751
+ nextHeight = Math.max(0, nextHeight);
752
+ if (nextWidth !== node.box.w || nextHeight !== node.box.h) {
753
+ changed = true;
754
+ geometryChanged = true;
755
+ }
756
+ }
757
+ if ((from.props & KF_TRANSFORM) && (to.props & KF_TRANSFORM)) {
758
+ const pxX = range > 0 ? Math.trunc(from.transformOffsetX + ((to.transformOffsetX - from.transformOffsetX) * k) / 100) : from.transformOffsetX;
759
+ const pxY = range > 0 ? Math.trunc(from.transformOffsetY + ((to.transformOffsetY - from.transformOffsetY) * k) / 100) : from.transformOffsetY;
760
+ const pctX = range > 0 ? Math.trunc(from.translatePctX + ((to.translatePctX - from.translatePctX) * k) / 100) : from.translatePctX;
761
+ const pctY = range > 0 ? Math.trunc(from.translatePctY + ((to.translatePctY - from.translatePctY) * k) / 100) : from.translatePctY;
762
+ const scaleX = Math.max(0, range > 0 ? Math.trunc(from.scaleX + ((to.scaleX - from.scaleX) * k) / 100) : from.scaleX);
763
+ const scaleY = Math.max(0, range > 0 ? Math.trunc(from.scaleY + ((to.scaleY - from.scaleY) * k) / 100) : from.scaleY);
764
+ nextRotateDeg = range > 0 ? Math.trunc(from.rotateDeg + ((to.rotateDeg - from.rotateDeg) * k) / 100) : from.rotateDeg;
765
+ let refW = hasSizeFrame ? nextWidth : animation.baseWidth;
766
+ let refH = hasSizeFrame ? nextHeight : animation.baseHeight;
767
+ if (refW <= 0)
768
+ refW = node.box.w;
769
+ if (refH <= 0)
770
+ refH = node.box.h;
771
+ const originPxX = Math.trunc((refW * animation.originX) / 100);
772
+ const originPxY = Math.trunc((refH * animation.originY) / 100);
773
+ const scaledW = Math.trunc((refW * scaleX) / 100);
774
+ const scaledH = Math.trunc((refH * scaleY) / 100);
775
+ const scaleOffsetX = originPxX - Math.trunc((originPxX * scaleX) / 100);
776
+ const scaleOffsetY = originPxY - Math.trunc((originPxY * scaleY) / 100);
777
+ nextTransformX = pxX + Math.trunc((refW * pctX) / 100) + scaleOffsetX;
778
+ nextTransformY = pxY + Math.trunc((refH * pctY) / 100) + scaleOffsetY;
779
+ nextWidth = Math.max(0, scaledW);
780
+ nextHeight = Math.max(0, scaledH);
781
+ if (nextTransformX !== (node.transformOffsetX ?? 0) ||
782
+ nextTransformY !== (node.transformOffsetY ?? 0) ||
783
+ nextRotateDeg !== (node.rotateDeg ?? 0) ||
784
+ nextWidth !== node.box.w ||
785
+ nextHeight !== node.box.h) {
786
+ changed = true;
787
+ geometryChanged = true;
788
+ }
789
+ }
790
+ if (changed) {
791
+ const throttleRedraw = !geometryChanged &&
792
+ !(completing || animation.elapsed - animation.lastUpdateMs >= 100);
793
+ if (throttleRedraw) {
794
+ if (completing)
795
+ animation.active = false;
796
+ continue;
797
+ }
798
+ let oldGeometryRect;
799
+ if (geometryChanged) {
800
+ const rect = this.currentPaintRect(node);
801
+ if (rect.w > 0 && rect.h > 0)
802
+ oldGeometryRect = rect;
803
+ node.transformOffsetX = nextTransformX;
804
+ node.transformOffsetY = nextTransformY;
805
+ node.rotateDeg = nextRotateDeg;
806
+ node.box.w = nextWidth;
807
+ node.box.h = nextHeight;
808
+ }
809
+ let repairedGeometry = false;
810
+ if (geometryChanged && oldGeometryRect) {
811
+ repairedGeometry = this.tryRepairGeometryFill(node, oldGeometryRect);
812
+ }
813
+ if (geometryChanged && !repairedGeometry) {
814
+ if (oldGeometryRect)
815
+ this.clearNodePaintRect(node, oldGeometryRect);
816
+ else
817
+ this.clearCurrentNodePaint(node);
818
+ this.invalidateScrollCanvasForNode(node.index);
819
+ }
820
+ if (!repairedGeometry)
821
+ this.markDirty(animation.node);
822
+ animation.lastUpdateMs = animation.elapsed;
823
+ }
824
+ if (completing)
825
+ animation.active = false;
826
+ }
827
+ }
828
+ drawYForNode(nodeIndex) {
829
+ return this.baseDrawYForNode(nodeIndex) + this.pressedOffsetYForNode(nodeIndex);
830
+ }
831
+ baseDrawXForNode(nodeIndex) {
832
+ const node = this.nodes[nodeIndex];
833
+ return node.box.x + (node.transformOffsetX ?? 0);
834
+ }
835
+ baseDrawYForNode(nodeIndex) {
836
+ let y = this.nodes[nodeIndex].box.y + (this.nodes[nodeIndex].transformOffsetY ?? 0);
837
+ let parent = this.nodes[nodeIndex].parentIndex;
838
+ while (parent >= 0 && this.nodes[parent]) {
839
+ if (this.nodes[parent].scrollable) {
840
+ y -= this.nodes[parent].scrollY;
841
+ // Rubber-band: overscrollPx (+top/-bottom) visibly offsets content past
842
+ // the boundary during drag/settle. Applies to generic containers; lists
843
+ // virtualize and manage their own offset in drawListNode.
844
+ if (!this.nodes[parent].virtualized)
845
+ y += this.nodes[parent].overscrollPx;
846
+ }
847
+ parent = this.nodes[parent].parentIndex;
848
+ }
849
+ return y;
850
+ }
851
+ scrollAncestorForNode(nodeIndex) {
852
+ let parent = this.nodes[nodeIndex]?.parentIndex ?? -1;
853
+ while (parent >= 0 && this.nodes[parent]) {
854
+ if (this.nodes[parent].scrollable)
855
+ return parent;
856
+ parent = this.nodes[parent].parentIndex;
857
+ }
858
+ return -1;
859
+ }
860
+ pressedOffsetXForNode(nodeIndex) {
861
+ const node = this.nodes[nodeIndex];
862
+ return node.value > 0 ? (node.pressedOffsetX ?? 0) : 0;
863
+ }
864
+ pressedOffsetYForNode(nodeIndex) {
865
+ const node = this.nodes[nodeIndex];
866
+ return node.value > 0 ? (node.pressedOffsetY ?? 0) : 0;
867
+ }
868
+ drawXForNode(nodeIndex) {
869
+ return this.baseDrawXForNode(nodeIndex) + this.pressedOffsetXForNode(nodeIndex);
870
+ }
871
+ parentClearColor(node) {
872
+ const parent = node.parentIndex >= 0 ? this.nodes[node.parentIndex] : undefined;
873
+ if (parent)
874
+ return parent.hasBg ? parent.bg : parent.clearColor;
875
+ return node.clearColor;
876
+ }
877
+ clearPressOffsetArea(node, baseX, baseY) {
878
+ const ox = node.pressedOffsetX ?? 0;
879
+ const oy = node.pressedOffsetY ?? 0;
880
+ if (ox === 0 && oy === 0)
881
+ return;
882
+ const x0 = Math.min(baseX, baseX + ox);
883
+ const y0 = Math.min(baseY, baseY + oy);
884
+ const x1 = Math.max(baseX + node.box.w, baseX + ox + node.box.w);
885
+ const y1 = Math.max(baseY + node.box.h, baseY + oy + node.box.h);
886
+ if (this.repairCurrentNodePaintWithParent(node, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }))
887
+ return;
888
+ this.gfx.fillRect(x0, y0, x1 - x0, y1 - y0, this.parentClearColor(node));
889
+ }
890
+ shadowExtents(node) {
891
+ let left = 0;
892
+ let top = 0;
893
+ let right = 0;
894
+ let bottom = 0;
895
+ const count = Math.min(node.shadowCount ?? 0, 4);
896
+ for (let i = 0; i < count; i++) {
897
+ if (node.shadowInset?.[i])
898
+ continue;
899
+ const blur = node.shadowBlur?.[i] || 1;
900
+ const ox = node.shadowOffsetX?.[i] ?? 0;
901
+ const oy = node.shadowOffsetY?.[i] ?? 0;
902
+ left = Math.max(left, blur - ox);
903
+ top = Math.max(top, blur - oy);
904
+ right = Math.max(right, blur + ox);
905
+ bottom = Math.max(bottom, blur + oy);
906
+ }
907
+ return { left, top, right, bottom };
908
+ }
909
+ rotationQuadrant(deg) {
910
+ let normalized = Math.trunc(deg ?? 0) % 360;
911
+ if (normalized < 0)
912
+ normalized += 360;
913
+ if (normalized === 90)
914
+ return 1;
915
+ if (normalized === 180)
916
+ return 2;
917
+ if (normalized === 270)
918
+ return 3;
919
+ return 0;
920
+ }
921
+ canUseQuarterTurnBounds(node) {
922
+ return node.kind === "img" || (node.kind === "fill" && node.gradientEnabled === 0);
923
+ }
924
+ rotatedFaceSize(node, w, h) {
925
+ if (!this.canUseQuarterTurnBounds(node))
926
+ return { w, h };
927
+ const q = this.rotationQuadrant(node.rotateDeg);
928
+ return q === 1 || q === 3 ? { w: h, h: w } : { w, h };
929
+ }
930
+ activeDrawClip() {
931
+ return this.gfx.getClipRect() ?? { x: 0, y: 0, w: this.gfx.width, h: this.gfx.height };
932
+ }
933
+ intersectRect(a, b) {
934
+ const x0 = Math.max(a.x, b.x);
935
+ const y0 = Math.max(a.y, b.y);
936
+ const x1 = Math.min(a.x + a.w, b.x + b.w);
937
+ const y1 = Math.min(a.y + a.h, b.y + b.h);
938
+ return x0 < x1 && y0 < y1 ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : undefined;
939
+ }
940
+ drawImageWithFit(asset, x, y, rotateDeg, fitMode, targetW, targetH) {
941
+ const srcW = Math.trunc(asset.width);
942
+ const srcH = Math.trunc(asset.height);
943
+ targetW = Math.trunc(targetW);
944
+ targetH = Math.trunc(targetH);
945
+ if (srcW <= 0 || srcH <= 0 || targetW <= 0 || targetH <= 0)
946
+ return;
947
+ let drawW = srcW;
948
+ let drawH = srcH;
949
+ let offX = Math.trunc((targetW - drawW) / 2);
950
+ let offY = Math.trunc((targetH - drawH) / 2);
951
+ const mode = fitMode ?? 1;
952
+ if (mode === 1) {
953
+ drawW = targetW;
954
+ drawH = targetH;
955
+ offX = 0;
956
+ offY = 0;
957
+ }
958
+ else if (mode === 2 || mode === 3 || mode === 4) {
959
+ const scaleX = Math.max(1, Math.trunc((targetW * 1000) / srcW));
960
+ const scaleY = Math.max(1, Math.trunc((targetH * 1000) / srcH));
961
+ let scale = scaleX;
962
+ if (mode === 2) {
963
+ if (scaleY < scaleX)
964
+ scale = scaleY;
965
+ }
966
+ else if (mode === 3) {
967
+ if (scaleY > scaleX)
968
+ scale = scaleY;
969
+ }
970
+ else {
971
+ if (scaleY < scaleX)
972
+ scale = scaleY;
973
+ if (scale > 1000)
974
+ scale = 1000;
975
+ }
976
+ drawW = Math.max(1, Math.trunc((srcW * scale) / 1000));
977
+ drawH = Math.max(1, Math.trunc((srcH * scale) / 1000));
978
+ if (mode === 3) {
979
+ if (drawW < targetW)
980
+ drawW = targetW;
981
+ if (drawH < targetH)
982
+ drawH = targetH;
983
+ }
984
+ offX = Math.trunc((targetW - drawW) / 2);
985
+ offY = Math.trunc((targetH - drawH) / 2);
986
+ }
987
+ const q = this.rotationQuadrant(rotateDeg);
988
+ const clip = this.intersectRect({ x, y, w: q === 1 || q === 3 ? targetH : targetW, h: q === 1 || q === 3 ? targetW : targetH }, this.activeDrawClip());
989
+ if (!clip)
990
+ return;
991
+ let txStart = 0;
992
+ let txEnd = targetW;
993
+ let tyStart = 0;
994
+ let tyEnd = targetH;
995
+ if (q === 0) {
996
+ txStart = Math.max(0, Math.min(targetW, clip.x - x));
997
+ txEnd = Math.max(0, Math.min(targetW, clip.x + clip.w - x));
998
+ tyStart = Math.max(0, Math.min(targetH, clip.y - y));
999
+ tyEnd = Math.max(0, Math.min(targetH, clip.y + clip.h - y));
1000
+ }
1001
+ else if (q === 1) {
1002
+ txStart = Math.max(0, Math.min(targetW, clip.y - y));
1003
+ txEnd = Math.max(0, Math.min(targetW, clip.y + clip.h - y));
1004
+ tyStart = Math.max(0, Math.min(targetH, x + targetH - (clip.x + clip.w)));
1005
+ tyEnd = Math.max(0, Math.min(targetH, x + targetH - clip.x));
1006
+ }
1007
+ else if (q === 2) {
1008
+ txStart = Math.max(0, Math.min(targetW, x + targetW - (clip.x + clip.w)));
1009
+ txEnd = Math.max(0, Math.min(targetW, x + targetW - clip.x));
1010
+ tyStart = Math.max(0, Math.min(targetH, y + targetH - (clip.y + clip.h)));
1011
+ tyEnd = Math.max(0, Math.min(targetH, y + targetH - clip.y));
1012
+ }
1013
+ else {
1014
+ txStart = Math.max(0, Math.min(targetW, y + targetW - (clip.y + clip.h)));
1015
+ txEnd = Math.max(0, Math.min(targetW, y + targetW - clip.y));
1016
+ tyStart = Math.max(0, Math.min(targetH, clip.x - x));
1017
+ tyEnd = Math.max(0, Math.min(targetH, clip.x + clip.w - x));
1018
+ }
1019
+ if (txStart >= txEnd || tyStart >= tyEnd)
1020
+ return;
1021
+ for (let ty = tyStart; ty < tyEnd; ty++) {
1022
+ const localY = ty - offY;
1023
+ if (localY < 0 || localY >= drawH)
1024
+ continue;
1025
+ const srcY = Math.max(0, Math.min(srcH - 1, Math.trunc((localY * srcH) / drawH)));
1026
+ for (let tx = txStart; tx < txEnd; tx++) {
1027
+ const localX = tx - offX;
1028
+ if (localX < 0 || localX >= drawW)
1029
+ continue;
1030
+ const srcX = Math.max(0, Math.min(srcW - 1, Math.trunc((localX * srcW) / drawW)));
1031
+ const color = asset.data[srcY * srcW + srcX] ?? 0;
1032
+ let dx = tx;
1033
+ let dy = ty;
1034
+ if (q === 1) {
1035
+ dx = targetH - 1 - ty;
1036
+ dy = tx;
1037
+ }
1038
+ else if (q === 2) {
1039
+ dx = targetW - 1 - tx;
1040
+ dy = targetH - 1 - ty;
1041
+ }
1042
+ else if (q === 3) {
1043
+ dx = ty;
1044
+ dy = targetW - 1 - tx;
1045
+ }
1046
+ this.gfx.drawPixel(x + dx, y + dy, color);
1047
+ }
1048
+ }
1049
+ }
1050
+ drawImageNode(node, drawY) {
1051
+ const faceSize = this.rotatedFaceSize(node, node.box.w, node.box.h);
1052
+ this.gfx.fillRect(node.box.x, drawY, faceSize.w, faceSize.h, node.hasBg ? node.bg : node.clearColor);
1053
+ const assetId = node.imgDataId ?? 255;
1054
+ if (assetId < this.imageAssets.length) {
1055
+ this.drawImageWithFit(this.imageAssets[assetId], node.box.x, drawY, node.rotateDeg, node.objectFit, node.box.w, node.box.h);
1056
+ }
1057
+ if (node.borderStyle) {
1058
+ this.drawRectOutline(node.box.x, drawY, faceSize.w, faceSize.h, node.borderRadius, node.borderStyle, node.borderWidth, node.borderColor || node.fg);
1059
+ }
1060
+ }
1061
+ nodePaintRect(node, baseX, baseY, drawX, drawY, textW, textH) {
1062
+ const shadow = this.shadowExtents(node);
1063
+ let faceW = node.box.w;
1064
+ let faceH = node.box.h;
1065
+ if (node.kind === "text" || node.kind === "check" || node.kind === "radio") {
1066
+ if (node.lastTextWidth > faceW)
1067
+ faceW = node.lastTextWidth;
1068
+ if ((node.lastTextHeight ?? 0) > faceH)
1069
+ faceH = node.lastTextHeight;
1070
+ if (textW > faceW)
1071
+ faceW = textW;
1072
+ if (textH > faceH)
1073
+ faceH = textH;
1074
+ }
1075
+ ({ w: faceW, h: faceH } = this.rotatedFaceSize(node, faceW, faceH));
1076
+ let x0 = Math.min(baseX - shadow.left, drawX);
1077
+ let y0 = Math.min(baseY - shadow.top, drawY);
1078
+ let x1 = Math.max(baseX + faceW + shadow.right, drawX + faceW);
1079
+ let y1 = Math.max(baseY + faceH + shadow.bottom, drawY + faceH);
1080
+ if (node.outlineStyle && node.outlineWidth > 0) {
1081
+ const o = node.outlineWidth;
1082
+ x0 = Math.min(x0, drawX - o);
1083
+ y0 = Math.min(y0, drawY - o);
1084
+ x1 = Math.max(x1, drawX + faceW + o);
1085
+ y1 = Math.max(y1, drawY + faceH + o);
1086
+ }
1087
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
1088
+ }
1089
+ currentPaintRect(node) {
1090
+ const displayText = node.hasTextBinding ? node.textBuffer : node.text;
1091
+ const ts = this.nodeTextSize(node);
1092
+ let textMaxW = node.box.w;
1093
+ if (node.kind === "text" || node.kind === "button") {
1094
+ const insets = this.textInsets(node);
1095
+ textMaxW = Math.max(0, node.box.w - insets.left - insets.right);
1096
+ }
1097
+ if (node.kind === "check" || node.kind === "radio") {
1098
+ textMaxW = node.box.w > 22 ? node.box.w - 22 : 0;
1099
+ }
1100
+ const metrics = this.textLayout(node, displayText, textMaxW, ts);
1101
+ let paintTextW = metrics.width;
1102
+ let paintTextH = metrics.height;
1103
+ if (node.kind === "text") {
1104
+ const insets = this.textInsets(node);
1105
+ paintTextW += insets.left + insets.right;
1106
+ paintTextH += insets.top + insets.bottom;
1107
+ }
1108
+ if (node.kind === "check" || node.kind === "radio") {
1109
+ paintTextW += 22;
1110
+ if (paintTextH < 16)
1111
+ paintTextH = 16;
1112
+ }
1113
+ return this.nodePaintRect(node, this.baseDrawXForNode(node.index), this.baseDrawYForNode(node.index), this.drawXForNode(node.index), this.drawYForNode(node.index), paintTextW, paintTextH);
1114
+ }
1115
+ currentSubtreePaintRect(node) {
1116
+ let rect;
1117
+ for (let i = node.index; i < Math.min(node.subtreeEnd, this.nodes.length); i++) {
1118
+ const child = this.nodes[i];
1119
+ if (!child || !this.isActiveNode(child) || !this.isEffectivelyVisible(child))
1120
+ continue;
1121
+ const childRect = this.currentPaintRect(child);
1122
+ if (childRect.w <= 0 || childRect.h <= 0)
1123
+ continue;
1124
+ if (!rect) {
1125
+ rect = { ...childRect };
1126
+ }
1127
+ else {
1128
+ const x0 = Math.min(rect.x, childRect.x);
1129
+ const y0 = Math.min(rect.y, childRect.y);
1130
+ const x1 = Math.max(rect.x + rect.w, childRect.x + childRect.w);
1131
+ const y1 = Math.max(rect.y + rect.h, childRect.y + childRect.h);
1132
+ rect = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
1133
+ }
1134
+ }
1135
+ return rect;
1136
+ }
1137
+ rectsIntersect(a, b) {
1138
+ return a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h;
1139
+ }
1140
+ markOverlappingHigherLayersDirty(nodeIndex) {
1141
+ const node = this.nodes[nodeIndex];
1142
+ if (!node || !this.isEffectivelyVisible(node) || !this.isActiveNode(node))
1143
+ return;
1144
+ const rect = this.currentPaintRect(node);
1145
+ this.markOverlappingHigherLayersDirtyForRect(nodeIndex, rect);
1146
+ }
1147
+ markOverlappingHigherLayersDirtyForRect(nodeIndex, rect) {
1148
+ const node = this.nodes[nodeIndex];
1149
+ if (!node || !this.isEffectivelyVisible(node) || !this.isActiveNode(node))
1150
+ return;
1151
+ if (rect.w <= 0 || rect.h <= 0)
1152
+ return;
1153
+ for (const candidate of this.nodes) {
1154
+ if (candidate.index === nodeIndex)
1155
+ continue;
1156
+ if (candidate.dirty || !this.isEffectivelyVisible(candidate) || !this.isActiveNode(candidate))
1157
+ continue;
1158
+ if (!this.drawsBefore(node, candidate))
1159
+ continue;
1160
+ const candidateRect = this.currentPaintRect(candidate);
1161
+ if (candidateRect.w <= 0 || candidateRect.h <= 0)
1162
+ continue;
1163
+ if (this.rectsIntersect(rect, candidateRect))
1164
+ candidate.dirty = true;
1165
+ }
1166
+ }
1167
+ repairCurrentNodePaintWithParent(node, rect) {
1168
+ if (rect.w <= 0 || rect.h <= 0)
1169
+ return false;
1170
+ const parent = node.parentIndex >= 0 ? this.nodes[node.parentIndex] : undefined;
1171
+ if (!parent)
1172
+ return false;
1173
+ this.gfx.withClipRect(rect, () => {
1174
+ this.gfx.fillRect(rect.x, rect.y, rect.w, rect.h, this.parentClearColor(parent));
1175
+ const parentDrawX = this.drawXForNode(parent.index);
1176
+ const parentDrawY = this.drawYForNode(parent.index);
1177
+ const origParentX = parent.box.x;
1178
+ parent.box.x = parentDrawX;
1179
+ try {
1180
+ let parentFillBg = parent.bg;
1181
+ if (parent.opacity < 100) {
1182
+ parentFillBg = blendRuntime(parent.bg, this.parentClearColor(parent), parent.opacity);
1183
+ }
1184
+ if (parent.gradientEnabled > 0) {
1185
+ this.drawGradientFill(parent, parentDrawY);
1186
+ }
1187
+ else if (parent.borderRadius > 0 && parent.hasBg) {
1188
+ this.gfx.fillRoundRect(parent.box.x, parentDrawY, parent.box.w, parent.box.h, parent.borderRadius, parentFillBg);
1189
+ }
1190
+ else if (parent.hasBg) {
1191
+ this.gfx.fillRect(parent.box.x, parentDrawY, parent.box.w, parent.box.h, parentFillBg);
1192
+ }
1193
+ if (parent.borderStyle)
1194
+ this.drawNodeBorder(parent, parentDrawX, parentDrawY, parent.borderColor || parent.fg);
1195
+ this.drawNodeOutline(parent, parentDrawX, parentDrawY);
1196
+ }
1197
+ finally {
1198
+ parent.box.x = origParentX;
1199
+ }
1200
+ });
1201
+ return true;
1202
+ }
1203
+ clearNodePaintRect(node, rect) {
1204
+ if (rect.w <= 0 || rect.h <= 0)
1205
+ return;
1206
+ const clip = this.scrollClipForNode(node.index);
1207
+ const repairRect = this.intersectClipRect(clip, rect);
1208
+ if (repairRect.w <= 0 || repairRect.h <= 0)
1209
+ return;
1210
+ if (this.repairCurrentNodePaintWithParent(node, repairRect))
1211
+ return;
1212
+ this.gfx.withClipRect(repairRect, () => {
1213
+ this.gfx.fillRect(repairRect.x, repairRect.y, repairRect.w, repairRect.h, this.parentClearColor(node));
1214
+ const parent = node.parentIndex >= 0 ? this.nodes[node.parentIndex] : undefined;
1215
+ if (parent) {
1216
+ const parentDrawY = this.drawYForNode(parent.index);
1217
+ const parentDrawX = this.drawXForNode(parent.index);
1218
+ if (parent.borderStyle)
1219
+ this.drawNodeBorder(parent, parentDrawX, parentDrawY, parent.borderColor || parent.fg);
1220
+ this.drawNodeOutline(parent, parentDrawX, parentDrawY);
1221
+ }
1222
+ });
1223
+ this.directFrameChanged = true;
1224
+ }
1225
+ clearCurrentNodePaint(node) {
1226
+ const displayText = node.hasTextBinding ? node.textBuffer : node.text;
1227
+ const ts = this.nodeTextSize(node);
1228
+ let textMaxW = node.box.w;
1229
+ if (node.kind === "text" || node.kind === "button") {
1230
+ const insets = this.textInsets(node);
1231
+ textMaxW = Math.max(0, node.box.w - insets.left - insets.right);
1232
+ }
1233
+ const metrics = this.textLayout(node, displayText, textMaxW, ts);
1234
+ const insets = node.kind === "text" ? this.textInsets(node) : { left: 0, right: 0, top: 0, bottom: 0 };
1235
+ const rect = this.nodePaintRect(node, this.baseDrawXForNode(node.index), this.baseDrawYForNode(node.index), this.drawXForNode(node.index), this.drawYForNode(node.index), metrics.width + insets.left + insets.right, metrics.height + insets.top + insets.bottom);
1236
+ this.clearNodePaintRect(node, rect);
1237
+ }
1238
+ tryRepairGeometryFill(node, oldRect) {
1239
+ if (oldRect.w <= 0 || oldRect.h <= 0)
1240
+ return false;
1241
+ if (node.kind !== "fill" || !node.hasBg)
1242
+ return false;
1243
+ if (node.gradientEnabled !== 0)
1244
+ return false;
1245
+ if (node.borderRadius !== 0 || node.borderStyle !== 0 || node.outlineStyle !== 0 || (node.shadowCount ?? 0) !== 0)
1246
+ return false;
1247
+ const newRect = this.currentPaintRect(node);
1248
+ if (newRect.w <= 0 || newRect.h <= 0)
1249
+ return false;
1250
+ const x0 = Math.min(oldRect.x, newRect.x);
1251
+ const y0 = Math.min(oldRect.y, newRect.y);
1252
+ const x1 = Math.max(oldRect.x + oldRect.w, newRect.x + newRect.w);
1253
+ const y1 = Math.max(oldRect.y + oldRect.h, newRect.y + newRect.h);
1254
+ let repair = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
1255
+ repair = this.intersectClipRect(this.scrollClipForNode(node.index), repair);
1256
+ if (repair.w <= 0 || repair.h <= 0) {
1257
+ this.invalidateScrollCanvasForNode(node.index);
1258
+ return true;
1259
+ }
1260
+ if (repair.w * repair.h > UI_MAX_BUFFERED_PAINT_PIXELS)
1261
+ return false;
1262
+ if (!this.repairCurrentNodePaintWithParent(node, repair)) {
1263
+ this.gfx.withClipRect(repair, () => {
1264
+ this.gfx.fillRect(repair.x, repair.y, repair.w, repair.h, this.parentClearColor(node));
1265
+ });
1266
+ }
1267
+ this.gfx.withClipRect(repair, () => {
1268
+ let fillBg = node.bg;
1269
+ if (node.opacity < 100)
1270
+ fillBg = blendRuntime(node.bg, this.parentClearColor(node), node.opacity);
1271
+ const fillSize = this.rotatedFaceSize(node, node.box.w, node.box.h);
1272
+ this.gfx.fillRect(this.drawXForNode(node.index), this.drawYForNode(node.index), fillSize.w, fillSize.h, fillBg);
1273
+ });
1274
+ this.invalidateScrollCanvasForNode(node.index);
1275
+ this.markOverlappingHigherLayersDirtyForRect(node.index, repair);
1276
+ this.directFrameChanged = true;
1277
+ return true;
1278
+ }
1279
+ clearCurrentSubtreePaint(node) {
1280
+ const rect = this.currentSubtreePaintRect(node);
1281
+ if (!rect || rect.w <= 0 || rect.h <= 0)
1282
+ return;
1283
+ const clip = this.scrollClipForNode(node.index);
1284
+ const repairRect = this.intersectClipRect(clip, rect);
1285
+ if (repairRect.w <= 0 || repairRect.h <= 0)
1286
+ return;
1287
+ if (this.repairCurrentNodePaintWithParent(node, repairRect))
1288
+ return;
1289
+ this.gfx.withClipRect(repairRect, () => {
1290
+ this.gfx.fillRect(repairRect.x, repairRect.y, repairRect.w, repairRect.h, this.parentClearColor(node));
1291
+ const parent = node.parentIndex >= 0 ? this.nodes[node.parentIndex] : undefined;
1292
+ if (parent) {
1293
+ const parentDrawY = this.drawYForNode(parent.index);
1294
+ const parentDrawX = this.drawXForNode(parent.index);
1295
+ if (parent.borderStyle)
1296
+ this.drawNodeBorder(parent, parentDrawX, parentDrawY, parent.borderColor || parent.fg);
1297
+ this.drawNodeOutline(parent, parentDrawX, parentDrawY);
1298
+ }
1299
+ });
1300
+ }
1301
+ setVisible(nodeIndex, visible) {
1302
+ const node = this.nodes[nodeIndex];
1303
+ if (!node || node.visible === visible)
1304
+ return;
1305
+ if (!visible) {
1306
+ const subtreeRect = this.currentSubtreePaintRect(node);
1307
+ this.clearCurrentSubtreePaint(node);
1308
+ for (let i = Math.min(node.subtreeEnd, this.nodes.length) - 1; i >= nodeIndex; i--) {
1309
+ const child = this.nodes[i];
1310
+ if (!child || !this.isActiveNode(child))
1311
+ continue;
1312
+ child.dirty = false;
1313
+ }
1314
+ if (subtreeRect)
1315
+ this.markOverlappingHigherLayersDirtyForRect(nodeIndex, subtreeRect);
1316
+ node.visible = false;
1317
+ return;
1318
+ }
1319
+ node.visible = true;
1320
+ for (let i = nodeIndex; i < Math.min(node.subtreeEnd, this.nodes.length); i++) {
1321
+ const child = this.nodes[i];
1322
+ if (!child || !this.isActiveNode(child) || !this.isEffectivelyVisible(child))
1323
+ continue;
1324
+ child.dirty = true;
1325
+ this.markOverlappingHigherLayersDirty(i);
1326
+ }
1327
+ }
1328
+ scrollClipForNode(nodeIndex) {
1329
+ let parent = this.nodes[nodeIndex].parentIndex;
1330
+ let clip;
1331
+ while (parent >= 0 && this.nodes[parent]) {
1332
+ const scrollParent = this.nodes[parent];
1333
+ if (scrollParent.scrollable) {
1334
+ const next = { x: scrollParent.box.x, y: scrollParent.box.y, w: scrollParent.box.w, h: scrollParent.box.h };
1335
+ if (!clip) {
1336
+ clip = next;
1337
+ }
1338
+ else {
1339
+ const x0 = Math.max(clip.x, next.x);
1340
+ const y0 = Math.max(clip.y, next.y);
1341
+ const x1 = Math.min(clip.x + clip.w, next.x + next.w);
1342
+ const y1 = Math.min(clip.y + clip.h, next.y + next.h);
1343
+ clip = { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
1344
+ }
1345
+ }
1346
+ parent = scrollParent.parentIndex;
1347
+ }
1348
+ return clip;
1349
+ }
1350
+ rectIntersectsClip(node, drawY, clip) {
1351
+ return node.box.x + node.box.w > clip.x &&
1352
+ node.box.x < clip.x + clip.w &&
1353
+ drawY + node.box.h > clip.y &&
1354
+ drawY < clip.y + clip.h;
1355
+ }
1356
+ intersectClipRect(a, b) {
1357
+ if (!a)
1358
+ return b;
1359
+ const x0 = Math.max(a.x, b.x);
1360
+ const y0 = Math.max(a.y, b.y);
1361
+ const x1 = Math.min(a.x + a.w, b.x + b.w);
1362
+ const y1 = Math.min(a.y + a.h, b.y + b.h);
1363
+ return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
1364
+ }
1365
+ clearDirtyScrollViewports() {
1366
+ let changed = false;
1367
+ for (const node of this.nodes) {
1368
+ if (!this.isActiveNode(node))
1369
+ continue;
1370
+ if (!node.scrollable || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1371
+ continue;
1372
+ if (node.dirty) {
1373
+ this.markScrollDescendantsDirtyLocal(node.index);
1374
+ for (let i = node.index; i < node.subtreeEnd; i++) {
1375
+ if (this.nodes[i]?.kind === "progress")
1376
+ this.nodes[i].lastTextWidth = -1;
1377
+ else if (this.nodes[i]?.kind === "range")
1378
+ this.nodes[i].lastTextWidth = -1;
1379
+ if (this.nodes[i])
1380
+ this.nodes[i].lastTextHeight = 0;
1381
+ }
1382
+ this.gfx.fillRect(node.box.x, node.box.y, node.box.w, node.box.h, node.hasBg ? node.bg : node.clearColor);
1383
+ changed = true;
1384
+ }
1385
+ }
1386
+ return changed;
1387
+ }
1388
+ drawScrollbars(scrollbarDirty) {
1389
+ let changed = false;
1390
+ for (const node of this.nodes) {
1391
+ if (!this.isActiveNode(node))
1392
+ continue;
1393
+ if (!node.scrollable || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1394
+ continue;
1395
+ if (!scrollbarDirty.has(node.index))
1396
+ continue;
1397
+ const tx = node.box.x + node.box.w - 4;
1398
+ const ty = node.box.y;
1399
+ const th = node.box.h;
1400
+ node.scrollY = Math.max(0, Math.min(node.scrollY, node.contentHeight - th));
1401
+ const trackColor = (node.fg >> 1) & 0x7bef;
1402
+ this.gfx.fillRect(tx, ty, 3, th, trackColor);
1403
+ const thumbH = Math.max(8, Math.trunc((th * th) / node.contentHeight));
1404
+ const thumbY = ty + Math.trunc(((node.box.h - thumbH) * node.scrollY) / Math.max(1, node.contentHeight - th));
1405
+ this.gfx.fillRect(tx, thumbY, 3, thumbH, node.fg);
1406
+ changed = true;
1407
+ }
1408
+ return changed;
1409
+ }
1410
+ nodeTextSize(node) {
1411
+ return Math.max(1, Math.trunc(node.textSize || 2));
1412
+ }
1413
+ fontAsset(fontFace) {
1414
+ if (!fontFace)
1415
+ return undefined;
1416
+ return this.fontAssets.find((asset) => asset.id === fontFace);
1417
+ }
1418
+ fontGlyph(asset, codepoint) {
1419
+ return asset.glyphs.find((glyph) => glyph.codepoint === codepoint);
1420
+ }
1421
+ fontAlpha(asset, glyph, pixelIndex) {
1422
+ const nibble = glyph.dataOffset + pixelIndex;
1423
+ const byte = asset.alpha[nibble >> 1] ?? 0;
1424
+ return (nibble & 1) ? byte & 0x0f : byte >> 4;
1425
+ }
1426
+ textWidth(text, size, fontFace = 0, letterSpacing = 0) {
1427
+ const displayText = text ?? "";
1428
+ const asset = this.fontAsset(fontFace);
1429
+ if (!asset) {
1430
+ if (!displayText)
1431
+ return 0;
1432
+ let width = 0;
1433
+ for (const _ch of displayText)
1434
+ width += Math.max(1, Math.trunc(size)) * 6 + Math.trunc(letterSpacing);
1435
+ return width;
1436
+ }
1437
+ let width = 0;
1438
+ for (const ch of displayText) {
1439
+ const glyph = this.fontGlyph(asset, ch.codePointAt(0) ?? 0);
1440
+ width += glyph ? glyph.advance : Math.trunc(asset.lineHeight / 2);
1441
+ }
1442
+ return width;
1443
+ }
1444
+ textHeight(size, fontFace = 0) {
1445
+ return this.fontAsset(fontFace)?.lineHeight ?? this.gfx.textHeight(size);
1446
+ }
1447
+ textLineHeight(node, size) {
1448
+ const lineHeight = Math.trunc(node.lineHeight || 0);
1449
+ return lineHeight > 0 ? lineHeight : this.textHeight(size, node.fontFace);
1450
+ }
1451
+ textWhiteSpace(node) {
1452
+ switch (node.whiteSpaceMode ?? (node.nowrap ? 1 : 0)) {
1453
+ case 1: return "nowrap";
1454
+ case 2: return "pre";
1455
+ case 3: return "pre-line";
1456
+ default: return "normal";
1457
+ }
1458
+ }
1459
+ textLayout(node, text, maxWidth, size) {
1460
+ const constrainedWidth = maxWidth !== undefined && maxWidth > 0 ? maxWidth : undefined;
1461
+ return layoutText(text ?? "", {
1462
+ maxWidth: constrainedWidth,
1463
+ whiteSpace: this.textWhiteSpace(node),
1464
+ lineHeight: this.textLineHeight(node, size),
1465
+ measureText: (value) => this.textWidth(value, size, node.fontFace, node.letterSpacing),
1466
+ });
1467
+ }
1468
+ clipTextToWidth(text, maxWidth, size, fontFace = 0, letterSpacing = 0) {
1469
+ let out = "";
1470
+ let width = 0;
1471
+ for (const ch of text) {
1472
+ const next = this.textWidth(ch, size, fontFace, letterSpacing);
1473
+ if (width + next > maxWidth)
1474
+ break;
1475
+ out += ch;
1476
+ width += next;
1477
+ }
1478
+ return out;
1479
+ }
1480
+ // text-overflow: ellipsis — trim trailing chars until the prefix + "..." fits
1481
+ // maxWidth, then append "...". Mirrors the C++ ui_truncate_ellipsis.
1482
+ truncateEllipsis(text, maxWidth, size, fontFace = 0, letterSpacing = 0) {
1483
+ const dotsW = this.textWidth("...", size, fontFace, letterSpacing);
1484
+ let prefix = "";
1485
+ let width = 0;
1486
+ for (const ch of text) {
1487
+ const next = this.textWidth(ch, size, fontFace, letterSpacing);
1488
+ if (width + next + dotsW > maxWidth)
1489
+ break;
1490
+ prefix += ch;
1491
+ width += next;
1492
+ }
1493
+ return prefix.length > 0 ? prefix + "..." : (maxWidth >= dotsW ? "..." : "");
1494
+ }
1495
+ drawAssetText(text, x, y, fg, bg, antialias, fontFace = 0) {
1496
+ const asset = this.fontAsset(fontFace);
1497
+ if (!asset)
1498
+ return false;
1499
+ let cursor = x;
1500
+ const baseline = y + asset.baseline;
1501
+ const clip = this.activeDrawClip();
1502
+ for (const ch of text) {
1503
+ const glyph = this.fontGlyph(asset, ch.codePointAt(0) ?? 0);
1504
+ if (!glyph) {
1505
+ cursor += Math.trunc(asset.lineHeight / 2);
1506
+ continue;
1507
+ }
1508
+ const glyphX = cursor + glyph.xOffset;
1509
+ const glyphY = baseline + glyph.yOffset;
1510
+ if (glyphX + glyph.width <= clip.x || glyphX >= clip.x + clip.w ||
1511
+ glyphY + glyph.height <= clip.y || glyphY >= clip.y + clip.h) {
1512
+ cursor += glyph.advance;
1513
+ continue;
1514
+ }
1515
+ const gxStart = Math.max(0, clip.x - glyphX);
1516
+ const gyStart = Math.max(0, clip.y - glyphY);
1517
+ const gxEnd = Math.min(glyph.width, clip.x + clip.w - glyphX);
1518
+ const gyEnd = Math.min(glyph.height, clip.y + clip.h - glyphY);
1519
+ for (let gy = gyStart; gy < gyEnd; gy++) {
1520
+ for (let gx = gxStart; gx < gxEnd; gx++) {
1521
+ const alpha = this.fontAlpha(asset, glyph, gy * glyph.width + gx);
1522
+ if (alpha === 0)
1523
+ continue;
1524
+ const dx = glyphX + gx;
1525
+ const dy = glyphY + gy;
1526
+ if (antialias) {
1527
+ this.gfx.drawPixel(dx, dy, alpha >= 15 ? fg : blendRuntime(fg, bg, Math.trunc((alpha * 100) / 15)));
1528
+ }
1529
+ else if (alpha >= 8) {
1530
+ this.gfx.drawPixel(dx, dy, fg);
1531
+ }
1532
+ }
1533
+ }
1534
+ cursor += glyph.advance;
1535
+ }
1536
+ return true;
1537
+ }
1538
+ drawText(text, x, y, fg, bg, size, antialias, fontFace = 0, letterSpacing = 0) {
1539
+ const displayText = text ?? "";
1540
+ if (this.drawAssetText(displayText, x, y, fg, bg, antialias, fontFace))
1541
+ return;
1542
+ if (antialias && letterSpacing === 0 && this.snapshot.program.colorFormat !== "mono" && (fg & 0xffff) !== (bg & 0xffff)) {
1543
+ this.gfx.drawAntialiasedText(displayText, x, y, fg, bg, size);
1544
+ return;
1545
+ }
1546
+ this.gfx.setTextColor(fg, bg);
1547
+ this.gfx.setTextSize(size);
1548
+ this.gfx.setTextWrap(false);
1549
+ if (letterSpacing === 0) {
1550
+ this.gfx.setCursor(x, y);
1551
+ this.gfx.print(displayText);
1552
+ return;
1553
+ }
1554
+ let cursor = x;
1555
+ for (const ch of displayText) {
1556
+ this.gfx.setCursor(cursor, y);
1557
+ this.gfx.print(ch);
1558
+ cursor += Math.max(1, Math.trunc(size)) * 6 + Math.trunc(letterSpacing);
1559
+ }
1560
+ }
1561
+ drawGradientFill(node, drawY) {
1562
+ const bx = node.box.x;
1563
+ const by = drawY;
1564
+ const bw = node.box.w;
1565
+ const bh = node.box.h;
1566
+ if (bw <= 0 || bh <= 0)
1567
+ return;
1568
+ const clip = this.intersectRect({ x: bx, y: by, w: bw, h: bh }, this.activeDrawClip());
1569
+ if (!clip)
1570
+ return;
1571
+ if (node.gradientEnabled === 1) {
1572
+ const yStart = clip.y - by;
1573
+ const yEnd = clip.y + clip.h - by;
1574
+ for (let y = yStart; y < yEnd; y++) {
1575
+ const opacity = Math.trunc((y * 100) / (bh > 1 ? bh - 1 : 1));
1576
+ this.gfx.drawFastHLine(clip.x, by + y, clip.w, blendRuntime(node.gradientColor1, node.gradientColor2, opacity));
1577
+ }
1578
+ }
1579
+ else if (node.gradientEnabled === 2) {
1580
+ const xStart = clip.x - bx;
1581
+ const xEnd = clip.x + clip.w - bx;
1582
+ for (let x = xStart; x < xEnd; x++) {
1583
+ const opacity = Math.trunc((x * 100) / (bw > 1 ? bw - 1 : 1));
1584
+ this.gfx.drawFastVLine(bx + x, clip.y, clip.h, blendRuntime(node.gradientColor1, node.gradientColor2, opacity));
1585
+ }
1586
+ }
1587
+ }
1588
+ drawNodeShadow(node, drawY, insetOnly) {
1589
+ const count = Math.min(node.shadowCount ?? 0, 4);
1590
+ if (count <= 0)
1591
+ return;
1592
+ const bx = node.box.x;
1593
+ const by = drawY;
1594
+ const bw = node.box.w;
1595
+ const bh = node.box.h;
1596
+ const clearCol = node.clearColor;
1597
+ const radius = Math.max(0, Math.trunc(node.borderRadius || 0));
1598
+ for (let s = 0; s < count; s++) {
1599
+ const inset = !!node.shadowInset?.[s];
1600
+ if (insetOnly && !inset)
1601
+ continue;
1602
+ if (!insetOnly && inset)
1603
+ continue;
1604
+ const shadowCol = node.shadowColor?.[s] ?? 0;
1605
+ const ox = Math.trunc(node.shadowOffsetX?.[s] ?? 0);
1606
+ const oy = Math.trunc(node.shadowOffsetY?.[s] ?? 0);
1607
+ const rawBlur = Math.trunc(node.shadowBlur?.[s] ?? 0);
1608
+ const blur = rawBlur === 0 ? 1 : rawBlur;
1609
+ const baseAlpha = Math.max(0, Math.min(100, Math.trunc(node.shadowAlpha?.[s] ?? 100)));
1610
+ if (inset && rawBlur === 0) {
1611
+ const insetBg = node.hasBg ? node.bg : node.clearColor;
1612
+ const col = blendRuntime(shadowCol, insetBg, baseAlpha);
1613
+ if (oy > 0)
1614
+ this.gfx.fillRect(bx, by, bw, oy, col);
1615
+ else if (oy < 0)
1616
+ this.gfx.fillRect(bx, by + bh + oy, bw, -oy, col);
1617
+ if (ox > 0)
1618
+ this.gfx.fillRect(bx, by, ox, bh, col);
1619
+ else if (ox < 0)
1620
+ this.gfx.fillRect(bx + bw + ox, by, -ox, bh, col);
1621
+ if (ox === 0 && oy === 0)
1622
+ this.gfx.drawRect(bx, by, bw, bh, col);
1623
+ continue;
1624
+ }
1625
+ for (let pass = blur; pass >= 1; pass--) {
1626
+ const opacity = Math.trunc(baseAlpha / (pass + 1));
1627
+ const col = blendRuntime(shadowCol, inset ? (node.hasBg ? node.bg : clearCol) : clearCol, opacity);
1628
+ if (inset) {
1629
+ const ix = bx + pass + ox;
1630
+ const iy = by + pass + oy;
1631
+ const iw = bw - 2 * pass;
1632
+ const ih = bh - 2 * pass;
1633
+ if (iw <= 0 || ih <= 0)
1634
+ continue;
1635
+ this.gfx.fillRect(ix, iy, iw, 1, col);
1636
+ this.gfx.fillRect(ix, iy + ih - 1, iw, 1, col);
1637
+ this.gfx.fillRect(ix, iy, 1, ih, col);
1638
+ this.gfx.fillRect(ix + iw - 1, iy, 1, ih, col);
1639
+ }
1640
+ else {
1641
+ const sx = bx + ox - pass;
1642
+ const sy = by + oy - pass;
1643
+ const sw = bw + 2 * pass;
1644
+ const sh = bh + 2 * pass;
1645
+ if (radius > 0)
1646
+ this.gfx.fillRoundRect(sx, sy, sw, sh, radius + pass, col);
1647
+ else
1648
+ this.gfx.fillRect(sx, sy, sw, sh, col);
1649
+ }
1650
+ }
1651
+ }
1652
+ }
1653
+ drawRectOutline(x, y, w, h, radius, style, width, color) {
1654
+ if (style === 0 || width <= 0 || w <= 0 || h <= 0)
1655
+ return;
1656
+ for (let b = 0; b < width; b++) {
1657
+ const rx = x + b;
1658
+ const ry = y + b;
1659
+ const rw = w - 2 * b;
1660
+ const rh = h - 2 * b;
1661
+ if (rw <= 0 || rh <= 0)
1662
+ return;
1663
+ const r = radius > b ? radius - b : 0;
1664
+ if (style === 1) {
1665
+ if (r > 0)
1666
+ this.drawClosedRoundRect(rx, ry, rw, rh, r, color);
1667
+ else
1668
+ this.gfx.drawRect(rx, ry, rw, rh, color);
1669
+ }
1670
+ else {
1671
+ for (let dx = 0; dx < rw; dx += 8) {
1672
+ const seg = Math.min(4, rw - dx);
1673
+ if (seg > 0) {
1674
+ this.gfx.drawFastHLine(rx + dx, ry, seg, color);
1675
+ this.gfx.drawFastHLine(rx + dx, ry + rh - 1, seg, color);
1676
+ }
1677
+ }
1678
+ for (let dy = 0; dy < rh; dy += 8) {
1679
+ const seg = Math.min(4, rh - dy);
1680
+ if (seg > 0) {
1681
+ this.gfx.drawFastVLine(rx, ry + dy, seg, color);
1682
+ this.gfx.drawFastVLine(rx + rw - 1, ry + dy, seg, color);
1683
+ }
1684
+ }
1685
+ }
1686
+ }
1687
+ }
1688
+ drawClosedRoundRect(x, y, w, h, radius, color) {
1689
+ let r = Math.max(0, Math.trunc(radius));
1690
+ if (w <= 0 || h <= 0)
1691
+ return;
1692
+ r = Math.min(r, Math.trunc(Math.min(w, h) / 2));
1693
+ if (r <= 0) {
1694
+ this.gfx.drawRect(x, y, w, h, color);
1695
+ return;
1696
+ }
1697
+ this.gfx.drawRoundRect(x, y, w, h, r, color);
1698
+ this.gfx.drawPixel(x + r, y, color);
1699
+ this.gfx.drawPixel(x + w - r - 1, y, color);
1700
+ this.gfx.drawPixel(x + r, y + h - 1, color);
1701
+ this.gfx.drawPixel(x + w - r - 1, y + h - 1, color);
1702
+ this.gfx.drawPixel(x, y + r, color);
1703
+ this.gfx.drawPixel(x + w - 1, y + r, color);
1704
+ this.gfx.drawPixel(x, y + h - r - 1, color);
1705
+ this.gfx.drawPixel(x + w - 1, y + h - r - 1, color);
1706
+ }
1707
+ drawNodeBorder(node, drawX, drawY, color) {
1708
+ this.drawRectOutline(drawX, drawY, node.box.w, node.box.h, node.borderRadius, node.borderStyle, node.borderWidth, color);
1709
+ }
1710
+ drawNodeOutline(node, drawX, drawY) {
1711
+ if (!node.outlineStyle || !node.outlineWidth)
1712
+ return;
1713
+ const w = node.outlineWidth;
1714
+ this.drawRectOutline(drawX - w, drawY - w, node.box.w + 2 * w, node.box.h + 2 * w, node.borderRadius + w, node.outlineStyle, w, node.outlineColor);
1715
+ }
1716
+ // Set dirty=true across a scroll subtree WITHOUT the per-child O(n) overlap
1717
+ // repair. During scroll the subtree repaints into a freshly-cleared canvas, so
1718
+ // intra-subtree repair is pointless, and scroll children are draw-clipped to
1719
+ // the container's viewport box — so a single overlap check at the container
1720
+ // (done by markScrollDescendantsDirty) covers all external higher-z neighbors.
1721
+ markScrollDescendantsDirtyLocal(nodeIndex) {
1722
+ const node = this.nodes[nodeIndex];
1723
+ for (let i = nodeIndex + 1; i < node.subtreeEnd; i++) {
1724
+ if (this.nodes[i])
1725
+ this.nodes[i].dirty = true;
1726
+ }
1727
+ this.nodes[nodeIndex].dirty = true;
1728
+ }
1729
+ markScrollDescendantsDirty(nodeIndex) {
1730
+ this.markScrollDescendantsDirtyLocal(nodeIndex);
1731
+ // Single overlap check at the container covers every external higher-z
1732
+ // neighbor of the viewport (children are clipped to this box). Drops scroll
1733
+ // marking from O(K·n) to O(K + n).
1734
+ this.markOverlappingHigherLayersDirty(nodeIndex);
1735
+ }
1736
+ markScrollViewDirty(nodeIndex) {
1737
+ if (!this.nodes[nodeIndex])
1738
+ return;
1739
+ this.nodes[nodeIndex].dirty = true;
1740
+ this.markOverlappingHigherLayersDirty(nodeIndex);
1741
+ }
1742
+ invalidateScrollCanvasForNode(_nodeIndex) {
1743
+ // Device runtime invalidates its cached shifted scroll canvas here. The
1744
+ // preview renderer draws into one framebuffer and has no persistent scroll
1745
+ // backing canvas, so the equivalent operation is intentionally empty.
1746
+ }
1747
+ drawDirty() {
1748
+ if (this.keyboardVisible) {
1749
+ this.keyboardTick(Date.now());
1750
+ if (this.keyboardDirty === 0)
1751
+ return false;
1752
+ if (this.keyboardDirty === 1) {
1753
+ this.drawKeyboard();
1754
+ }
1755
+ else {
1756
+ this.drawKeyboardTextRow();
1757
+ if (this.keyboardRepaintKey >= 0)
1758
+ this.drawKeyboardKey(this.keyboardRepaintKey);
1759
+ }
1760
+ this.keyboardDirty = 0;
1761
+ this.keyboardRepaintKey = -1;
1762
+ return true;
1763
+ }
1764
+ let changed = this.clearDirtyScrollViewports();
1765
+ const scrollbarDirty = new Set();
1766
+ for (const node of this.nodes) {
1767
+ if (this.isActiveNode(node) && this.isEffectivelyVisible(node) && node.scrollable && node.dirty)
1768
+ scrollbarDirty.add(node.index);
1769
+ }
1770
+ const dirtyNodes = this.nodes
1771
+ .filter((node) => {
1772
+ if (!node.dirty)
1773
+ return false;
1774
+ if (!this.isActiveNode(node)) {
1775
+ node.dirty = false;
1776
+ return false;
1777
+ }
1778
+ if (!this.isEffectivelyVisible(node)) {
1779
+ node.dirty = false;
1780
+ return false;
1781
+ }
1782
+ return true;
1783
+ })
1784
+ .sort((a, b) => this.compareDrawOrder(a, b));
1785
+ for (const node of dirtyNodes) {
1786
+ if (!node.dirty)
1787
+ continue;
1788
+ if (!this.isEffectivelyVisible(node)) {
1789
+ node.dirty = false;
1790
+ continue;
1791
+ }
1792
+ if (!this.isActiveNode(node)) {
1793
+ node.dirty = false;
1794
+ continue;
1795
+ }
1796
+ const origBoxX = node.box.x;
1797
+ const baseX = this.baseDrawXForNode(node.index);
1798
+ const baseY = this.baseDrawYForNode(node.index);
1799
+ const drawX = this.drawXForNode(node.index);
1800
+ const drawY = this.drawYForNode(node.index);
1801
+ node.box.x = drawX;
1802
+ const scrollClip = this.scrollClipForNode(node.index);
1803
+ if (scrollClip && !this.rectIntersectsClip(node, drawY, scrollClip)) {
1804
+ node.box.x = origBoxX;
1805
+ node.dirty = false;
1806
+ continue;
1807
+ }
1808
+ this.clearPressOffsetArea(node, baseX, baseY);
1809
+ const displayText = node.hasTextBinding ? node.textBuffer : node.text;
1810
+ const ts = this.nodeTextSize(node);
1811
+ let bColor = node.borderColor || node.fg;
1812
+ if (node.opacity < 100)
1813
+ bColor = blendRuntime(bColor, node.clearColor, node.opacity);
1814
+ // Opacity background: blend the node's bg toward the backdrop (the parent
1815
+ // clear color — what's actually behind the node) so a translucent element
1816
+ // fades toward what's behind it. Mirrors the C++ fillBg computation.
1817
+ // (node.clearColor is the node's own bg for filled nodes — a no-op blend
1818
+ // target — so we use parentClearColor, the real backdrop.)
1819
+ let fillBg = node.bg;
1820
+ if (node.opacity < 100)
1821
+ fillBg = blendRuntime(node.bg, this.parentClearColor(node), node.opacity);
1822
+ this.gfx.withClipRect(scrollClip, () => {
1823
+ node.box.x = baseX;
1824
+ this.drawNodeShadow(node, baseY, false);
1825
+ node.box.x = drawX;
1826
+ switch (node.kind) {
1827
+ case "fill":
1828
+ if (node.gradientEnabled > 0) {
1829
+ this.drawGradientFill(node, drawY);
1830
+ }
1831
+ else {
1832
+ const fillSize = this.rotatedFaceSize(node, node.box.w, node.box.h);
1833
+ if (node.borderRadius > 0 && node.hasBg)
1834
+ this.gfx.fillRoundRect(node.box.x, drawY, fillSize.w, fillSize.h, node.borderRadius, fillBg);
1835
+ else if (node.hasBg)
1836
+ this.gfx.fillRect(node.box.x, drawY, fillSize.w, fillSize.h, fillBg);
1837
+ if (node.borderStyle)
1838
+ this.drawRectOutline(node.box.x, drawY, fillSize.w, fillSize.h, node.borderRadius, node.borderStyle, node.borderWidth, bColor);
1839
+ }
1840
+ this.drawNodeShadow(node, drawY, true);
1841
+ break;
1842
+ case "text":
1843
+ this.drawTextNode(node, displayText, drawY, ts);
1844
+ break;
1845
+ case "button":
1846
+ this.drawButtonNode(node, displayText, bColor, fillBg, drawY, ts);
1847
+ break;
1848
+ case "check":
1849
+ this.drawCheckNode(node, displayText, drawY, ts);
1850
+ break;
1851
+ case "radio":
1852
+ this.drawRadioNode(node, displayText, drawY, ts);
1853
+ break;
1854
+ case "progress":
1855
+ this.drawProgressNode(node, drawY);
1856
+ break;
1857
+ case "range":
1858
+ this.drawRangeNode(node, drawY);
1859
+ break;
1860
+ case "input":
1861
+ this.drawInputNode(node, drawY);
1862
+ break;
1863
+ case "img":
1864
+ this.drawImageNode(node, drawY);
1865
+ break;
1866
+ case "list":
1867
+ this.drawListNode(node, drawY, scrollClip);
1868
+ break;
1869
+ case "canvas":
1870
+ this.drawCanvasNode(node, drawY);
1871
+ break;
1872
+ }
1873
+ if (node.kind === "fill" && this.rotationQuadrant(node.rotateDeg) !== 0 && node.outlineStyle && node.outlineWidth > 0) {
1874
+ const w = node.outlineWidth;
1875
+ const outlineSize = this.rotatedFaceSize(node, node.box.w, node.box.h);
1876
+ this.drawRectOutline(node.box.x - w, drawY - w, outlineSize.w + 2 * w, outlineSize.h + 2 * w, node.borderRadius + w, node.outlineStyle, w, node.outlineColor);
1877
+ }
1878
+ else {
1879
+ this.drawNodeOutline(node, node.box.x, drawY);
1880
+ }
1881
+ });
1882
+ changed = true;
1883
+ node.box.x = origBoxX;
1884
+ node.dirty = false;
1885
+ }
1886
+ return this.drawScrollbars(scrollbarDirty) || changed;
1887
+ }
1888
+ lineX(node, lineWidth, left, maxWidth, align = node.textAlign) {
1889
+ if (align === 1)
1890
+ return left + Math.trunc((maxWidth - lineWidth) / 2);
1891
+ if (align === 2)
1892
+ return left + maxWidth - lineWidth;
1893
+ return left;
1894
+ }
1895
+ textInsets(node) {
1896
+ const border = node.borderWidth || 0;
1897
+ return {
1898
+ left: border + (node.paddingLeft || 0),
1899
+ right: border + (node.paddingRight || 0),
1900
+ top: border + (node.paddingTop || 0),
1901
+ bottom: border + (node.paddingBottom || 0),
1902
+ };
1903
+ }
1904
+ drawTextLines(node, displayText, left, top, maxWidth, ts, fg, bg, align = node.textAlign) {
1905
+ const layout = this.textLayout(node, displayText, maxWidth, ts);
1906
+ let y = top;
1907
+ const clip = this.activeDrawClip();
1908
+ for (const line of layout.lines) {
1909
+ if (y + layout.lineHeight <= clip.y || y >= clip.y + clip.h) {
1910
+ y += layout.lineHeight;
1911
+ continue;
1912
+ }
1913
+ // text-overflow: when a line is wider than maxWidth, truncate it. Ellipsis
1914
+ // (textOverflow truthy) appends "..."; clip (falsy) hard-cuts. Mirrors the
1915
+ // C++ ui_draw_wrapped_text overflow branch (ui_truncate_ellipsis/_clip).
1916
+ let lineText = line.text;
1917
+ if (line.width > maxWidth && maxWidth > 0) {
1918
+ if (node.textOverflow) {
1919
+ lineText = this.truncateEllipsis(line.text, maxWidth, ts, node.fontFace, node.letterSpacing);
1920
+ }
1921
+ else {
1922
+ lineText = this.clipTextToWidth(line.text, maxWidth, ts, node.fontFace, node.letterSpacing);
1923
+ }
1924
+ }
1925
+ const lineW = this.textWidth(lineText, ts, node.fontFace, node.letterSpacing);
1926
+ const x = this.lineX(node, lineW, left, maxWidth, align);
1927
+ if (x + lineW > clip.x && x < clip.x + clip.w) {
1928
+ this.drawText(lineText, x, y, fg, bg, ts, node.fontAntialias, node.fontFace, node.letterSpacing);
1929
+ if (node.underline)
1930
+ this.gfx.drawFastHLine(x, y + this.textHeight(ts, node.fontFace) - 1, lineW, fg);
1931
+ }
1932
+ y += layout.lineHeight;
1933
+ }
1934
+ return { width: layout.width, height: layout.height };
1935
+ }
1936
+ // Draw a rich-text node from its precomputed run/segment/line geometry (the
1937
+ // host twin of the C++ ui_draw_rich_text). Does NOT re-wrap — geometry was
1938
+ // baked at transpile time. Iterate segments once, skip lines and segments
1939
+ // outside the active clip, and compute each segment's x-origin from textAlign
1940
+ // + line width. Segments of different font-sizes align on the line's baseline.
1941
+ drawRichNode(node, drawY, ts, contentX = node.box.x, contentY = drawY, contentW = node.box.w, boxPainted = false) {
1942
+ if (!node.runLines || !node.runs)
1943
+ return;
1944
+ // Clear (mirrors drawTextNode's clear + translucent blend).
1945
+ const clearW = Math.max(node.box.w, node.lastTextWidth ?? 0);
1946
+ const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0);
1947
+ let textClear = node.hasBg ? node.bg : node.clearColor;
1948
+ if (node.opacity < 100) {
1949
+ const backdrop = this.parentClearColor(node);
1950
+ textClear = blendRuntime(node.hasBg ? node.bg : node.clearColor, backdrop, node.opacity);
1951
+ }
1952
+ if (!boxPainted)
1953
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, textClear);
1954
+ const drawSegs = (xOffset, yOffset, fgOverride) => {
1955
+ const rl = node.runLines;
1956
+ const runs = node.runs;
1957
+ const clip = this.gfx.getClipRect();
1958
+ for (let si = 0; si < rl.segRun.length; si++) {
1959
+ const li = rl.segLine[si];
1960
+ if (li >= rl.lineY.length)
1961
+ continue;
1962
+ const lineTop = contentY + yOffset + rl.lineY[li];
1963
+ const lineBottom = lineTop + rl.lineH[li];
1964
+ if (clip && (lineBottom <= clip.y || lineTop >= clip.y + clip.h))
1965
+ continue;
1966
+ const lineX = this.lineX(node, rl.lineW[li], contentX + xOffset, contentW);
1967
+ const sx = lineX + rl.segX[si];
1968
+ if (clip && (sx + rl.segW[si] <= clip.x || sx >= clip.x + clip.w))
1969
+ continue;
1970
+ const run = runs[rl.segRun[si]];
1971
+ if (!run)
1972
+ continue;
1973
+ const baseline = contentY + yOffset + rl.lineBaseline[li];
1974
+ const segY = baseline - (7 * run.textSize);
1975
+ const fg = fgOverride ?? run.fg;
1976
+ this.drawText(rl.segText[si], sx, segY, fg, textClear, run.textSize, node.fontAntialias, run.fontFace, run.letterSpacing);
1977
+ if (run.underline & 1)
1978
+ this.gfx.drawFastHLine(sx, segY + 8 * run.textSize - 1, rl.segW[si], fg);
1979
+ if (run.underline & 2)
1980
+ this.gfx.drawFastHLine(sx, segY + 4 * run.textSize, rl.segW[si], fg);
1981
+ }
1982
+ };
1983
+ if (node.textShadowCount > 0) {
1984
+ const shadowColor = blendRuntime(node.textShadowColor, textClear, node.textShadowAlpha);
1985
+ drawSegs(node.textShadowOffsetX, node.textShadowOffsetY, shadowColor);
1986
+ }
1987
+ drawSegs(0, 0);
1988
+ }
1989
+ // Hit-test a tap point (screen coords) against a rich-text node's link runs.
1990
+ // Returns the link run's screen index, or -1. Mirrors the C++ ui_rich_link_hit.
1991
+ richLinkHit(nodeIndex, tx, ty) {
1992
+ const node = this.nodes[nodeIndex];
1993
+ if (!node.runs || !node.runLines)
1994
+ return -1;
1995
+ const drawX = this.drawXForNode(nodeIndex);
1996
+ const drawY = this.drawYForNode(nodeIndex);
1997
+ const insets = this.textInsets(node);
1998
+ const nx = tx - drawX - insets.left;
1999
+ const ny = ty - drawY - insets.top;
2000
+ const contentW = Math.max(1, node.box.w - insets.left - insets.right);
2001
+ for (let si = 0; si < node.runLines.segRun.length; si++) {
2002
+ const run = node.runs[node.runLines.segRun[si]];
2003
+ if (run.linkTarget < 0)
2004
+ continue;
2005
+ const li = node.runLines.segLine[si];
2006
+ // Account for center/right alignment the same way draw does.
2007
+ const originX = this.lineX(node, node.runLines.lineW[li], 0, contentW);
2008
+ const sx = originX + node.runLines.segX[si];
2009
+ const sy = node.runLines.lineY[li];
2010
+ const sw = node.runLines.segW[si];
2011
+ const sh = node.runLines.lineH[li];
2012
+ if (nx >= sx && nx < sx + sw && ny >= sy && ny < sy + sh)
2013
+ return run.linkTarget;
2014
+ }
2015
+ return -1;
2016
+ }
2017
+ drawTextNode(node, displayText, drawY, ts) {
2018
+ const insets = this.textInsets(node);
2019
+ const contentX = node.box.x + insets.left;
2020
+ const contentY = drawY + insets.top;
2021
+ const contentW = Math.max(1, node.box.w - insets.left - insets.right);
2022
+ let bColor = node.borderColor || node.fg;
2023
+ if (node.opacity < 100)
2024
+ bColor = blendRuntime(bColor, this.parentClearColor(node), node.opacity);
2025
+ let fillBg = node.bg;
2026
+ if (node.opacity < 100)
2027
+ fillBg = blendRuntime(node.bg, this.parentClearColor(node), node.opacity);
2028
+ const textBoxPainted = node.gradientEnabled > 0 || node.hasBg;
2029
+ if (node.gradientEnabled > 0) {
2030
+ this.drawGradientFill(node, drawY);
2031
+ }
2032
+ else if (node.borderRadius > 0 && node.hasBg) {
2033
+ this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, fillBg);
2034
+ }
2035
+ else if (node.hasBg) {
2036
+ this.gfx.fillRect(node.box.x, drawY, node.box.w, node.box.h, fillBg);
2037
+ }
2038
+ // Rich-text (inline runs): draw from precomputed geometry instead of the
2039
+ // single-string wrapped path.
2040
+ if (node.runs && node.runLines) {
2041
+ this.drawNodeShadow(node, drawY, true);
2042
+ this.drawRichNode(node, drawY, ts, contentX, contentY, contentW, textBoxPainted);
2043
+ if (node.borderStyle)
2044
+ this.drawNodeBorder(node, node.box.x, drawY, bColor);
2045
+ return;
2046
+ }
2047
+ const layout = this.textLayout(node, displayText, contentW, ts);
2048
+ // overflow:hidden/scroll: cap the clear at the node's own box width so a
2049
+ // nowrap line wider than its box doesn't repaint past the edge (mirrors the
2050
+ // C++ scrollable clearW cap).
2051
+ let clearW = Math.max(node.box.w, node.lastTextWidth + insets.left + insets.right, layout.width + insets.left + insets.right);
2052
+ if (node.scrollable)
2053
+ clearW = node.box.w;
2054
+ const clearH = Math.max(node.box.h, (node.lastTextHeight ?? 0) + insets.top + insets.bottom, layout.height + insets.top + insets.bottom);
2055
+ // Clear + glyph-cell background. When the node is translucent (inherited
2056
+ // from an opacity:<1 parent), blend toward the parent's clear color (the
2057
+ // backdrop behind the translucent element) so the text area matches the
2058
+ // parent's blended fill rather than repainting solid. Mirrors the C++ text
2059
+ // clear + textBg blend.
2060
+ let textClear = node.hasBg ? node.bg : node.clearColor;
2061
+ if (node.opacity < 100) {
2062
+ const backdrop = this.parentClearColor(node);
2063
+ textClear = blendRuntime(node.hasBg ? node.bg : node.clearColor, backdrop, node.opacity);
2064
+ }
2065
+ if (!textBoxPainted)
2066
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, textClear);
2067
+ node.lastTextWidth = layout.width;
2068
+ node.lastTextHeight = layout.height;
2069
+ this.drawNodeShadow(node, drawY, true);
2070
+ if (node.borderStyle)
2071
+ this.drawNodeBorder(node, node.box.x, drawY, bColor);
2072
+ if (node.textShadowCount > 0) {
2073
+ const shadowColor = blendRuntime(node.textShadowColor, textClear, node.textShadowAlpha);
2074
+ this.drawTextLines(node, displayText, contentX + node.textShadowOffsetX, contentY + node.textShadowOffsetY, contentW, ts, shadowColor, shadowColor);
2075
+ }
2076
+ this.drawTextLines(node, displayText, contentX, contentY, contentW, ts, node.fg, textClear);
2077
+ }
2078
+ listStateForNode(nodeIndex) {
2079
+ return this.listStates.find((list) => list.nodeIndex === nodeIndex);
2080
+ }
2081
+ shiftListViewport(node, drawY, deltaY, bg) {
2082
+ const x = Math.trunc(node.box.x);
2083
+ const y = Math.trunc(drawY);
2084
+ const w = Math.trunc(node.box.w);
2085
+ const h = Math.trunc(node.box.h);
2086
+ const shift = Math.abs(Math.trunc(deltaY));
2087
+ if (w <= 0 || h <= 0)
2088
+ return { y: 0, h: 0 };
2089
+ const borderInset = node.borderStyle ? Math.max(0, Math.trunc(node.borderWidth || 1)) : 0;
2090
+ const contentW = w > 4 ? w - 4 : w;
2091
+ const copyX = x + borderInset;
2092
+ const copyY = y + borderInset;
2093
+ const copyW = Math.max(0, contentW - borderInset);
2094
+ const copyH = Math.max(0, h - 2 * borderInset);
2095
+ if (x < 0 || y < 0 || x + w > this.gfx.width || y + h > this.gfx.height || shift <= 0 || shift >= copyH || copyW <= 0) {
2096
+ this.gfx.fillRect(x, y, w, h, bg);
2097
+ return { y: 0, h };
2098
+ }
2099
+ const stride = this.gfx.width;
2100
+ const pixels = this.gfx.buffer;
2101
+ let exposedY = 0;
2102
+ if (deltaY > 0) {
2103
+ for (let row = 0; row < copyH - shift; row++) {
2104
+ const dst = (copyY + row) * stride + copyX;
2105
+ const src = (copyY + row + shift) * stride + copyX;
2106
+ pixels.copyWithin(dst, src, src + copyW);
2107
+ }
2108
+ exposedY = borderInset + copyH - shift;
2109
+ }
2110
+ else {
2111
+ for (let row = copyH - shift - 1; row >= 0; row--) {
2112
+ const dst = (copyY + row + shift) * stride + copyX;
2113
+ const src = (copyY + row) * stride + copyX;
2114
+ pixels.copyWithin(dst, src, src + copyW);
2115
+ }
2116
+ exposedY = borderInset;
2117
+ }
2118
+ this.gfx.fillRect(copyX, y + exposedY, copyW, shift, bg);
2119
+ if (w > contentW)
2120
+ this.gfx.fillRect(x + contentW, y, w - contentW, h, bg);
2121
+ return { y: exposedY, h: shift };
2122
+ }
2123
+ drawListRows(node, list, drawY, scrollClip, bg, repaintY, repaintH) {
2124
+ if (list.itemCount <= 0 || repaintH <= 0)
2125
+ return;
2126
+ const stripClip = this.intersectClipRect(scrollClip, { x: node.box.x, y: drawY + repaintY, w: node.box.w, h: repaintH });
2127
+ this.gfx.withClipRect(stripClip, () => {
2128
+ const itemHeight = list.itemHeight;
2129
+ const ts = this.nodeTextSize(node);
2130
+ const textH = this.textHeight(ts, node.fontFace);
2131
+ const listScrollY = node.scrollY;
2132
+ const first = Math.max(0, Math.trunc((listScrollY + repaintY) / itemHeight));
2133
+ const last = Math.min(list.itemCount - 1, Math.trunc((listScrollY + repaintY + repaintH - 1) / itemHeight) + 1);
2134
+ for (let row = first; row <= last; row++) {
2135
+ const itemY = drawY + row * itemHeight - listScrollY;
2136
+ const text = clampText(this.evaluateExpression(list.itemExpression, this.listLocal(list.itemParam, row)));
2137
+ this.drawText(text, node.box.x + 4, itemY + Math.trunc((itemHeight - textH) / 2), node.fg, bg, ts, node.fontAntialias, node.fontFace, node.letterSpacing);
2138
+ }
2139
+ });
2140
+ }
2141
+ drawListNode(node, drawY, scrollClip) {
2142
+ const list = this.listStateForNode(node.index);
2143
+ const bg = node.hasBg ? node.bg : node.clearColor;
2144
+ if (!list || list.itemHeight <= 0) {
2145
+ this.gfx.fillRect(node.box.x, drawY, node.box.w, node.box.h, bg);
2146
+ return;
2147
+ }
2148
+ const deltaY = node.scrollY - node.lastPaintedScrollY;
2149
+ const absDelta = Math.abs(deltaY);
2150
+ const canShift = deltaY !== 0 && absDelta < node.box.h;
2151
+ const repaint = canShift
2152
+ ? this.shiftListViewport(node, drawY, deltaY, bg)
2153
+ : (() => {
2154
+ this.gfx.fillRect(node.box.x, drawY, node.box.w, node.box.h, bg);
2155
+ return { y: 0, h: node.box.h };
2156
+ })();
2157
+ this.drawListRows(node, list, drawY, scrollClip, bg, repaint.y, repaint.h);
2158
+ const listContentH = node.contentHeight;
2159
+ if (listContentH > node.box.h) {
2160
+ const listClip = this.intersectClipRect(scrollClip, { x: node.box.x, y: drawY, w: node.box.w, h: node.box.h });
2161
+ this.gfx.withClipRect(listClip, () => {
2162
+ const tx = node.box.x + node.box.w - 4;
2163
+ const trackColor = (node.fg >> 1) & 0x7bef;
2164
+ this.gfx.fillRect(tx, drawY, 3, node.box.h, trackColor);
2165
+ const thumbH = Math.max(8, Math.trunc((node.box.h * node.box.h) / listContentH));
2166
+ const maxScroll = Math.max(1, listContentH - node.box.h);
2167
+ // Clamp the thumb to the track during overscroll (scrollY stays in range,
2168
+ // but overscrollPx can push the visual; the thumb pins to the ends).
2169
+ const clampedScrollY = Math.max(0, Math.min(node.scrollY, listContentH - node.box.h));
2170
+ const thumbY = drawY + Math.trunc(((node.box.h - thumbH) * clampedScrollY) / maxScroll);
2171
+ this.gfx.fillRect(tx, thumbY, 3, thumbH, node.fg);
2172
+ });
2173
+ }
2174
+ let bColor = node.borderColor || node.fg;
2175
+ if (node.opacity < 100)
2176
+ bColor = blendRuntime(bColor, node.clearColor, node.opacity);
2177
+ this.drawNodeShadow(node, drawY, true);
2178
+ if (node.borderStyle)
2179
+ this.drawNodeBorder(node, node.box.x, drawY, bColor);
2180
+ node.lastPaintedScrollY = node.scrollY;
2181
+ }
2182
+ drawCanvasNode(node, drawY) {
2183
+ const binding = this.canvasBindings.find((b) => b.nodeIndex === node.index);
2184
+ if (!binding)
2185
+ return;
2186
+ this.runCanvasBody(binding.drawBody, node.box.x, drawY, node.canvasW ?? node.box.w, node.canvasH ?? node.box.h);
2187
+ }
2188
+ /** Lower a canvas drawBody (`ctx.X(...)` source) against the host gfx.
2189
+ * Mirrors the device ctx→ui_display_* rewrite so preview and device match.
2190
+ * Coordinates are translated to the node origin (ox, oy). Only the flat call
2191
+ * sequence is supported — same constraint as bindList item expressions. */
2192
+ runCanvasBody(body, ox, oy, cw, ch) {
2193
+ const color = (c) => {
2194
+ try {
2195
+ return resolveColor(c.replace(/^['"]|['"]$/g, ""), "rgb565");
2196
+ }
2197
+ catch {
2198
+ return 0xffff;
2199
+ }
2200
+ };
2201
+ const n = (i, args) => parseInt(args[i], 10) || 0;
2202
+ // Resolve each numeric argument: ctx.width/height → canvas dims, integer
2203
+ // literals parse directly, anything else (e.g. screen.gauge.value, Math.*,
2204
+ // module vars) is evaluated against the screen proxy / module scope. This
2205
+ // mirrors the device, which emits these as real C++ expressions evaluated at
2206
+ // draw time (`__ui_nodes[i].value`), so the canvas tracks live state.
2207
+ const num = (i, args) => {
2208
+ const t = args[i].trim();
2209
+ if (t === "ctx.width" || t === "ctx?.width")
2210
+ return cw;
2211
+ if (t === "ctx.height" || t === "ctx?.height")
2212
+ return ch;
2213
+ const parsed = parseInt(t, 10);
2214
+ if (Number.isNaN(parsed)) {
2215
+ // Substitute ctx.width/ctx.height with their numeric values so compound
2216
+ // expressions like "ctx.height - 3" evaluate correctly. Then try
2217
+ // evaluating as a JS expression against module scope.
2218
+ const substituted = t
2219
+ .replace(/ctx\??\.width/g, String(cw))
2220
+ .replace(/ctx\??\.height/g, String(ch));
2221
+ if (/^[\d\s+\-*/().]+$/.test(substituted)) {
2222
+ // Pure arithmetic — eval safely (no identifiers).
2223
+ try {
2224
+ return Math.trunc(Function(`"use strict"; return (${substituted});`)()) || 0;
2225
+ }
2226
+ catch { /* fall through */ }
2227
+ }
2228
+ const value = this.evaluateExpression(substituted);
2229
+ return Math.trunc(Number(value)) || 0;
2230
+ }
2231
+ return parsed || 0;
2232
+ };
2233
+ const g = this.gfx;
2234
+ const re = /ctx\.(fillRect|rect|fillCircle|circle|line|hline|vline|fillRoundRect|roundRect|drawPixel|fillScreen|text)\(([^)]*)\)/g;
2235
+ let m;
2236
+ while ((m = re.exec(body)) !== null) {
2237
+ const method = m[1];
2238
+ const args = m[2].split(",").map((s) => s.trim());
2239
+ if (method === "fillRect")
2240
+ g.fillRect(ox + num(0, args), oy + num(1, args), num(2, args), num(3, args), color(args[4]));
2241
+ else if (method === "rect")
2242
+ g.drawRect(ox + num(0, args), oy + num(1, args), num(2, args), num(3, args), color(args[4]));
2243
+ else if (method === "fillCircle")
2244
+ g.fillCircle(ox + num(0, args), oy + num(1, args), num(2, args), color(args[3]));
2245
+ else if (method === "circle")
2246
+ g.drawCircle(ox + num(0, args), oy + num(1, args), num(2, args), color(args[3]));
2247
+ else if (method === "line")
2248
+ g.drawLine(ox + num(0, args), oy + num(1, args), ox + num(2, args), oy + num(3, args), color(args[4]));
2249
+ else if (method === "hline")
2250
+ g.drawFastHLine(ox + num(0, args), oy + num(1, args), num(2, args), color(args[3]));
2251
+ else if (method === "vline")
2252
+ g.drawFastVLine(ox + num(0, args), oy + num(1, args), num(2, args), color(args[3]));
2253
+ else if (method === "fillRoundRect")
2254
+ g.fillRoundRect(ox + num(0, args), oy + num(1, args), num(2, args), num(3, args), num(4, args), color(args[5]));
2255
+ else if (method === "roundRect")
2256
+ g.drawRoundRect(ox + num(0, args), oy + num(1, args), num(2, args), num(3, args), num(4, args), color(args[5]));
2257
+ else if (method === "drawPixel")
2258
+ g.drawPixel(ox + num(0, args), oy + num(1, args), color(args[2]));
2259
+ else if (method === "fillScreen")
2260
+ g.fillRect(ox, oy, cw, ch, color(args[0]));
2261
+ else if (method === "text") {
2262
+ g.setCursor(ox + num(0, args), oy + num(1, args));
2263
+ if (args.length >= 4)
2264
+ g.setTextColor(color(args[3]));
2265
+ g.print(args[2].replace(/^['"`]|['"`]$/g, ""));
2266
+ }
2267
+ }
2268
+ // ctx.rgbBitmap(x, y, data, w, h) — blits an RGB565 pixel array. Handled
2269
+ // separately because `data` may be an inline array literal (commas inside
2270
+ // brackets) that the generic ([^)]*) regex above can't split. Mirrors the
2271
+ // runtime's ui_display_draw_rgb_bitmap lowering (canvas-lowering.ts:94).
2272
+ const rgbRe = /ctx\.rgbBitmap\s*\(\s*([^,]+),\s*([^,]+),\s*(\[[^\]]*\]|[$A-Z_a-z][$\w]*)\s*,\s*([^,]+),\s*([^)]+)\)/g;
2273
+ let rm;
2274
+ while ((rm = rgbRe.exec(body)) !== null) {
2275
+ const bx = num(0, [rm[1]]);
2276
+ const by = num(0, [rm[2]]);
2277
+ const dataExpr = rm[3];
2278
+ const bw = num(0, [rm[4]]);
2279
+ const bh = num(0, [rm[5]]);
2280
+ // Evaluate the pixel array against module scope (handles both inline
2281
+ // `[r,g,b,...]` literals and variable names).
2282
+ let pixels = undefined;
2283
+ try {
2284
+ pixels = this.evaluateExpression(`(${dataExpr})`);
2285
+ }
2286
+ catch { /* leave undefined; treated as empty below */ }
2287
+ if (!Array.isArray(pixels))
2288
+ continue;
2289
+ const px = pixels;
2290
+ for (let dy = 0; dy < bh; dy++) {
2291
+ for (let dx = 0; dx < bw; dx++) {
2292
+ const i = dy * bw + dx;
2293
+ if (i >= px.length)
2294
+ break;
2295
+ g.drawPixel(ox + bx + dx, oy + by + dy, Number(px[i]) || 0);
2296
+ }
2297
+ }
2298
+ }
2299
+ }
2300
+ drawButtonNode(node, displayText, bColor, fillBg, drawY, ts) {
2301
+ if (node.borderRadius > 0 && node.hasBg)
2302
+ this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, fillBg);
2303
+ else if (node.hasBg)
2304
+ this.gfx.fillRect(node.box.x, drawY, node.box.w, node.box.h, fillBg);
2305
+ this.drawNodeShadow(node, drawY, true);
2306
+ if (node.borderStyle)
2307
+ this.drawNodeBorder(node, node.box.x, drawY, bColor);
2308
+ const insetL = (node.borderWidth || 0) + (node.paddingLeft || 0);
2309
+ const insetR = (node.borderWidth || 0) + (node.paddingRight || 0);
2310
+ const insetT = (node.borderWidth || 0) + (node.paddingTop || 0);
2311
+ const insetB = (node.borderWidth || 0) + (node.paddingBottom || 0);
2312
+ const textX = node.box.x + insetL;
2313
+ const textY = drawY + insetT;
2314
+ const textW = Math.max(1, node.box.w - insetL - insetR);
2315
+ const textH = Math.max(1, node.box.h - insetT - insetB);
2316
+ const layout = this.textLayout(node, displayText, textW, ts);
2317
+ const top = textY + Math.trunc((textH - layout.height) / 2);
2318
+ const glyphBg = node.opacity < 100 ? blendRuntime(node.bg, this.parentClearColor(node), node.opacity) : (node.hasBg ? node.bg : node.clearColor);
2319
+ this.drawTextLines(node, displayText, textX, top, textW, ts, node.fg, glyphBg, node.textAlign);
2320
+ }
2321
+ drawCheckNode(node, displayText, drawY, ts) {
2322
+ const layout = this.textLayout(node, displayText, Math.max(0, node.box.w - 22), ts);
2323
+ const clearW = Math.max(node.box.w, node.lastTextWidth);
2324
+ const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height);
2325
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, node.hasBg ? node.bg : node.clearColor);
2326
+ node.lastTextWidth = 22 + layout.width;
2327
+ node.lastTextHeight = Math.max(layout.height, 16);
2328
+ const cbX = node.box.x;
2329
+ const cbY = drawY;
2330
+ if (node.value) {
2331
+ this.gfx.fillRect(cbX, cbY, 16, 16, node.fg);
2332
+ const inv = node.hasBg ? node.bg : node.clearColor;
2333
+ this.gfx.drawLine(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
2334
+ this.gfx.drawLine(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
2335
+ this.gfx.drawLine(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
2336
+ this.gfx.drawLine(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
2337
+ this.gfx.drawLine(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
2338
+ this.gfx.drawLine(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
2339
+ }
2340
+ else {
2341
+ this.gfx.drawRect(cbX, cbY, 16, 16, node.fg);
2342
+ }
2343
+ this.drawTextLines(node, displayText, node.box.x + 22, drawY, Math.max(0, node.box.w - 22), ts, node.fg, node.hasBg ? node.bg : node.clearColor, 0);
2344
+ }
2345
+ drawRadioNode(node, displayText, drawY, ts) {
2346
+ const layout = this.textLayout(node, displayText, Math.max(0, node.box.w - 22), ts);
2347
+ const clearW = Math.max(node.box.w, node.lastTextWidth);
2348
+ const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height);
2349
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, node.hasBg ? node.bg : node.clearColor);
2350
+ node.lastTextWidth = 22 + layout.width;
2351
+ node.lastTextHeight = Math.max(layout.height, 16);
2352
+ const cbX = node.box.x;
2353
+ const cbY = drawY;
2354
+ if (node.value) {
2355
+ this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.fg);
2356
+ this.gfx.fillCircle(cbX + 8, cbY + 8, 3, node.hasBg ? node.bg : node.clearColor);
2357
+ }
2358
+ else {
2359
+ this.gfx.drawCircle(cbX + 8, cbY + 8, 7, node.fg);
2360
+ }
2361
+ this.drawTextLines(node, displayText, node.box.x + 22, drawY, Math.max(0, node.box.w - 22), ts, node.fg, node.hasBg ? node.bg : node.clearColor, 0);
2362
+ }
2363
+ drawProgressNode(node, drawY) {
2364
+ const bx = node.box.x;
2365
+ const by = drawY;
2366
+ const bw = node.box.w;
2367
+ const bh = node.box.h;
2368
+ const bgCol = node.hasBg ? node.bg : node.clearColor;
2369
+ const fgCol = node.fg;
2370
+ const pct = Math.max(0, Math.min(100, node.value));
2371
+ const fillW = Math.trunc(((bw - 2) * pct) / 100);
2372
+ const prevW = node.lastTextWidth;
2373
+ if (prevW < 0) {
2374
+ this.gfx.drawRect(bx, by, bw, bh, fgCol);
2375
+ this.gfx.fillRect(bx + 1, by + 1, bw - 2, bh - 2, bgCol);
2376
+ if (fillW > 0)
2377
+ this.gfx.fillRect(bx + 1, by + 1, fillW, bh - 2, fgCol);
2378
+ }
2379
+ else if (fillW > prevW) {
2380
+ this.gfx.fillRect(bx + 1 + prevW, by + 1, fillW - prevW, bh - 2, fgCol);
2381
+ }
2382
+ else if (fillW < prevW) {
2383
+ this.gfx.fillRect(bx + 1 + fillW, by + 1, prevW - fillW, bh - 2, bgCol);
2384
+ }
2385
+ node.lastTextWidth = fillW;
2386
+ }
2387
+ drawRangeNode(node, drawY) {
2388
+ const bx = node.box.x;
2389
+ const by = drawY;
2390
+ const bw = node.box.w;
2391
+ const bh = node.box.h;
2392
+ const fgCol = node.fg;
2393
+ const bgCol = node.hasBg ? node.bg : node.clearColor;
2394
+ const dimFg = (fgCol >> 1) & 0x7bef;
2395
+ const trackY = by + Math.trunc(bh / 2);
2396
+ const rangeMin = node.rangeMin;
2397
+ const rangeMax = node.rangeMax;
2398
+ const range = rangeMax > rangeMin ? rangeMax - rangeMin : 100;
2399
+ const value = Math.max(rangeMin, Math.min(rangeMax, node.value)) - rangeMin;
2400
+ const fillW = Math.trunc(((bw - 8) * value) / range);
2401
+ const prevFillW = node.lastTextWidth;
2402
+ let newThumbX = bx + 4 + fillW - 3;
2403
+ if (prevFillW < 0) {
2404
+ this.gfx.drawFastHLine(bx, trackY, bw, dimFg);
2405
+ this.gfx.drawFastHLine(bx + 4, trackY, fillW, fgCol);
2406
+ }
2407
+ else {
2408
+ const prevThumbX = bx + 4 + prevFillW - 3;
2409
+ let left = Math.min(prevThumbX, newThumbX);
2410
+ let right = Math.max(prevThumbX + 6, newThumbX + 6);
2411
+ left = Math.max(left, bx);
2412
+ right = Math.min(right, bx + bw);
2413
+ this.gfx.fillRect(left, trackY - 5, right - left, 10, bgCol);
2414
+ const fillEnd = bx + 4 + fillW;
2415
+ if (right <= fillEnd) {
2416
+ this.gfx.drawFastHLine(left, trackY, right - left, fgCol);
2417
+ }
2418
+ else if (left >= fillEnd) {
2419
+ this.gfx.drawFastHLine(left, trackY, right - left, dimFg);
2420
+ }
2421
+ else {
2422
+ this.gfx.drawFastHLine(left, trackY, fillEnd - left, fgCol);
2423
+ this.gfx.drawFastHLine(fillEnd, trackY, right - fillEnd, dimFg);
2424
+ }
2425
+ }
2426
+ newThumbX = Math.max(bx + 1, Math.min(bx + bw - 7, newThumbX));
2427
+ this.gfx.fillRect(newThumbX, trackY - 5, 6, 10, fgCol);
2428
+ node.lastTextWidth = fillW;
2429
+ }
2430
+ drawInputNode(node, drawY) {
2431
+ const bx = node.box.x;
2432
+ const by = drawY;
2433
+ const bw = node.box.w;
2434
+ const bh = node.box.h;
2435
+ const bgCol = node.hasBg ? node.bg : node.clearColor;
2436
+ const fgCol = node.fg;
2437
+ const border = node.borderColor || fgCol;
2438
+ const borderStyle = node.borderStyle || 1;
2439
+ const borderWidth = node.borderWidth || 1;
2440
+ const displayText = node.textBuffer || node.placeholder || node.text || "";
2441
+ const textColor = node.textBuffer ? fgCol : ((fgCol >> 1) & 0x7bef);
2442
+ const ts = this.nodeTextSize(node);
2443
+ const clippedText = this.clipTextToWidth(displayText, Math.max(0, bw - 8), ts, node.fontFace, node.letterSpacing);
2444
+ if (node.borderRadius > 0)
2445
+ this.gfx.fillRoundRect(bx, by, bw, bh, node.borderRadius, bgCol);
2446
+ else
2447
+ this.gfx.fillRect(bx, by, bw, bh, bgCol);
2448
+ this.drawRectOutline(bx, by, bw, bh, node.borderRadius, borderStyle, borderWidth, border);
2449
+ this.drawText(clippedText, bx + 4, by + Math.trunc((bh - this.textHeight(ts, node.fontFace)) / 2), textColor, bgCol, ts, node.fontAntialias, node.fontFace, node.letterSpacing);
2450
+ }
2451
+ hitTest(tx, ty) {
2452
+ let best = -1;
2453
+ for (let i = 0; i < this.nodes.length; i++) {
2454
+ const node = this.nodes[i];
2455
+ if (!this.isEffectivelyVisible(node))
2456
+ continue;
2457
+ if (!this.isActiveNode(node))
2458
+ continue;
2459
+ const drawX = this.drawXForNode(i);
2460
+ const drawY = this.drawYForNode(i);
2461
+ if (tx >= drawX && tx < drawX + node.box.w && ty >= drawY && ty < drawY + node.box.h) {
2462
+ // The node's box contains the tap. For nodes inside a scroll container,
2463
+ // the tap point (not the whole box) must lie within the visible
2464
+ // viewport: a tall paragraph whose box overflows below the fold can
2465
+ // still have a tappable link segment in its visible portion.
2466
+ const clip = this.scrollClipForNode(i);
2467
+ if (clip && (tx < clip.x || tx >= clip.x + clip.w || ty < clip.y || ty >= clip.y + clip.h))
2468
+ continue;
2469
+ if (this.hasAnyHandler(i) && (best < 0 || this.drawsBefore(this.nodes[best], node)))
2470
+ best = i;
2471
+ }
2472
+ }
2473
+ return best;
2474
+ }
2475
+ // Unified scroll hit-scan: one pass over scrollable nodes. Lists are
2476
+ // scrollable (UA rule) and their contentHeight is seeded on the node by
2477
+ // refreshListState, so this single scan finds the owning container — list or
2478
+ // generic — topmost first.
2479
+ findScrollNode(tx, ty) {
2480
+ let best = -1;
2481
+ for (let i = 0; i < this.nodes.length; i++) {
2482
+ const node = this.nodes[i];
2483
+ if (!this.isActiveNode(node))
2484
+ continue;
2485
+ if (!node.scrollable || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
2486
+ continue;
2487
+ const drawX = this.drawXForNode(i);
2488
+ const drawY = this.drawYForNode(i);
2489
+ if (tx >= drawX && tx < drawX + node.box.w && ty >= drawY && ty < drawY + node.box.h) {
2490
+ if (best < 0 || this.drawsBefore(this.nodes[best], node))
2491
+ best = i;
2492
+ }
2493
+ }
2494
+ return best;
2495
+ }
2496
+ hasAnyHandler(nodeIndex) {
2497
+ const node = this.nodes[nodeIndex];
2498
+ if (node.kind === "list")
2499
+ return true;
2500
+ if (node.kind === "range")
2501
+ return true;
2502
+ if (node.kind === "input")
2503
+ return true;
2504
+ if (node.kind === "button")
2505
+ return true; // pressable for :pressed/transition feedback even with no JS onClick
2506
+ if (node.tag === "check" || node.tag === "select" || node.tag === "radio")
2507
+ return true;
2508
+ return this.callbacks.some((callback) => callback.nodeIndex === nodeIndex);
2509
+ }
2510
+ updateRangeValue(nodeIndex, tx) {
2511
+ const node = this.nodes[nodeIndex];
2512
+ const rangeMin = node.rangeMin;
2513
+ const rangeMax = node.rangeMax;
2514
+ const range = rangeMax > rangeMin ? rangeMax - rangeMin : 100;
2515
+ const usable = Math.max(1, node.box.w - 8);
2516
+ const relX = tx - this.drawXForNode(nodeIndex) - 4;
2517
+ const next = Math.max(rangeMin, Math.min(rangeMax, rangeMin + Math.trunc((relX * range) / usable)));
2518
+ if (next !== node.value) {
2519
+ node.value = next;
2520
+ this.markDirty(nodeIndex);
2521
+ // Mirror the runtime: a <range> value change fires a "rangechange" event
2522
+ // (ui-element-auto-wire.ts records bind:value write-backs under this
2523
+ // kind). Must dispatch synchronously here, BEFORE the next tick's
2524
+ // evaluateBindings read-half clobbers node.value back to the stale signal
2525
+ // value — firing it now lets the write-back update the signal first.
2526
+ this.dispatch("rangechange", nodeIndex);
2527
+ }
2528
+ }
2529
+ // ── Scroll engine: Input layer ────────────────────────────────────────────
2530
+ // Preview is the capacitive tier → passthrough 1:1 (no deadband).
2531
+ smoothDragDelta(dy) {
2532
+ return Math.round(dy * this.scrollDragScale);
2533
+ }
2534
+ scrollMax(nodeIndex) {
2535
+ const node = this.nodes[nodeIndex];
2536
+ if (!node)
2537
+ return 0;
2538
+ return Math.max(0, node.contentHeight - node.box.h);
2539
+ }
2540
+ // Rubber-band excursion for d cumulative pixels dragged past a boundary.
2541
+ overscrollFor(d) {
2542
+ if (d <= 0)
2543
+ return 0;
2544
+ return Math.min(UI_SCROLL_MAX_OVERSCROLL, Math.round((UI_SCROLL_MAX_OVERSCROLL * d) / (d + UI_SCROLL_STIFFNESS)));
2545
+ }
2546
+ // ── Scroll engine: Physics layer ──────────────────────────────────────────
2547
+ // 1:1 in-bounds; rubber-band at edges; scrollY stays in [0, maxScroll] while
2548
+ // overscrollPx tracks the elastic excursion. Mirrors the C++ engine exactly.
2549
+ applyScrollDelta(nodeIndex, dy) {
2550
+ const node = this.nodes[nodeIndex];
2551
+ if (!node)
2552
+ return false;
2553
+ const maxS = this.scrollMax(nodeIndex);
2554
+ const sy = node.scrollY;
2555
+ const nextY = sy - dy;
2556
+ const prevOv = node.overscrollPx;
2557
+ let nextOv = prevOv;
2558
+ if (nextY < 0) {
2559
+ node.scrollY = 0;
2560
+ const draggedPast = dy - sy;
2561
+ const cum = Math.max(0, prevOv + draggedPast);
2562
+ nextOv = this.overscrollFor(cum);
2563
+ }
2564
+ else if (nextY > maxS) {
2565
+ node.scrollY = maxS;
2566
+ const draggedPast = nextY - maxS;
2567
+ const cum = (prevOv < 0 ? -prevOv : 0) + draggedPast;
2568
+ nextOv = -this.overscrollFor(cum);
2569
+ }
2570
+ else {
2571
+ node.scrollY = nextY;
2572
+ nextOv = 0;
2573
+ }
2574
+ node.overscrollPx = nextOv;
2575
+ const changed = node.scrollY !== sy || nextOv !== prevOv;
2576
+ if (changed)
2577
+ this.markScrollDescendantsDirty(nodeIndex);
2578
+ return changed;
2579
+ }
2580
+ // On release: arm a bounded settle — bounce overscroll back to 0, or edge-snap
2581
+ // scrollY within edgeSnapPx. The animation runs in advanceScrollSettle (tick).
2582
+ releaseScroll(nodeIndex) {
2583
+ const node = this.nodes[nodeIndex];
2584
+ if (!node)
2585
+ return false;
2586
+ if (node.overscrollPx !== 0) {
2587
+ node.settling = true;
2588
+ this.settleFromOverscroll = node.overscrollPx;
2589
+ this.settleFromScrollY = 0;
2590
+ this.settleStartMs = Date.now();
2591
+ return true;
2592
+ }
2593
+ const sy = node.scrollY;
2594
+ const maxS = this.scrollMax(nodeIndex);
2595
+ if (sy > 0 && sy <= UI_SCROLL_EDGE_SNAP_PX) {
2596
+ node.settling = true;
2597
+ this.settleFromScrollY = sy;
2598
+ this.settleFromOverscroll = 0;
2599
+ this.settleStartMs = Date.now();
2600
+ return true;
2601
+ }
2602
+ if (maxS > 0 && sy < maxS && sy >= maxS - UI_SCROLL_EDGE_SNAP_PX) {
2603
+ node.settling = true;
2604
+ this.settleFromScrollY = sy - maxS;
2605
+ this.settleFromOverscroll = 0;
2606
+ this.settleStartMs = Date.now();
2607
+ return true;
2608
+ }
2609
+ return false;
2610
+ }
2611
+ // Advance the settle animation for one node (ease-out, bounded ~180ms).
2612
+ advanceScrollSettle(nodeIndex) {
2613
+ const node = this.nodes[nodeIndex];
2614
+ if (!node || !node.settling)
2615
+ return;
2616
+ const elapsed = Date.now() - this.settleStartMs;
2617
+ const t = elapsed >= UI_SCROLL_SETTLE_MS ? 1 : elapsed / UI_SCROLL_SETTLE_MS;
2618
+ const k = 1 - (1 - t) * (1 - t); // ease-out
2619
+ if (node.overscrollPx !== 0) {
2620
+ const from = this.settleFromOverscroll;
2621
+ node.overscrollPx = Math.round(from - from * k);
2622
+ if (t >= 1)
2623
+ node.overscrollPx = 0;
2624
+ }
2625
+ else if (this.settleFromScrollY !== 0) {
2626
+ const from = this.settleFromScrollY;
2627
+ const maxS = this.scrollMax(nodeIndex);
2628
+ if (from > 0) {
2629
+ node.scrollY = Math.round(from - from * k);
2630
+ if (t >= 1)
2631
+ node.scrollY = 0;
2632
+ }
2633
+ else {
2634
+ const target = maxS;
2635
+ node.scrollY = Math.round(target + (from * (1 - k)));
2636
+ if (t >= 1)
2637
+ node.scrollY = target;
2638
+ }
2639
+ }
2640
+ if (t >= 1)
2641
+ node.settling = false;
2642
+ this.markScrollDescendantsDirty(nodeIndex);
2643
+ }
2644
+ dispatchListTap(nodeIndex, tx, ty) {
2645
+ const node = this.nodes[nodeIndex];
2646
+ const list = this.listStateForNode(nodeIndex);
2647
+ if (!node || !list || !list.tapBody)
2648
+ return;
2649
+ const drawY = this.drawYForNode(nodeIndex);
2650
+ const row = Math.trunc((ty - drawY + node.scrollY) / Math.max(1, list.itemHeight));
2651
+ if (row < 0 || row >= list.itemCount)
2652
+ return;
2653
+ this.runBody(list.tapBody, this.listLocal(list.tapParam, row));
2654
+ }
2655
+ handleTouch(tx, ty) {
2656
+ const now = Date.now();
2657
+ this.lastTouchX = tx;
2658
+ this.lastTouchY = ty;
2659
+ if (this.keyboardVisible) {
2660
+ if (this.touchState === 0) {
2661
+ if (now - this.lastTouchTime < UI_TOUCH_DEBOUNCE_MS)
2662
+ return;
2663
+ this.touchState = 1;
2664
+ this.touchDownTime = now;
2665
+ this.keyboardHandleTouch(tx, ty);
2666
+ }
2667
+ this.lastTouchTime = now;
2668
+ return;
2669
+ }
2670
+ if (this.touchState === 0) {
2671
+ if (now - this.lastTouchTime < UI_TOUCH_DEBOUNCE_MS)
2672
+ return;
2673
+ const node = this.hitTest(tx, ty);
2674
+ this.touchNode = node;
2675
+ this.touchState = 1;
2676
+ this.touchDownTime = now;
2677
+ this.dragStartX = tx;
2678
+ this.dragStartY = ty;
2679
+ this.isDragging = false;
2680
+ // Unified scroll owner: one hit-scan covers containers and lists.
2681
+ this.scrollNode = this.findScrollNode(tx, ty);
2682
+ this.rangeNode = node >= 0 && this.nodes[node].kind === "range" ? node : -1;
2683
+ if (this.rangeNode >= 0) {
2684
+ // Range owns this gesture. Do not let vertical touch jitter also scroll
2685
+ // the containing view, which would dirty and repaint the whole viewport.
2686
+ this.scrollNode = -1;
2687
+ }
2688
+ if (node >= 0) {
2689
+ let handledTouchTarget = false;
2690
+ if (this.nodes[node].kind === "button") {
2691
+ this.setPressed(node, true);
2692
+ handledTouchTarget = true;
2693
+ }
2694
+ if (this.nodes[node].kind === "range") {
2695
+ this.updateRangeValue(node, tx);
2696
+ handledTouchTarget = true;
2697
+ }
2698
+ const touchStartsScrollableView = this.scrollNode >= 0 && node === this.scrollNode;
2699
+ if (!handledTouchTarget && !touchStartsScrollableView)
2700
+ this.markDirty(node);
2701
+ }
2702
+ }
2703
+ else {
2704
+ if (this.rangeNode < 0 && !this.isDragging && this.scrollNode >= 0 && Math.abs(ty - this.dragStartY) >= UI_DRAG_THRESHOLD) {
2705
+ this.isDragging = true;
2706
+ }
2707
+ // Unified scroll drag: immediate-apply each frame via the physics layer
2708
+ // (1:1 in-bounds, rubber-band at edges). No accumulator, no cadence gate.
2709
+ if (this.rangeNode < 0 && this.isDragging && this.scrollNode >= 0) {
2710
+ const rawDy = ty - this.dragStartY;
2711
+ if (rawDy !== 0) {
2712
+ const dy = this.smoothDragDelta(rawDy);
2713
+ if (dy !== 0) {
2714
+ this.applyScrollDelta(this.scrollNode, dy);
2715
+ this.dragStartX = tx;
2716
+ this.dragStartY = ty;
2717
+ }
2718
+ }
2719
+ }
2720
+ else if (this.rangeNode >= 0 && Math.abs(tx - this.dragStartX) >= UI_DRAG_THRESHOLD) {
2721
+ this.updateRangeValue(this.rangeNode, tx);
2722
+ }
2723
+ else if (this.touchState === 1 && this.touchNode >= 0 && now - this.touchDownTime >= UI_TOUCH_HOLD_MS) {
2724
+ this.touchState = 2;
2725
+ this.dispatch("hold", this.touchNode);
2726
+ }
2727
+ }
2728
+ this.lastTouchTime = now;
2729
+ }
2730
+ handleNoTouch() {
2731
+ if (this.touchState === 0)
2732
+ return;
2733
+ const now = Date.now();
2734
+ if (now - this.lastReleaseTime < UI_TOUCH_DEBOUNCE_MS)
2735
+ return;
2736
+ if (this.keyboardVisible) {
2737
+ this.keyboardHandleTap();
2738
+ this.touchState = 0;
2739
+ this.touchNode = -1;
2740
+ this.isDragging = false;
2741
+ this.scrollNode = -1;
2742
+ this.rangeNode = -1;
2743
+ this.lastReleaseTime = now;
2744
+ return;
2745
+ }
2746
+ const elapsed = now - this.touchDownTime;
2747
+ const node = this.touchNode;
2748
+ // Release the scroll owner: arm a bounded settle (bounce-back / edge-snap).
2749
+ // No fling — motion ends with the finger; the settle is the only post-lift
2750
+ // motion and terminates within UI_SCROLL_SETTLE_MS.
2751
+ if (this.scrollNode >= 0) {
2752
+ this.releaseScroll(this.scrollNode);
2753
+ }
2754
+ if (node >= 0 && !this.isDragging) {
2755
+ if (elapsed < UI_TOUCH_HOLD_MS) {
2756
+ if (this.nodes[node].kind === "list")
2757
+ this.dispatchListTap(node, this.lastTouchX, this.lastTouchY);
2758
+ this.dispatchBuiltInClick(node);
2759
+ this.dispatch("click", node);
2760
+ // Rich-text inline link: if the tapped node has link runs, navigate to
2761
+ // the target screen of the link segment the tap landed on.
2762
+ const richTarget = this.richLinkHit(node, this.lastTouchX, this.lastTouchY);
2763
+ if (richTarget >= 0)
2764
+ this.navigate(richTarget);
2765
+ }
2766
+ this.dispatch("release", node);
2767
+ if (this.nodes[node].kind === "button")
2768
+ this.setPressed(node, false);
2769
+ this.markDirty(node);
2770
+ }
2771
+ // Resume any `await ui.onTap()` awaiter. Runs for EVERY completed tap —
2772
+ // including empty-space taps (node == -1) and holds released above — so
2773
+ // "wake on any touch" works. After the click/release dispatch so onClick
2774
+ // fires first (matches the device runtime, "both fire").
2775
+ this.tapSeq++;
2776
+ this.tapNode = node;
2777
+ this.touchState = 0;
2778
+ this.touchNode = -1;
2779
+ this.isDragging = false;
2780
+ this.scrollNode = -1;
2781
+ this.rangeNode = -1;
2782
+ this.lastReleaseTime = now;
2783
+ }
2784
+ dispatchBuiltInClick(nodeIndex) {
2785
+ const node = this.nodes[nodeIndex];
2786
+ if (node.tag === "check") {
2787
+ node.value = node.value > 0 ? 0 : 1;
2788
+ this.markDirty(nodeIndex);
2789
+ }
2790
+ else if (node.tag === "select") {
2791
+ const count = Math.max(node.options?.length ?? 0, 2);
2792
+ node.value = (node.value + 1) % count;
2793
+ this.markDirty(nodeIndex);
2794
+ }
2795
+ else if (node.tag === "radio") {
2796
+ for (const candidate of this.nodes) {
2797
+ if (!this.isActiveNode(candidate))
2798
+ continue;
2799
+ if (candidate.tag === "radio" && candidate.name && candidate.name === node.name) {
2800
+ candidate.value = 0;
2801
+ this.markDirty(candidate.index);
2802
+ }
2803
+ }
2804
+ node.value = 1;
2805
+ this.markDirty(nodeIndex);
2806
+ }
2807
+ else if (node.kind === "input") {
2808
+ this.keyboardOpen(nodeIndex);
2809
+ }
2810
+ }
2811
+ keyboardTemplateForNode(node) {
2812
+ if (node.keyboard) {
2813
+ const custom = this.snapshot.keyboardTemplates?.find((template) => template.id === node.keyboard);
2814
+ if (custom)
2815
+ return custom;
2816
+ }
2817
+ return node.inputType === "number" ? DEFAULT_NUMBER_KEYBOARD : DEFAULT_ALPHA_KEYBOARD;
2818
+ }
2819
+ keyboardOpen(nodeIndex) {
2820
+ const node = this.nodes[nodeIndex];
2821
+ const template = this.keyboardTemplateForNode(node);
2822
+ const cols = template.rows.length > 0 ? Math.max(...template.rows.map((row) => row.length)) : 0;
2823
+ this.keyboardTarget = nodeIndex;
2824
+ this.keyboardBuffer = clampText(node.textBuffer);
2825
+ this.keyboardMaxLen = Math.max(1, Math.min(node.maxlen || UI_TEXT_BUF, UI_TEXT_BUF));
2826
+ this.keyboardShift = false;
2827
+ this.keyboardBackspaceHeld = false;
2828
+ this.keyboardPressedKey = -1;
2829
+ this.keyboardRepaintKey = -1;
2830
+ this.keyboardRows = template.rows.length;
2831
+ this.keyboardCols = Math.max(1, cols);
2832
+ this.keyboardBg = resolveKeyboardBackground(template, this.snapshot.cssRules ?? []);
2833
+ this.keyboardKeys = [];
2834
+ for (const row of template.rows) {
2835
+ for (const key of row) {
2836
+ this.keyboardKeys.push({
2837
+ ch: key.ch,
2838
+ special: key.special,
2839
+ style: resolveKeyStyle(key, template, this.snapshot.cssRules ?? []),
2840
+ });
2841
+ }
2842
+ for (let p = row.length; p < this.keyboardCols; p++) {
2843
+ this.keyboardKeys.push({
2844
+ ch: " ",
2845
+ special: 255,
2846
+ style: { bg: DEFAULT_KEY_BG, fg: DEFAULT_KEY_FG, borderColor: DEFAULT_KEY_BORDER },
2847
+ });
2848
+ }
2849
+ }
2850
+ this.keyboardComputeBox();
2851
+ this.keyboardVisible = true;
2852
+ this.keyboardDirty = 1;
2853
+ }
2854
+ keyboardClose() {
2855
+ if (this.keyboardTarget >= 0) {
2856
+ const node = this.nodes[this.keyboardTarget];
2857
+ node.textBuffer = clampText(this.keyboardBuffer).slice(0, this.keyboardMaxLen);
2858
+ this.markDirty(this.keyboardTarget);
2859
+ // Pass the committed text as a `text` local so ui.bindInput callbacks
2860
+ // (which declare it as their param) see the same value the device passes
2861
+ // to the lowered C++ callback.
2862
+ this.dispatch("change", this.keyboardTarget, { text: node.textBuffer });
2863
+ }
2864
+ // Capture the keyboard box before clearing visibility — it identifies the
2865
+ // screen region the opaque overlay covered and that now needs restoring.
2866
+ const kbBox = this.keyboardBox;
2867
+ this.keyboardVisible = false;
2868
+ this.keyboardDirty = 0;
2869
+ this.keyboardTarget = -1;
2870
+ this.keyboardPressedKey = -1;
2871
+ this.keyboardBackspaceHeld = false;
2872
+ // The <screen> root's paint rect spans the whole display, so it always
2873
+ // intersects kbBox. Routing it through markDirty would cascade via
2874
+ // markOverlappingHigherLayersDirty into marking every node on the active
2875
+ // screen dirty (its rect overlaps everything), which is the full-screen
2876
+ // flash this function exists to avoid. Repaint just the keyboard-box
2877
+ // slice of its background directly instead.
2878
+ const screenRoot = this.nodes.find((n) => n.parentIndex < 0 && n.screenId === this.activeScreen);
2879
+ if (screenRoot) {
2880
+ const rootBg = screenRoot.hasBg ? screenRoot.bg : screenRoot.clearColor;
2881
+ this.gfx.fillRect(kbBox.x, kbBox.y, kbBox.w, kbBox.h, rootBg);
2882
+ }
2883
+ // Repaint everything else the keyboard overlay actually overwrote: nodes
2884
+ // whose paint rect intersects the keyboard box, plus the edited input
2885
+ // itself (already marked above). markDirty handles overlap repair +
2886
+ // scroll clipping — safe here since these nodes are bounded in size,
2887
+ // unlike the screen root.
2888
+ for (const node of this.nodes) {
2889
+ if (screenRoot && node.index === screenRoot.index)
2890
+ continue;
2891
+ const rect = this.currentPaintRect(node);
2892
+ if (rect.w > 0 && rect.h > 0 && this.rectsIntersect(rect, kbBox)) {
2893
+ this.markDirty(node.index);
2894
+ }
2895
+ }
2896
+ }
2897
+ keyboardComputeBox() {
2898
+ const isNumber = this.keyboardCols <= 4;
2899
+ const h = Math.trunc(this.snapshot.program.height * (isNumber ? 60 : 75) / 100);
2900
+ const w = isNumber ? Math.trunc(this.snapshot.program.width * 50 / 100) : this.snapshot.program.width;
2901
+ this.keyboardBox = {
2902
+ x: isNumber ? Math.trunc((this.snapshot.program.width - w) / 2) : 0,
2903
+ y: this.snapshot.program.height - h,
2904
+ w,
2905
+ h,
2906
+ };
2907
+ }
2908
+ keyboardKeyRect(index) {
2909
+ const col = index % this.keyboardCols;
2910
+ const row = Math.trunc(index / this.keyboardCols);
2911
+ const keysH = Math.max(1, this.keyboardBox.h - UI_KB_TEXT_H);
2912
+ return {
2913
+ x: this.keyboardBox.x + Math.trunc((col * this.keyboardBox.w) / this.keyboardCols),
2914
+ y: this.keyboardBox.y + UI_KB_TEXT_H + Math.trunc((row * keysH) / Math.max(1, this.keyboardRows)),
2915
+ w: Math.trunc(this.keyboardBox.w / this.keyboardCols),
2916
+ h: Math.trunc(keysH / Math.max(1, this.keyboardRows)),
2917
+ };
2918
+ }
2919
+ keyboardHandleTouch(tx, ty) {
2920
+ this.keyboardPressedKey = -1;
2921
+ for (let i = 0; i < this.keyboardKeys.length; i++) {
2922
+ const key = this.keyboardKeys[i];
2923
+ if (key.special === 255)
2924
+ continue;
2925
+ const rect = this.keyboardKeyRect(i);
2926
+ if (tx < rect.x || tx >= rect.x + rect.w || ty < rect.y || ty >= rect.y + rect.h)
2927
+ continue;
2928
+ this.keyboardPressedKey = i;
2929
+ this.keyboardRepaintKey = i;
2930
+ this.keyboardDirty = 2;
2931
+ if (key.special === 2) {
2932
+ this.keyboardBackspaceHeld = true;
2933
+ this.keyboardBackspaceRepeat = Date.now();
2934
+ this.keyboardDelete();
2935
+ }
2936
+ return;
2937
+ }
2938
+ }
2939
+ keyboardHandleTap() {
2940
+ const keyIndex = this.keyboardPressedKey;
2941
+ this.keyboardBackspaceHeld = false;
2942
+ if (keyIndex < 0)
2943
+ return;
2944
+ const key = this.keyboardKeys[keyIndex];
2945
+ this.keyboardPressedKey = -1;
2946
+ switch (key.special) {
2947
+ case 0: {
2948
+ const wasShift = this.keyboardShift;
2949
+ let ch = key.ch.slice(0, 1);
2950
+ if (this.keyboardShift && ch >= "a" && ch <= "z")
2951
+ ch = ch.toUpperCase();
2952
+ this.keyboardInsert(ch);
2953
+ this.keyboardShift = false;
2954
+ this.keyboardRepaintKey = wasShift ? -1 : keyIndex;
2955
+ this.keyboardDirty = wasShift ? 1 : 2;
2956
+ break;
2957
+ }
2958
+ case 1:
2959
+ this.keyboardShift = !this.keyboardShift;
2960
+ this.keyboardDirty = 1;
2961
+ break;
2962
+ case 2:
2963
+ this.keyboardRepaintKey = keyIndex;
2964
+ this.keyboardDirty = 2;
2965
+ break;
2966
+ case 3:
2967
+ this.keyboardClose();
2968
+ break;
2969
+ case 4:
2970
+ this.keyboardSwapPage();
2971
+ break;
2972
+ }
2973
+ }
2974
+ keyboardInsert(ch) {
2975
+ if (!ch || this.keyboardBuffer.length >= this.keyboardMaxLen)
2976
+ return;
2977
+ this.keyboardBuffer = clampText(this.keyboardBuffer + ch).slice(0, this.keyboardMaxLen);
2978
+ }
2979
+ keyboardDelete() {
2980
+ if (this.keyboardBuffer.length === 0)
2981
+ return;
2982
+ this.keyboardBuffer = this.keyboardBuffer.slice(0, -1);
2983
+ }
2984
+ keyboardTick(now) {
2985
+ if (!this.keyboardBackspaceHeld)
2986
+ return;
2987
+ if (now - this.keyboardBackspaceRepeat < UI_KB_REPEAT_MS)
2988
+ return;
2989
+ this.keyboardDelete();
2990
+ this.keyboardBackspaceRepeat = now;
2991
+ this.keyboardRepaintKey = this.keyboardPressedKey;
2992
+ this.keyboardDirty = 2;
2993
+ }
2994
+ keyboardSwapPage() {
2995
+ const previousTarget = this.keyboardTarget;
2996
+ const template = this.keyboardCols <= 4 ? DEFAULT_ALPHA_KEYBOARD : DEFAULT_NUMBER_KEYBOARD;
2997
+ const cols = Math.max(...template.rows.map((row) => row.length));
2998
+ this.keyboardRows = template.rows.length;
2999
+ this.keyboardCols = Math.max(1, cols);
3000
+ this.keyboardBg = resolveKeyboardBackground(template, this.snapshot.cssRules ?? []);
3001
+ this.keyboardKeys = [];
3002
+ for (const row of template.rows) {
3003
+ for (const key of row) {
3004
+ this.keyboardKeys.push({
3005
+ ch: key.ch,
3006
+ special: key.special,
3007
+ style: resolveKeyStyle(key, template, this.snapshot.cssRules ?? []),
3008
+ });
3009
+ }
3010
+ for (let p = row.length; p < this.keyboardCols; p++) {
3011
+ this.keyboardKeys.push({
3012
+ ch: " ",
3013
+ special: 255,
3014
+ style: { bg: DEFAULT_KEY_BG, fg: DEFAULT_KEY_FG, borderColor: DEFAULT_KEY_BORDER },
3015
+ });
3016
+ }
3017
+ }
3018
+ this.keyboardTarget = previousTarget;
3019
+ this.keyboardComputeBox();
3020
+ this.keyboardDirty = 1;
3021
+ }
3022
+ keyboardLabel(key) {
3023
+ switch (key.special) {
3024
+ case 1: return "SHIFT";
3025
+ case 2: return "DEL";
3026
+ case 3: return "OK";
3027
+ case 4: return this.keyboardCols <= 4 ? "ABC" : "123";
3028
+ default: {
3029
+ const ch = key.ch.slice(0, 1);
3030
+ return this.keyboardShift && ch >= "a" && ch <= "z" ? ch.toUpperCase() : ch;
3031
+ }
3032
+ }
3033
+ }
3034
+ drawKeyboardTextRow() {
3035
+ this.gfx.fillRect(this.keyboardBox.x, this.keyboardBox.y, this.keyboardBox.w, UI_KB_TEXT_H, this.keyboardBg);
3036
+ this.gfx.setCursor(this.keyboardBox.x + 4, this.keyboardBox.y + 4);
3037
+ this.gfx.setTextColor(0xffff, this.keyboardBg);
3038
+ this.gfx.setTextSize(2);
3039
+ this.gfx.print(this.keyboardBuffer);
3040
+ this.gfx.print("_");
3041
+ }
3042
+ drawKeyboardKey(index) {
3043
+ const key = this.keyboardKeys[index];
3044
+ if (!key || key.special === 255)
3045
+ return;
3046
+ const rect = this.keyboardKeyRect(index);
3047
+ let bg = key.style.bg;
3048
+ let fg = key.style.fg;
3049
+ if (key.special === 1 && this.keyboardShift && index !== this.keyboardPressedKey)
3050
+ bg = 0xbdf7;
3051
+ if (index === this.keyboardPressedKey) {
3052
+ const previousBg = bg;
3053
+ bg = fg;
3054
+ fg = previousBg;
3055
+ }
3056
+ this.gfx.fillRect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2), bg);
3057
+ this.gfx.drawRect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2), key.style.borderColor);
3058
+ const label = this.keyboardLabel(key);
3059
+ this.gfx.setCursor(rect.x + Math.max(2, Math.trunc((rect.w - label.length * 6) / 2)), rect.y + Math.trunc(rect.h / 2) - 4);
3060
+ this.gfx.setTextColor(fg, bg);
3061
+ this.gfx.setTextSize(1);
3062
+ this.gfx.print(label);
3063
+ }
3064
+ drawKeyboard() {
3065
+ this.gfx.fillRect(this.keyboardBox.x, this.keyboardBox.y, this.keyboardBox.w, this.keyboardBox.h, this.keyboardBg);
3066
+ this.drawKeyboardTextRow();
3067
+ for (let i = 0; i < this.keyboardKeys.length; i++) {
3068
+ this.drawKeyboardKey(i);
3069
+ }
3070
+ }
3071
+ dispatch(kind, nodeIndex, locals) {
3072
+ for (const callback of this.callbacks) {
3073
+ if (callback.nodeIndex === nodeIndex && callback.kind === kind) {
3074
+ // A ui.bindInput callback declares a param that receives the input's
3075
+ // committed text (mirrors the runtime renaming the arrow's first param
3076
+ // to `text`). Bind it as a local under the author's chosen name so the
3077
+ // body references resolve.
3078
+ let merged = locals;
3079
+ if (callback.param && locals?.text !== undefined) {
3080
+ merged = { ...locals, [callback.param]: locals.text };
3081
+ }
3082
+ this.runBody(callback.body, merged);
3083
+ }
3084
+ }
3085
+ }
3086
+ setPressed(nodeIndex, pressed) {
3087
+ const node = this.nodes[nodeIndex];
3088
+ node.value = pressed ? 1 : 0;
3089
+ this.markDirty(nodeIndex);
3090
+ for (const transition of this.transitions) {
3091
+ if (transition.node !== nodeIndex)
3092
+ continue;
3093
+ transition.prevValue = transition.prop === "color" ? node.fg : node.bg;
3094
+ transition.targetValue = pressed ? transition.pressedTarget : transition.baseTarget;
3095
+ transition.elapsed = 0;
3096
+ transition.active = true;
3097
+ }
3098
+ }
3099
+ uiOnPress(nodeIndex) {
3100
+ this.setPressed(nodeIndex, true);
3101
+ }
3102
+ uiOnRelease(nodeIndex) {
3103
+ this.setPressed(nodeIndex, false);
3104
+ }
3105
+ markDirty(nodeIndex) {
3106
+ if (!this.nodes[nodeIndex])
3107
+ return;
3108
+ this.nodes[nodeIndex].dirty = true;
3109
+ this.markOverlappingHigherLayersDirty(nodeIndex);
3110
+ }
3111
+ scriptTreeNames() {
3112
+ const validIdentifier = /^[$A-Z_a-z][$\w]*$/;
3113
+ const seen = new Set();
3114
+ const names = [];
3115
+ for (const name of this.snapshot.uiTreeNames ?? []) {
3116
+ if (!validIdentifier.test(name) || name === "screen" || name === "ui" || seen.has(name))
3117
+ continue;
3118
+ seen.add(name);
3119
+ names.push(name);
3120
+ }
3121
+ return names;
3122
+ }
3123
+ normalizeScript(text) {
3124
+ let out = text
3125
+ .replace(/\(([$A-Z_a-z][$\w]*)\s+as\s+any\)/g, "$1")
3126
+ .replace(/\b([$A-Z_a-z][$\w]*)\s+as\s+any\b/g, "$1")
3127
+ .replace(/\b([$A-Z_a-z][$\w]*)\s+as\s+const\b/g, "$1");
3128
+ // Signals lower to plain device variables on hardware, so the author-facing
3129
+ // getter/setter syntax must be rewritten to plain reads/writes here too
3130
+ // (mirrors ir/transformers/ui-callback-lowering.ts):
3131
+ // sig.set(EXPR) → (sig = EXPR) (write — balanced-paren scan preserves
3132
+ // nested parens/calls in EXPR)
3133
+ // sig() → sig (read)
3134
+ // Run this before the module-var rewrite below so the bare `sig` it leaves
3135
+ // behind is then turned into `moduleScope.sig`, giving the same slot the
3136
+ // interpolation/binding path reads.
3137
+ out = this.rewriteSignalAccesses(out);
3138
+ // Rewrite bare references to module-scoped variables into moduleScope.NAME
3139
+ // so reads/writes hit the shared mutable binding (mirrors the device hoisting
3140
+ // them to globals). Skip property accesses (foo.bar) so `screen.gauge.value`
3141
+ // etc. are untouched. Identifiers are the exact, finite set of module vars.
3142
+ const names = this.moduleVarNames();
3143
+ if (names.length) {
3144
+ const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
3145
+ const re = new RegExp(`(?<![\\w.$])\\b(${alt})\\b(?!\\s*:)`, "g");
3146
+ out = out.replace(re, "moduleScope.$1");
3147
+ }
3148
+ return out;
3149
+ }
3150
+ /** Rewrite signal getter/setter syntax to plain variable reads/writes. See
3151
+ * normalizeScript for the lowering contract; this is the preview counterpart
3152
+ * of ui-callback-lowering.ts's rules 2 and 3. */
3153
+ rewriteSignalAccesses(text) {
3154
+ if (this.signalNames.size === 0)
3155
+ return text;
3156
+ let out = text;
3157
+ for (const name of this.signalNames) {
3158
+ // sig.set(EXPR) → (sig = EXPR). Scan for `name.set(` then capture the
3159
+ // balanced paren region so nested calls (e.g. `count.set(count() + 1)`)
3160
+ // are preserved intact. Replace in place; loop because a body may have
3161
+ // multiple writes to the same signal.
3162
+ const setMarker = `${name}.set(`;
3163
+ let searchFrom = 0;
3164
+ for (;;) {
3165
+ const idx = out.indexOf(setMarker, searchFrom);
3166
+ if (idx < 0)
3167
+ break;
3168
+ // Only rewrite when `name` is a standalone token (not `foo.name.set(`).
3169
+ const prev = out[idx - 1];
3170
+ if (prev && /[\w$]/.test(prev)) {
3171
+ searchFrom = idx + setMarker.length;
3172
+ continue;
3173
+ }
3174
+ const argStart = idx + setMarker.length;
3175
+ const argEnd = findMatchingCloseParen(out, argStart);
3176
+ if (argEnd < 0)
3177
+ break;
3178
+ const argText = out.slice(argStart, argEnd);
3179
+ const replacement = `(${name} = ${argText})`;
3180
+ out = out.slice(0, idx) + replacement + out.slice(argEnd + 1);
3181
+ // Restart scanning from the replacement; indices shifted.
3182
+ searchFrom = idx + replacement.length;
3183
+ }
3184
+ // sig() → sig. Match `name(` only when name is a standalone token; the
3185
+ // empty arg list mirrors the runtime read lowering.
3186
+ out = out.replace(new RegExp(`(?<![\\w.$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\(\\)`, "g"), name);
3187
+ }
3188
+ return out;
3189
+ }
3190
+ scriptValues(aliases, locals = {}) {
3191
+ return [this.screen, this.createUiFacade(), this.moduleScope, ...aliases.map(() => this.screen), ...Object.values(locals)];
3192
+ }
3193
+ listLocal(param, row) {
3194
+ return param ? { [param]: row } : {};
3195
+ }
3196
+ evaluateExpression(expression, locals = {}) {
3197
+ if (!expression)
3198
+ return undefined;
3199
+ const aliases = this.scriptTreeNames();
3200
+ const localNames = Object.keys(locals).filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name));
3201
+ const localValues = Object.fromEntries(localNames.map((name) => [name, locals[name]]));
3202
+ const normalized = this.normalizeScript(expression);
3203
+ try {
3204
+ return Function("screen", "ui", "moduleScope", ...aliases, ...localNames, `"use strict"; return (${normalized});`)(...this.scriptValues(aliases, localValues));
3205
+ }
3206
+ catch (error) {
3207
+ this.onDiagnostics?.(`Preview expression failed: ${expression} (${error instanceof Error ? error.message : String(error)})`);
3208
+ return undefined;
3209
+ }
3210
+ }
3211
+ runBody(body, locals = {}) {
3212
+ if (!body)
3213
+ return;
3214
+ const aliases = this.scriptTreeNames();
3215
+ const localNames = Object.keys(locals).filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name));
3216
+ const localValues = Object.fromEntries(localNames.map((name) => [name, locals[name]]));
3217
+ const normalized = this.normalizeScript(body);
3218
+ try {
3219
+ Function("screen", "ui", "moduleScope", ...aliases, ...localNames, `"use strict"; ${normalized}`)(...this.scriptValues(aliases, localValues));
3220
+ }
3221
+ catch (error) {
3222
+ this.onDiagnostics?.(`Preview callback failed: ${body} (${error instanceof Error ? error.message : String(error)})`);
3223
+ }
3224
+ }
3225
+ createUiFacade() {
3226
+ return {
3227
+ signal(initial) {
3228
+ let value = initial;
3229
+ const fn = (() => value);
3230
+ fn.set = (next) => { value = next; };
3231
+ return fn;
3232
+ },
3233
+ navigate: (screenIdx) => this.navigate(screenIdx),
3234
+ };
3235
+ }
3236
+ }