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