@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.
- package/LICENSE +21 -0
- package/README.md +1443 -0
- package/dist/engine-index.d.ts +7 -0
- package/dist/engine-index.js +41 -0
- package/dist/index.d.ts +165 -0
- package/dist/index.js +42 -0
- package/dist/preview/build-program.d.ts +7 -0
- package/dist/preview/build-program.js +665 -0
- package/dist/preview/host-gfx.d.ts +90 -0
- package/dist/preview/host-gfx.js +552 -0
- package/dist/preview/host-ui-runtime.d.ts +241 -0
- package/dist/preview/host-ui-runtime.js +3236 -0
- package/dist/preview/types.d.ts +83 -0
- package/dist/preview/types.js +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.js +10 -0
- package/dist/ui-engine/block-layout.d.ts +8 -0
- package/dist/ui-engine/block-layout.js +95 -0
- package/dist/ui-engine/color.d.ts +49 -0
- package/dist/ui-engine/color.js +273 -0
- package/dist/ui-engine/css-parser.d.ts +155 -0
- package/dist/ui-engine/css-parser.js +1058 -0
- package/dist/ui-engine/default-keyboards.d.ts +3 -0
- package/dist/ui-engine/default-keyboards.js +53 -0
- package/dist/ui-engine/easing.d.ts +12 -0
- package/dist/ui-engine/easing.js +76 -0
- package/dist/ui-engine/font-assets.d.ts +50 -0
- package/dist/ui-engine/font-assets.js +431 -0
- package/dist/ui-engine/html-parser.d.ts +116 -0
- package/dist/ui-engine/html-parser.js +284 -0
- package/dist/ui-engine/image-assets.d.ts +21 -0
- package/dist/ui-engine/image-assets.js +125 -0
- package/dist/ui-engine/inline-parser.d.ts +30 -0
- package/dist/ui-engine/inline-parser.js +66 -0
- package/dist/ui-engine/keyframes.d.ts +3 -0
- package/dist/ui-engine/keyframes.js +39 -0
- package/dist/ui-engine/layout-engine.d.ts +35 -0
- package/dist/ui-engine/layout-engine.js +203 -0
- package/dist/ui-engine/model.d.ts +239 -0
- package/dist/ui-engine/model.js +987 -0
- package/dist/ui-engine/rich-layout.d.ts +51 -0
- package/dist/ui-engine/rich-layout.js +220 -0
- package/dist/ui-engine/run-types.d.ts +13 -0
- package/dist/ui-engine/run-types.js +1 -0
- package/dist/ui-engine/runtime-header/antialiasing.d.ts +1 -0
- package/dist/ui-engine/runtime-header/antialiasing.js +141 -0
- package/dist/ui-engine/runtime-header/blend-bodies.d.ts +1 -0
- package/dist/ui-engine/runtime-header/blend-bodies.js +39 -0
- package/dist/ui-engine/runtime-header/canvas-helpers.d.ts +1 -0
- package/dist/ui-engine/runtime-header/canvas-helpers.js +155 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.d.ts +1 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.js +105 -0
- package/dist/ui-engine/runtime-header/color-mono-refresh.d.ts +1 -0
- package/dist/ui-engine/runtime-header/color-mono-refresh.js +154 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.d.ts +1 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +169 -0
- package/dist/ui-engine/runtime-header/display-shim.d.ts +1 -0
- package/dist/ui-engine/runtime-header/display-shim.js +104 -0
- package/dist/ui-engine/runtime-header/forward-decls.d.ts +1 -0
- package/dist/ui-engine/runtime-header/forward-decls.js +137 -0
- package/dist/ui-engine/runtime-header/guard-close.d.ts +1 -0
- package/dist/ui-engine/runtime-header/guard-close.js +8 -0
- package/dist/ui-engine/runtime-header/image-drawing.d.ts +1 -0
- package/dist/ui-engine/runtime-header/image-drawing.js +300 -0
- package/dist/ui-engine/runtime-header/init-press-input.d.ts +1 -0
- package/dist/ui-engine/runtime-header/init-press-input.js +126 -0
- package/dist/ui-engine/runtime-header/keyboard.d.ts +1 -0
- package/dist/ui-engine/runtime-header/keyboard.js +342 -0
- package/dist/ui-engine/runtime-header/node-decoration.d.ts +1 -0
- package/dist/ui-engine/runtime-header/node-decoration.js +212 -0
- package/dist/ui-engine/runtime-header/paint-order-coords.d.ts +1 -0
- package/dist/ui-engine/runtime-header/paint-order-coords.js +190 -0
- package/dist/ui-engine/runtime-header/paint-rects-repair.d.ts +1 -0
- package/dist/ui-engine/runtime-header/paint-rects-repair.js +553 -0
- package/dist/ui-engine/runtime-header/scroll-physics.d.ts +1 -0
- package/dist/ui-engine/runtime-header/scroll-physics.js +161 -0
- package/dist/ui-engine/runtime-header/state-bindings-nav.d.ts +1 -0
- package/dist/ui-engine/runtime-header/state-bindings-nav.js +176 -0
- package/dist/ui-engine/runtime-header/structs.d.ts +1 -0
- package/dist/ui-engine/runtime-header/structs.js +220 -0
- package/dist/ui-engine/runtime-header/text-rendering.d.ts +1 -0
- package/dist/ui-engine/runtime-header/text-rendering.js +715 -0
- package/dist/ui-engine/runtime-header/tick/bindings-phase.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick/bindings-phase.js +103 -0
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +1051 -0
- package/dist/ui-engine/runtime-header/tick/flush-phase.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick/flush-phase.js +17 -0
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +15 -0
- package/dist/ui-engine/runtime-header/tick/transitions-phase.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick/transitions-phase.js +213 -0
- package/dist/ui-engine/runtime-header/tick-motion-helpers.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick-motion-helpers.js +26 -0
- package/dist/ui-engine/runtime-header/tick.d.ts +1 -0
- package/dist/ui-engine/runtime-header/tick.js +17 -0
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.d.ts +1 -0
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +406 -0
- package/dist/ui-engine/runtime-header/types-defines.d.ts +1 -0
- package/dist/ui-engine/runtime-header/types-defines.js +79 -0
- package/dist/ui-engine/runtime-header.d.ts +1 -0
- package/dist/ui-engine/runtime-header.js +69 -0
- package/dist/ui-engine/scroll-memory-diagnostics.d.ts +16 -0
- package/dist/ui-engine/scroll-memory-diagnostics.js +58 -0
- package/dist/ui-engine/select-engine.d.ts +4 -0
- package/dist/ui-engine/select-engine.js +19 -0
- package/dist/ui-engine/style-resolver.d.ts +77 -0
- package/dist/ui-engine/style-resolver.js +341 -0
- package/dist/ui-engine/text-layout.d.ts +19 -0
- package/dist/ui-engine/text-layout.js +92 -0
- package/dist/ui-engine/transpile-ui.d.ts +11 -0
- package/dist/ui-engine/transpile-ui.js +33 -0
- package/dist/ui-engine/ua-stylesheet.d.ts +3 -0
- package/dist/ui-engine/ua-stylesheet.js +56 -0
- package/dist/ui-engine/ui-file-splitter.d.ts +14 -0
- package/dist/ui-engine/ui-file-splitter.js +47 -0
- package/dist/ui-engine/ui-lowering.d.ts +33 -0
- package/dist/ui-engine/ui-lowering.js +390 -0
- package/dist/ui-engine/ui-registry.d.ts +81 -0
- package/dist/ui-engine/ui-registry.js +429 -0
- package/dist/ui-engine/yoga-layout.d.ts +12 -0
- package/dist/ui-engine/yoga-layout.js +314 -0
- package/package.json +86 -0
- package/src/engine-index.ts +51 -0
- package/src/index.ts +232 -0
- package/src/preview/build-program.ts +734 -0
- package/src/preview/host-gfx.ts +580 -0
- package/src/preview/host-ui-runtime.ts +3245 -0
- package/src/preview/types.ts +96 -0
- package/src/types.ts +127 -0
- package/src/ui-engine/block-layout.ts +104 -0
- package/src/ui-engine/color.ts +263 -0
- package/src/ui-engine/css-parser.ts +1003 -0
- package/src/ui-engine/default-keyboards.ts +58 -0
- package/src/ui-engine/easing.ts +53 -0
- package/src/ui-engine/font-assets.ts +512 -0
- package/src/ui-engine/html-parser.ts +397 -0
- package/src/ui-engine/image-assets.ts +170 -0
- package/src/ui-engine/inline-parser.ts +78 -0
- package/src/ui-engine/keyframes.ts +47 -0
- package/src/ui-engine/layout-engine.ts +228 -0
- package/src/ui-engine/model.ts +1225 -0
- package/src/ui-engine/rich-layout.ts +257 -0
- package/src/ui-engine/run-types.ts +14 -0
- package/src/ui-engine/runtime-header/antialiasing.ts +141 -0
- package/src/ui-engine/runtime-header/blend-bodies.ts +39 -0
- package/src/ui-engine/runtime-header/canvas-helpers.ts +155 -0
- package/src/ui-engine/runtime-header/canvas-scrollbar.ts +105 -0
- package/src/ui-engine/runtime-header/color-mono-refresh.ts +154 -0
- package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +169 -0
- package/src/ui-engine/runtime-header/display-shim.ts +104 -0
- package/src/ui-engine/runtime-header/forward-decls.ts +137 -0
- package/src/ui-engine/runtime-header/guard-close.ts +8 -0
- package/src/ui-engine/runtime-header/image-drawing.ts +300 -0
- package/src/ui-engine/runtime-header/init-press-input.ts +126 -0
- package/src/ui-engine/runtime-header/keyboard.ts +342 -0
- package/src/ui-engine/runtime-header/node-decoration.ts +212 -0
- package/src/ui-engine/runtime-header/paint-order-coords.ts +190 -0
- package/src/ui-engine/runtime-header/paint-rects-repair.ts +553 -0
- package/src/ui-engine/runtime-header/scroll-physics.ts +161 -0
- package/src/ui-engine/runtime-header/state-bindings-nav.ts +176 -0
- package/src/ui-engine/runtime-header/structs.ts +220 -0
- package/src/ui-engine/runtime-header/text-rendering.ts +715 -0
- package/src/ui-engine/runtime-header/tick/bindings-phase.ts +103 -0
- package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +1051 -0
- package/src/ui-engine/runtime-header/tick/flush-phase.ts +17 -0
- package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +15 -0
- package/src/ui-engine/runtime-header/tick/transitions-phase.ts +213 -0
- package/src/ui-engine/runtime-header/tick-motion-helpers.ts +26 -0
- package/src/ui-engine/runtime-header/tick.ts +18 -0
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +406 -0
- package/src/ui-engine/runtime-header/types-defines.ts +79 -0
- package/src/ui-engine/runtime-header.ts +71 -0
- package/src/ui-engine/scroll-memory-diagnostics.ts +79 -0
- package/src/ui-engine/select-engine.ts +21 -0
- package/src/ui-engine/style-resolver.ts +416 -0
- package/src/ui-engine/text-layout.ts +114 -0
- package/src/ui-engine/transpile-ui.ts +45 -0
- package/src/ui-engine/ua-stylesheet.ts +60 -0
- package/src/ui-engine/ui-file-splitter.ts +59 -0
- package/src/ui-engine/ui-lowering.ts +459 -0
- package/src/ui-engine/ui-registry.ts +530 -0
- 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
|
+
}
|