@typecad/ui 1.0.0-alpha.12 → 1.0.0-alpha.14
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/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
- package/assets/fonts/dejavu/LICENSE +187 -0
- package/assets/fonts/dejavu/README.md +49 -0
- package/dist/cli.js +22 -19
- package/dist/engine-index.js +2 -0
- package/dist/preview/build-program.d.ts +7 -1
- package/dist/preview/build-program.js +94 -15
- package/dist/preview/host-gfx.d.ts +3 -0
- package/dist/preview/host-gfx.js +30 -3
- package/dist/preview/host-ui-runtime.d.ts +115 -0
- package/dist/preview/host-ui-runtime.js +927 -46
- package/dist/ui-engine/block-layout.js +25 -11
- package/dist/ui-engine/color.d.ts +6 -0
- package/dist/ui-engine/color.js +80 -2
- package/dist/ui-engine/compat-report.d.ts +8 -0
- package/dist/ui-engine/compat-report.js +124 -0
- package/dist/ui-engine/css-imports.d.ts +4 -0
- package/dist/ui-engine/css-imports.js +70 -0
- package/dist/ui-engine/css-parser.js +58 -14
- package/dist/ui-engine/default-font.d.ts +25 -0
- package/dist/ui-engine/default-font.js +80 -0
- package/dist/ui-engine/font-assets.d.ts +2 -2
- package/dist/ui-engine/font-assets.js +22 -5
- package/dist/ui-engine/html-parser.d.ts +4 -0
- package/dist/ui-engine/html-parser.js +241 -26
- package/dist/ui-engine/image-assets.d.ts +8 -1
- package/dist/ui-engine/image-assets.js +35 -2
- package/dist/ui-engine/image-decode.d.ts +22 -0
- package/dist/ui-engine/image-decode.js +194 -0
- package/dist/ui-engine/inline-parser.js +1 -1
- package/dist/ui-engine/layout-engine.d.ts +19 -0
- package/dist/ui-engine/layout-engine.js +59 -3
- package/dist/ui-engine/model.d.ts +18 -0
- package/dist/ui-engine/model.js +82 -8
- package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
- package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
- package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
- package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
- package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
- package/dist/ui-engine/runtime-header/keyboard.js +682 -380
- package/dist/ui-engine/runtime-header/node-draw-body.js +1658 -1386
- package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
- package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
- package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
- package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
- package/dist/ui-engine/runtime-header/structs.js +228 -216
- package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
- package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
- package/dist/ui-engine/runtime-header/types-defines.js +110 -93
- package/dist/ui-engine/shadcn-kit.d.ts +1 -0
- package/dist/ui-engine/shadcn-kit.js +506 -0
- package/dist/ui-engine/style-resolver.d.ts +4 -0
- package/dist/ui-engine/style-resolver.js +58 -6
- package/dist/ui-engine/transpile-ui.js +12 -5
- package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
- package/dist/ui-engine/ua-stylesheet.js +119 -32
- package/dist/ui-engine/ui-lowering.js +37 -3
- package/dist/ui-engine/ui-registry.js +32 -4
- package/dist/ui-engine/yoga-layout.js +57 -52
- package/dist/wizard/index.d.ts +2 -0
- package/dist/wizard/index.js +1 -0
- package/dist/wizard/integration-wizard.d.ts +3 -0
- package/dist/wizard/integration-wizard.js +67 -7
- package/dist/wizard/package-writer.d.ts +20 -0
- package/dist/wizard/package-writer.js +79 -0
- package/package.json +11 -5
- package/src/cli.ts +90 -87
- package/src/engine-index.ts +53 -51
- package/src/preview/build-program.ts +810 -734
- package/src/preview/host-gfx.ts +30 -3
- package/src/preview/host-ui-runtime.ts +4132 -3289
- package/src/ui-engine/block-layout.ts +24 -11
- package/src/ui-engine/color.ts +77 -2
- package/src/ui-engine/compat-report.ts +139 -0
- package/src/ui-engine/css-imports.ts +69 -0
- package/src/ui-engine/css-parser.ts +64 -15
- package/src/ui-engine/default-font.ts +95 -0
- package/src/ui-engine/font-assets.ts +545 -525
- package/src/ui-engine/html-parser.ts +615 -397
- package/src/ui-engine/image-assets.ts +37 -2
- package/src/ui-engine/image-decode.ts +244 -0
- package/src/ui-engine/inline-parser.ts +1 -1
- package/src/ui-engine/layout-engine.ts +61 -3
- package/src/ui-engine/model.ts +1322 -1230
- package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
- package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
- package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
- package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
- package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
- package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
- package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
- package/src/ui-engine/runtime-header/keyboard.ts +689 -386
- package/src/ui-engine/runtime-header/node-draw-body.ts +1683 -1411
- package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
- package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
- package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
- package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
- package/src/ui-engine/runtime-header/structs.ts +234 -222
- package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
- package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
- package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
- package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
- package/src/ui-engine/runtime-header/types-defines.ts +116 -99
- package/src/ui-engine/shadcn-kit.ts +507 -0
- package/src/ui-engine/style-resolver.ts +471 -416
- package/src/ui-engine/transpile-ui.ts +12 -5
- package/src/ui-engine/ua-stylesheet.ts +137 -31
- package/src/ui-engine/ui-lowering.ts +486 -452
- package/src/ui-engine/ui-registry.ts +556 -524
- package/src/ui-engine/yoga-layout.ts +307 -309
- package/src/wizard/index.ts +46 -38
- package/src/wizard/integration-wizard.ts +689 -619
- package/src/wizard/package-writer.ts +98 -0
|
@@ -75,7 +75,21 @@ function findMatchingCloseParen(text, openIdx) {
|
|
|
75
75
|
}
|
|
76
76
|
return -1;
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
/** Linear color lerp at the ACTIVE depth (module runtimeColorFormat): 888
|
|
79
|
+
* targets lerp per 8-bit channel; 565 targets lerp per 5/6-bit channel
|
|
80
|
+
* (device parity — the C++ ui_lerp macro switches the same way). Exported
|
|
81
|
+
* for tests. */
|
|
82
|
+
export function lerpColor(a, b, k100) {
|
|
83
|
+
if (runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") {
|
|
84
|
+
if (k100 >= 100)
|
|
85
|
+
return b & 0xffffff;
|
|
86
|
+
const mix = (shift) => {
|
|
87
|
+
const av = (a >> shift) & 0xff;
|
|
88
|
+
const bv = (b >> shift) & 0xff;
|
|
89
|
+
return (av + Math.trunc(((bv - av) * k100) / 100)) & 0xff;
|
|
90
|
+
};
|
|
91
|
+
return (mix(16) << 16) | (mix(8) << 8) | mix(0);
|
|
92
|
+
}
|
|
79
93
|
if (k100 >= 100)
|
|
80
94
|
return b & 0xffff;
|
|
81
95
|
const ar = (a >> 11) & 0x1f;
|
|
@@ -114,6 +128,18 @@ function blendRuntime(fg, bg, opacity) {
|
|
|
114
128
|
? blendRgb888(fg, bg, opacity)
|
|
115
129
|
: blendRgb565(fg, bg, opacity);
|
|
116
130
|
}
|
|
131
|
+
/** Halve a color at the active depth (device parity: UI_DIM_MASK — 0x7BEF on
|
|
132
|
+
* 565, 0x7F7F7F per channel on 888/666). The 565-only form channel-shifts
|
|
133
|
+
* on rgb888 targets. */
|
|
134
|
+
function dimRuntimeColor(c) {
|
|
135
|
+
return c & ((runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") ? 0x7f7f7f : 0x7bef);
|
|
136
|
+
}
|
|
137
|
+
/** Halve a runtime color for HTML-disabled controls — matches the device's
|
|
138
|
+
* `(c >> 1) & UI_DIM_MASK` (dimRuntimeColor's bare AND barely changes dark
|
|
139
|
+
* 565 colors; the shift gives a real fade on both depths). */
|
|
140
|
+
function dimDisabledColor(c) {
|
|
141
|
+
return (c >> 1) & ((runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") ? 0x7f7f7f : 0x7bef);
|
|
142
|
+
}
|
|
117
143
|
function mergeClassRules(classes, rules) {
|
|
118
144
|
const merged = {};
|
|
119
145
|
const classSet = new Set(classes ?? []);
|
|
@@ -244,6 +270,10 @@ export class PreviewUIRuntime {
|
|
|
244
270
|
settleFromScrollY = 0; // settle start value (edge snap; +toward 0, -toward max)
|
|
245
271
|
rangeNode = -1;
|
|
246
272
|
keyboardVisible = false;
|
|
273
|
+
/** Modal <select> option list: node index while open, -1 when closed. */
|
|
274
|
+
selectMenuNode = -1;
|
|
275
|
+
/** True while the select modal overlay needs (re)stamping this frame. */
|
|
276
|
+
selectMenuDirty = false;
|
|
247
277
|
keyboardDirty = 0;
|
|
248
278
|
keyboardTarget = -1;
|
|
249
279
|
keyboardKeys = [];
|
|
@@ -293,6 +323,13 @@ export class PreviewUIRuntime {
|
|
|
293
323
|
// Snap draw colors to black/white for mono/e-ink targets (parity with the
|
|
294
324
|
// device's UI_NATIVE_MONO draw-path snap). No-op for color targets.
|
|
295
325
|
this.gfx.setMonoSnap(snapshot.program.colorFormat === "mono");
|
|
326
|
+
// Buffer storage depth tracks the target: 565 panels store packed 565,
|
|
327
|
+
// rgb888 stores packed 888, rgb666 stores 888 and quantizes at the canvas
|
|
328
|
+
// push. Without this, 888-packed colors render channel-shifted (the
|
|
329
|
+
// buffer was historically assumed to always hold 565).
|
|
330
|
+
this.gfx.setStorageMode(snapshot.program.colorFormat === "rgb888" ? "rgb888"
|
|
331
|
+
: snapshot.program.colorFormat === "rgb666" ? "rgb666"
|
|
332
|
+
: "rgb565");
|
|
296
333
|
this.createScreenProxy();
|
|
297
334
|
}
|
|
298
335
|
start() {
|
|
@@ -302,7 +339,14 @@ export class PreviewUIRuntime {
|
|
|
302
339
|
this.seedModuleScope();
|
|
303
340
|
this.applyInitialAssignments();
|
|
304
341
|
this.uiInit();
|
|
342
|
+
this.seedDrawersClosed();
|
|
305
343
|
this.tick(16);
|
|
344
|
+
// Device parity: the device's loop() calls ui_tick() every iteration —
|
|
345
|
+
// that is what advances CSS keyframe animations, transitions, and scroll
|
|
346
|
+
// settles. The browser has no loop(), so drive the same tick from a ~20ms
|
|
347
|
+
// poller (the microtask-pump cadence the pin watcher uses). Idle ticks are
|
|
348
|
+
// cheap: drawDirty() finds nothing and no frame is pushed.
|
|
349
|
+
this.timers.push(setInterval(() => this.tick(), 20));
|
|
306
350
|
for (const interval of this.intervals) {
|
|
307
351
|
this.timers.push(setInterval(() => {
|
|
308
352
|
this.runBody(interval.body);
|
|
@@ -355,16 +399,25 @@ export class PreviewUIRuntime {
|
|
|
355
399
|
const now = Date.now();
|
|
356
400
|
const delta = deltaMs ?? Math.max(0, now - this.lastTickTime);
|
|
357
401
|
this.lastTickTime = now;
|
|
402
|
+
if (this.debugCapture)
|
|
403
|
+
this.debugPaintedRects = [];
|
|
358
404
|
this.evaluateBindings();
|
|
359
405
|
this.advanceTransitions(delta);
|
|
360
406
|
this.advanceAnimations(delta);
|
|
407
|
+
this.applyDrawers(delta);
|
|
361
408
|
// Advance any in-flight scroll settle animation (bounce-back / edge-snap).
|
|
362
409
|
for (let i = 0; i < this.nodes.length; i++) {
|
|
363
410
|
if (this.nodes[i].settling)
|
|
364
411
|
this.advanceScrollSettle(i);
|
|
365
412
|
}
|
|
366
|
-
|
|
413
|
+
let changed = this.drawDirty() || this.directFrameChanged;
|
|
367
414
|
this.directFrameChanged = false;
|
|
415
|
+
// Modal <select> list: stamp the overlay whenever the frame beneath it
|
|
416
|
+
// changed (or it was just opened) so redraws never bury it.
|
|
417
|
+
if (this.selectMenuNode >= 0 && (changed || this.selectMenuDirty)) {
|
|
418
|
+
this.drawSelectMenu();
|
|
419
|
+
changed = true;
|
|
420
|
+
}
|
|
368
421
|
if (changed) {
|
|
369
422
|
this.onFrame?.(this.gfx.toRgbaBytes());
|
|
370
423
|
}
|
|
@@ -383,6 +436,19 @@ export class PreviewUIRuntime {
|
|
|
383
436
|
this.handleNoTouch();
|
|
384
437
|
this.tick();
|
|
385
438
|
}
|
|
439
|
+
/** Mouse-wheel scroll: a discrete delta applied to the scroll owner under
|
|
440
|
+
* (x, y) — the same hit-scan a drag uses, without the gesture state
|
|
441
|
+
* machine. Wheel-down (positive deltaPx) shows later content; any
|
|
442
|
+
* overscroll from the tick bounces back via the standard settle. */
|
|
443
|
+
wheel(x, y, deltaPx) {
|
|
444
|
+
const owner = this.findScrollNode(x, y);
|
|
445
|
+
if (owner < 0)
|
|
446
|
+
return;
|
|
447
|
+
if (this.applyScrollDelta(owner, -Math.trunc(deltaPx))) {
|
|
448
|
+
this.releaseScroll(owner);
|
|
449
|
+
}
|
|
450
|
+
this.tick();
|
|
451
|
+
}
|
|
386
452
|
triggerPin(control) {
|
|
387
453
|
if (control.kind === "toggle" && control.nodeIndex !== undefined) {
|
|
388
454
|
const node = this.nodes[control.nodeIndex];
|
|
@@ -513,16 +579,31 @@ export class PreviewUIRuntime {
|
|
|
513
579
|
}
|
|
514
580
|
return true;
|
|
515
581
|
}
|
|
582
|
+
/** Effective stacking z, CSS-style: a non-zero zIndex raises the node AND
|
|
583
|
+
* its subtree — a z-raised panel forms a stacking context, so its
|
|
584
|
+
* descendants stack WITH it (above lower-z siblings), never
|
|
585
|
+
* independently UNDER it (a flat (z, index) sort painted a dialog card
|
|
586
|
+
* over its own children). 0 = "auto" — keep walking ancestors. */
|
|
587
|
+
stackingZ(node) {
|
|
588
|
+
let n = node;
|
|
589
|
+
while (n) {
|
|
590
|
+
const z = Math.trunc(n.zIndex ?? 0);
|
|
591
|
+
if (z !== 0)
|
|
592
|
+
return z;
|
|
593
|
+
n = n.parentIndex >= 0 && n.parentIndex < this.nodes.length ? this.nodes[n.parentIndex] : undefined;
|
|
594
|
+
}
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
516
597
|
drawsBefore(a, b) {
|
|
517
|
-
const az =
|
|
518
|
-
const bz =
|
|
598
|
+
const az = this.stackingZ(a);
|
|
599
|
+
const bz = this.stackingZ(b);
|
|
519
600
|
if (az !== bz)
|
|
520
601
|
return az < bz;
|
|
521
602
|
return a.index < b.index;
|
|
522
603
|
}
|
|
523
604
|
compareDrawOrder(a, b) {
|
|
524
|
-
const az =
|
|
525
|
-
const bz =
|
|
605
|
+
const az = this.stackingZ(a);
|
|
606
|
+
const bz = this.stackingZ(b);
|
|
526
607
|
if (az !== bz)
|
|
527
608
|
return az - bz;
|
|
528
609
|
return a.index - b.index;
|
|
@@ -539,6 +620,20 @@ export class PreviewUIRuntime {
|
|
|
539
620
|
this.keyboardPressedKey = -1;
|
|
540
621
|
this.keyboardBackspaceHeld = false;
|
|
541
622
|
}
|
|
623
|
+
this.closeSelectMenu(false);
|
|
624
|
+
this.drawerStates.clear();
|
|
625
|
+
for (const node of this.nodes) {
|
|
626
|
+
const n = node;
|
|
627
|
+
if (n.__drawerDx)
|
|
628
|
+
n.__drawerDx = 0;
|
|
629
|
+
if (n.__drawerDy)
|
|
630
|
+
n.__drawerDy = 0;
|
|
631
|
+
if (n.drawerSide !== undefined) {
|
|
632
|
+
n.transformOffsetX = 0;
|
|
633
|
+
n.transformOffsetY = 0;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
this.seedDrawersClosed();
|
|
542
637
|
this.gfx.fillScreen(0x0000);
|
|
543
638
|
for (const node of this.nodes) {
|
|
544
639
|
node.dirty = true;
|
|
@@ -555,6 +650,10 @@ export class PreviewUIRuntime {
|
|
|
555
650
|
const value = this.evaluateExpression(binding.expression);
|
|
556
651
|
if (binding.property === "text") {
|
|
557
652
|
const text = clampText(value);
|
|
653
|
+
// A text-bound node draws its buffer (build-time hasTextBinding only
|
|
654
|
+
// covers auto-wire/interpolation synthesis — ui.bind targets set it
|
|
655
|
+
// here so the bound string actually renders).
|
|
656
|
+
node.hasTextBinding = true;
|
|
558
657
|
if (text !== node.textBuffer) {
|
|
559
658
|
node.textBuffer = text;
|
|
560
659
|
this.markDirty(binding.nodeIndex);
|
|
@@ -830,11 +929,12 @@ export class PreviewUIRuntime {
|
|
|
830
929
|
}
|
|
831
930
|
baseDrawXForNode(nodeIndex) {
|
|
832
931
|
const node = this.nodes[nodeIndex];
|
|
833
|
-
return node.box.x + (node.transformOffsetX ?? 0);
|
|
932
|
+
return node.box.x + (node.transformOffsetX ?? 0) + (node.__drawerDx ?? 0);
|
|
834
933
|
}
|
|
835
934
|
baseDrawYForNode(nodeIndex) {
|
|
836
|
-
|
|
837
|
-
let
|
|
935
|
+
const node = this.nodes[nodeIndex];
|
|
936
|
+
let y = node.box.y + (node.transformOffsetY ?? 0) + (node.__drawerDy ?? 0);
|
|
937
|
+
let parent = node.parentIndex;
|
|
838
938
|
while (parent >= 0 && this.nodes[parent]) {
|
|
839
939
|
if (this.nodes[parent].scrollable) {
|
|
840
940
|
y -= this.nodes[parent].scrollY;
|
|
@@ -883,9 +983,18 @@ export class PreviewUIRuntime {
|
|
|
883
983
|
const y0 = Math.min(baseY, baseY + oy);
|
|
884
984
|
const x1 = Math.max(baseX + node.box.w, baseX + ox + node.box.w);
|
|
885
985
|
const y1 = Math.max(baseY + node.box.h, baseY + oy + node.box.h);
|
|
886
|
-
|
|
986
|
+
// Clip to the node's scroll viewport like every other clear path. During
|
|
987
|
+
// overscroll (rubber-band past the container edge) the box's draw position
|
|
988
|
+
// sits outside the viewport, and the node's own paint was clipped there —
|
|
989
|
+
// an unclipped clear/repair here paints parent background over foreign
|
|
990
|
+
// regions (e.g. the fixed header above the scroll body) and nothing re-
|
|
991
|
+
// dirties that region afterwards, leaving permanent holes.
|
|
992
|
+
const rect = this.intersectClipRect(this.scrollClipForNode(node.index), { x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
|
993
|
+
if (rect.w <= 0 || rect.h <= 0)
|
|
887
994
|
return;
|
|
888
|
-
this.
|
|
995
|
+
if (this.repairCurrentNodePaintWithParent(node, rect))
|
|
996
|
+
return;
|
|
997
|
+
this.gfx.fillRect(rect.x, rect.y, rect.w, rect.h, this.parentClearColor(node));
|
|
889
998
|
}
|
|
890
999
|
shadowExtents(node) {
|
|
891
1000
|
let left = 0;
|
|
@@ -1321,6 +1430,7 @@ export class PreviewUIRuntime {
|
|
|
1321
1430
|
if (subtreeRect)
|
|
1322
1431
|
this.markOverlappingHigherLayersDirtyForRect(nodeIndex, subtreeRect);
|
|
1323
1432
|
node.visible = false;
|
|
1433
|
+
this.reflowAfterVisibility(nodeIndex);
|
|
1324
1434
|
return;
|
|
1325
1435
|
}
|
|
1326
1436
|
node.visible = true;
|
|
@@ -1331,6 +1441,170 @@ export class PreviewUIRuntime {
|
|
|
1331
1441
|
child.dirty = true;
|
|
1332
1442
|
this.markOverlappingHigherLayersDirty(i);
|
|
1333
1443
|
}
|
|
1444
|
+
this.reflowAfterVisibility(nodeIndex);
|
|
1445
|
+
}
|
|
1446
|
+
// ── Visibility reflow (device parity: ui_reflow_visibility) ─────────────
|
|
1447
|
+
// Layout is baked at build time; toggling visible alone leaves a collapsed
|
|
1448
|
+
// pane's space reserved. Re-stack ancestor flow containers from the baked
|
|
1449
|
+
// flowAxis/flowGap/flowFlags metadata, cascading until a container stops
|
|
1450
|
+
// changing, then repaint the active screen once.
|
|
1451
|
+
subtreeFlowExtent(c, axis) {
|
|
1452
|
+
const base = axis === 1 ? this.nodes[c].box.y : this.nodes[c].box.x;
|
|
1453
|
+
let maxEnd = base + (axis === 1 ? this.nodes[c].box.h : this.nodes[c].box.w);
|
|
1454
|
+
const end = Math.min(this.nodes[c].subtreeEnd, this.nodes.length);
|
|
1455
|
+
for (let j = c + 1; j < end; j++) {
|
|
1456
|
+
const rel = (axis === 1 ? this.nodes[j].box.y : this.nodes[j].box.x) - base;
|
|
1457
|
+
if (rel < 0)
|
|
1458
|
+
continue;
|
|
1459
|
+
const jEnd = rel + (axis === 1 ? this.nodes[j].box.h : this.nodes[j].box.w);
|
|
1460
|
+
if (jEnd > maxEnd)
|
|
1461
|
+
maxEnd = jEnd;
|
|
1462
|
+
}
|
|
1463
|
+
return maxEnd - base;
|
|
1464
|
+
}
|
|
1465
|
+
shiftSubtreeMain(c, axis, delta) {
|
|
1466
|
+
const end = Math.min(this.nodes[c].subtreeEnd, this.nodes.length);
|
|
1467
|
+
for (let j = c; j < end; j++) {
|
|
1468
|
+
if (axis === 1)
|
|
1469
|
+
this.nodes[j].box.y += delta;
|
|
1470
|
+
else
|
|
1471
|
+
this.nodes[j].box.x += delta;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
restackFlowContainer(p) {
|
|
1475
|
+
const axis = this.nodes[p].flowAxis === 2 ? 2 : this.nodes[p].flowAxis === 1 ? 1 : 0;
|
|
1476
|
+
if (axis === 0)
|
|
1477
|
+
return false;
|
|
1478
|
+
const gap = this.nodes[p].flowGap ?? 0;
|
|
1479
|
+
let startSlot = 0;
|
|
1480
|
+
let cursor = 0;
|
|
1481
|
+
let sawFlow = false;
|
|
1482
|
+
let sawVisible = false;
|
|
1483
|
+
let changed = false;
|
|
1484
|
+
const end = Math.min(this.nodes[p].subtreeEnd, this.nodes.length);
|
|
1485
|
+
for (let c = p + 1; c < end; c++) {
|
|
1486
|
+
if (this.nodes[c].parentIndex !== p)
|
|
1487
|
+
continue;
|
|
1488
|
+
if (((this.nodes[c].flowFlags ?? 0) & 4) !== 0)
|
|
1489
|
+
continue;
|
|
1490
|
+
const rel = axis === 1
|
|
1491
|
+
? this.nodes[c].box.y - this.nodes[p].box.y
|
|
1492
|
+
: this.nodes[c].box.x - this.nodes[p].box.x;
|
|
1493
|
+
if (!sawFlow) {
|
|
1494
|
+
startSlot = rel;
|
|
1495
|
+
sawFlow = true;
|
|
1496
|
+
}
|
|
1497
|
+
if (!this.isEffectivelyVisible(this.nodes[c]))
|
|
1498
|
+
continue;
|
|
1499
|
+
if (!sawVisible) {
|
|
1500
|
+
cursor = startSlot;
|
|
1501
|
+
sawVisible = true;
|
|
1502
|
+
}
|
|
1503
|
+
const shift = cursor - rel;
|
|
1504
|
+
if (shift !== 0) {
|
|
1505
|
+
this.shiftSubtreeMain(c, axis, shift);
|
|
1506
|
+
changed = true;
|
|
1507
|
+
}
|
|
1508
|
+
cursor += this.subtreeFlowExtent(c, axis) + gap;
|
|
1509
|
+
}
|
|
1510
|
+
if (!sawVisible)
|
|
1511
|
+
return changed;
|
|
1512
|
+
const autoMain = axis === 1
|
|
1513
|
+
? ((this.nodes[p].flowFlags ?? 0) & 1)
|
|
1514
|
+
: ((this.nodes[p].flowFlags ?? 0) & 2);
|
|
1515
|
+
if (autoMain) {
|
|
1516
|
+
let newSize = cursor - gap + (axis === 1 ? this.nodes[p].paddingBottom ?? 0 : this.nodes[p].paddingRight ?? 0);
|
|
1517
|
+
if (newSize < 1)
|
|
1518
|
+
newSize = 1;
|
|
1519
|
+
const cur = axis === 1 ? this.nodes[p].box.h : this.nodes[p].box.w;
|
|
1520
|
+
if (newSize !== cur) {
|
|
1521
|
+
if (axis === 1)
|
|
1522
|
+
this.nodes[p].box.h = newSize;
|
|
1523
|
+
else
|
|
1524
|
+
this.nodes[p].box.w = newSize;
|
|
1525
|
+
changed = true;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return changed;
|
|
1529
|
+
}
|
|
1530
|
+
reflowScrollContent(p) {
|
|
1531
|
+
const node = this.nodes[p];
|
|
1532
|
+
if (!node.scrollable || node.virtualized)
|
|
1533
|
+
return;
|
|
1534
|
+
let maxBottom = node.box.y;
|
|
1535
|
+
const end = Math.min(node.subtreeEnd, this.nodes.length);
|
|
1536
|
+
for (let j = p + 1; j < end; j++) {
|
|
1537
|
+
if (!this.isEffectivelyVisible(this.nodes[j]))
|
|
1538
|
+
continue;
|
|
1539
|
+
const bottom = this.nodes[j].box.y + this.nodes[j].box.h;
|
|
1540
|
+
if (bottom > maxBottom)
|
|
1541
|
+
maxBottom = bottom;
|
|
1542
|
+
}
|
|
1543
|
+
const ch = Math.max(maxBottom - node.box.y, node.box.h);
|
|
1544
|
+
node.contentHeight = ch;
|
|
1545
|
+
const maxScroll = Math.max(0, ch - node.box.h);
|
|
1546
|
+
if (node.scrollY > maxScroll)
|
|
1547
|
+
node.scrollY = maxScroll;
|
|
1548
|
+
node.lastPaintedScrollY = node.scrollY - (node.box.h > 0 ? node.box.h : 1);
|
|
1549
|
+
}
|
|
1550
|
+
reflowAfterVisibility(changedNode) {
|
|
1551
|
+
let p = this.nodes[changedNode]?.parentIndex ?? -1;
|
|
1552
|
+
let anyChange = false;
|
|
1553
|
+
let guard = 0;
|
|
1554
|
+
while (p >= 0 && p < this.nodes.length && guard++ < 64) {
|
|
1555
|
+
const changedHere = this.restackFlowContainer(p);
|
|
1556
|
+
this.reflowScrollContent(p);
|
|
1557
|
+
if (!changedHere)
|
|
1558
|
+
break;
|
|
1559
|
+
anyChange = true;
|
|
1560
|
+
p = this.nodes[p].parentIndex ?? -1;
|
|
1561
|
+
}
|
|
1562
|
+
if (!anyChange)
|
|
1563
|
+
return;
|
|
1564
|
+
for (const node of this.nodes) {
|
|
1565
|
+
if ((node.screenId ?? 0) !== this.activeScreen)
|
|
1566
|
+
continue;
|
|
1567
|
+
node.dirty = true;
|
|
1568
|
+
node.lastTextHeight = 0;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
classifyDirtyAgainstOpenDrawers() {
|
|
1572
|
+
for (const [dIdx, dState] of this.drawerStates) {
|
|
1573
|
+
if (!dState.open || dState.progress < 1)
|
|
1574
|
+
continue;
|
|
1575
|
+
const drawer = this.nodes[dIdx];
|
|
1576
|
+
if (!drawer)
|
|
1577
|
+
continue;
|
|
1578
|
+
const dX = this.drawXForNode(dIdx);
|
|
1579
|
+
const dY = this.drawYForNode(dIdx);
|
|
1580
|
+
for (const node of this.nodes) {
|
|
1581
|
+
if (!node.dirty)
|
|
1582
|
+
continue;
|
|
1583
|
+
if (node.index >= dIdx && node.index < drawer.subtreeEnd)
|
|
1584
|
+
continue;
|
|
1585
|
+
if (!this.isActiveNode(node))
|
|
1586
|
+
continue;
|
|
1587
|
+
const nX = this.drawXForNode(node.index);
|
|
1588
|
+
const nY = this.drawYForNode(node.index);
|
|
1589
|
+
if (nX + node.box.w <= dX || nX >= dX + drawer.box.w ||
|
|
1590
|
+
nY + node.box.h <= dY || nY >= dY + drawer.box.h)
|
|
1591
|
+
continue;
|
|
1592
|
+
const fullyCovered = nX >= dX && nY >= dY &&
|
|
1593
|
+
nX + node.box.w <= dX + drawer.box.w &&
|
|
1594
|
+
nY + node.box.h <= dY + drawer.box.h;
|
|
1595
|
+
if (fullyCovered && drawer.hasBg && drawer.opacity >= 100) {
|
|
1596
|
+
node.dirty = false;
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
drawer.dirty = true;
|
|
1600
|
+
drawer.lastTextHeight = 0;
|
|
1601
|
+
for (let c = dIdx + 1; c < drawer.subtreeEnd && c < this.nodes.length; c++) {
|
|
1602
|
+
this.nodes[c].dirty = true;
|
|
1603
|
+
this.nodes[c].lastTextHeight = 0;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1334
1608
|
}
|
|
1335
1609
|
scrollClipForNode(nodeIndex) {
|
|
1336
1610
|
let parent = this.nodes[nodeIndex].parentIndex;
|
|
@@ -1374,7 +1648,10 @@ export class PreviewUIRuntime {
|
|
|
1374
1648
|
for (const node of this.nodes) {
|
|
1375
1649
|
if (!this.isActiveNode(node))
|
|
1376
1650
|
continue;
|
|
1377
|
-
|
|
1651
|
+
// Device parity (dirty-draw-phase): generic scroll passes skip
|
|
1652
|
+
// VIRTUALIZED nodes — lists own their full draw (rows + scrollbar)
|
|
1653
|
+
// in drawListNode. Letting this pass clear a list wipes its rows.
|
|
1654
|
+
if (!node.scrollable || node.virtualized || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
|
|
1378
1655
|
continue;
|
|
1379
1656
|
if (node.dirty) {
|
|
1380
1657
|
this.markScrollDescendantsDirtyLocal(node.index);
|
|
@@ -1397,7 +1674,10 @@ export class PreviewUIRuntime {
|
|
|
1397
1674
|
for (const node of this.nodes) {
|
|
1398
1675
|
if (!this.isActiveNode(node))
|
|
1399
1676
|
continue;
|
|
1400
|
-
|
|
1677
|
+
// Device parity (dirty-draw-phase): virtualized lists draw their own
|
|
1678
|
+
// scrollbar in drawListNode — drawing here too overdrew it with the
|
|
1679
|
+
// old dim and clamped scrollY against the node-level contentHeight.
|
|
1680
|
+
if (!node.scrollable || node.virtualized || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
|
|
1401
1681
|
continue;
|
|
1402
1682
|
if (!scrollbarDirty.has(node.index))
|
|
1403
1683
|
continue;
|
|
@@ -1405,7 +1685,7 @@ export class PreviewUIRuntime {
|
|
|
1405
1685
|
const ty = node.box.y;
|
|
1406
1686
|
const th = node.box.h;
|
|
1407
1687
|
node.scrollY = Math.max(0, Math.min(node.scrollY, node.contentHeight - th));
|
|
1408
|
-
const trackColor = (node.fg
|
|
1688
|
+
const trackColor = dimRuntimeColor(node.fg);
|
|
1409
1689
|
this.gfx.fillRect(tx, ty, 3, th, trackColor);
|
|
1410
1690
|
const thumbH = Math.max(8, Math.trunc((th * th) / node.contentHeight));
|
|
1411
1691
|
const thumbY = ty + Math.trunc(((node.box.h - thumbH) * node.scrollY) / Math.max(1, node.contentHeight - th));
|
|
@@ -1774,6 +2054,13 @@ export class PreviewUIRuntime {
|
|
|
1774
2054
|
if (this.isActiveNode(node) && this.isEffectivelyVisible(node) && node.scrollable && node.dirty)
|
|
1775
2055
|
scrollbarDirty.add(node.index);
|
|
1776
2056
|
}
|
|
2057
|
+
// Open-drawer overlap pre-pass (device parity, the dirty-draw loop's
|
|
2058
|
+
// classification): a dirty node under a fully-open opaque drawer paints
|
|
2059
|
+
// its clear+redraw over the panel — a bound text behind the drawer erases
|
|
2060
|
+
// the drawer's buttons. Fully covered -> nothing of it is visible, drop
|
|
2061
|
+
// the dirty flag; partially covered -> re-dirty that drawer's subtree so
|
|
2062
|
+
// it re-stamps on top in this same pass (its z sorts it after).
|
|
2063
|
+
this.classifyDirtyAgainstOpenDrawers();
|
|
1777
2064
|
const dirtyNodes = this.nodes
|
|
1778
2065
|
.filter((node) => {
|
|
1779
2066
|
if (!node.dirty)
|
|
@@ -1792,7 +2079,7 @@ export class PreviewUIRuntime {
|
|
|
1792
2079
|
for (const node of dirtyNodes) {
|
|
1793
2080
|
if (!node.dirty)
|
|
1794
2081
|
continue;
|
|
1795
|
-
if (!this.isEffectivelyVisible(node)) {
|
|
2082
|
+
if (!this.isEffectivelyVisible(node) || this.insideClosedDrawer(node.index)) {
|
|
1796
2083
|
node.dirty = false;
|
|
1797
2084
|
continue;
|
|
1798
2085
|
}
|
|
@@ -1827,8 +2114,15 @@ export class PreviewUIRuntime {
|
|
|
1827
2114
|
if (node.opacity < 100)
|
|
1828
2115
|
fillBg = blendRuntime(node.bg, this.parentClearColor(node), node.opacity);
|
|
1829
2116
|
this.gfx.withClipRect(scrollClip, () => {
|
|
1830
|
-
|
|
1831
|
-
|
|
2117
|
+
// Lists draw their outset shadow inside drawListNode, on the
|
|
2118
|
+
// full-repaint path only. The shadow rect spans the element, so
|
|
2119
|
+
// pre-drawing it here would paint over the live viewport pixels the
|
|
2120
|
+
// shift path is about to copy (device parity: the C++ main loop also
|
|
2121
|
+
// skips the outset shadow for lists — see node-draw-body.ts).
|
|
2122
|
+
if (node.kind !== "list") {
|
|
2123
|
+
node.box.x = baseX;
|
|
2124
|
+
this.drawNodeShadow(node, baseY, false);
|
|
2125
|
+
}
|
|
1832
2126
|
node.box.x = drawX;
|
|
1833
2127
|
switch (node.kind) {
|
|
1834
2128
|
case "fill":
|
|
@@ -1892,9 +2186,72 @@ export class PreviewUIRuntime {
|
|
|
1892
2186
|
changed = true;
|
|
1893
2187
|
node.box.x = origBoxX;
|
|
1894
2188
|
node.dirty = false;
|
|
2189
|
+
if (this.debugCapture) {
|
|
2190
|
+
this.debugPaintedRects.push({ x: drawX, y: drawY, w: node.box.w, h: node.box.h });
|
|
2191
|
+
}
|
|
1895
2192
|
}
|
|
1896
2193
|
return this.drawScrollbars(scrollbarDirty) || changed;
|
|
1897
2194
|
}
|
|
2195
|
+
/** Enable/disable per-frame debug geometry capture (the client overlay
|
|
2196
|
+
* reads it; zero cost while off). */
|
|
2197
|
+
setDebugCapture(on) {
|
|
2198
|
+
this.debugCapture = on;
|
|
2199
|
+
if (!on)
|
|
2200
|
+
this.debugPaintedRects = [];
|
|
2201
|
+
}
|
|
2202
|
+
/** Current-frame debug geometry for the client overlay: every visible node
|
|
2203
|
+
* on the active screen at its CURRENT draw position (transform/press/
|
|
2204
|
+
* drawer/scroll offsets applied), the scroll viewport clips, and the
|
|
2205
|
+
* regions painted this tick (dirty-flash). Coordinates are logical
|
|
2206
|
+
* display pixels, matching the app canvas 1:1. */
|
|
2207
|
+
debugInfo() {
|
|
2208
|
+
const nodes = [];
|
|
2209
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
2210
|
+
const node = this.nodes[i];
|
|
2211
|
+
if (!this.isActiveNode(node) || !this.isEffectivelyVisible(node))
|
|
2212
|
+
continue;
|
|
2213
|
+
if (this.insideClosedDrawer(i))
|
|
2214
|
+
continue;
|
|
2215
|
+
nodes.push({
|
|
2216
|
+
i,
|
|
2217
|
+
id: node.id,
|
|
2218
|
+
tag: node.tag,
|
|
2219
|
+
kind: String(node.kind),
|
|
2220
|
+
x: this.drawXForNode(i),
|
|
2221
|
+
y: this.drawYForNode(i),
|
|
2222
|
+
w: node.box.w,
|
|
2223
|
+
h: node.box.h,
|
|
2224
|
+
tappable: this.hasAnyHandler(i),
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
const clips = [];
|
|
2228
|
+
for (const node of this.nodes) {
|
|
2229
|
+
if (!this.isActiveNode(node) || !node.scrollable)
|
|
2230
|
+
continue;
|
|
2231
|
+
clips.push({ x: node.box.x, y: node.box.y, w: node.box.w, h: node.box.h });
|
|
2232
|
+
}
|
|
2233
|
+
return { nodes, clips, painted: this.debugPaintedRects.slice() };
|
|
2234
|
+
}
|
|
2235
|
+
/** Inspect-on-tap: topmost node (draw order) whose CURRENT draw box
|
|
2236
|
+
* contains the point — unlike hitTest, containers count too. Returns the
|
|
2237
|
+
* index, or -1. */
|
|
2238
|
+
debugHit(tx, ty) {
|
|
2239
|
+
let best = -1;
|
|
2240
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
2241
|
+
const node = this.nodes[i];
|
|
2242
|
+
if (!this.isActiveNode(node) || !this.isEffectivelyVisible(node))
|
|
2243
|
+
continue;
|
|
2244
|
+
if (this.insideClosedDrawer(i))
|
|
2245
|
+
continue;
|
|
2246
|
+
const x = this.drawXForNode(i);
|
|
2247
|
+
const y = this.drawYForNode(i);
|
|
2248
|
+
if (tx >= x && tx < x + node.box.w && ty >= y && ty < y + node.box.h) {
|
|
2249
|
+
if (best < 0 || this.drawsBefore(this.nodes[best], node))
|
|
2250
|
+
best = i;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return best;
|
|
2254
|
+
}
|
|
1898
2255
|
lineX(node, lineWidth, left, maxWidth, align = node.textAlign) {
|
|
1899
2256
|
if (align === 1)
|
|
1900
2257
|
return left + Math.trunc((maxWidth - lineWidth) / 2);
|
|
@@ -2158,6 +2515,17 @@ export class PreviewUIRuntime {
|
|
|
2158
2515
|
const deltaY = node.scrollY - node.lastPaintedScrollY;
|
|
2159
2516
|
const absDelta = Math.abs(deltaY);
|
|
2160
2517
|
const canShift = deltaY !== 0 && absDelta < node.box.h;
|
|
2518
|
+
if (!canShift) {
|
|
2519
|
+
// Full repaint: outset shadow at the rest position first, then the bg
|
|
2520
|
+
// fill covers its interior overlap. Skipped on the shift path — the
|
|
2521
|
+
// shadow rect spans the element and would destroy the pixels about to
|
|
2522
|
+
// be shifted (device parity: ui_draw_shadow(i, by, 0) only when
|
|
2523
|
+
// !canShiftList, before the canvas push).
|
|
2524
|
+
const origX = node.box.x;
|
|
2525
|
+
node.box.x = this.baseDrawXForNode(node.index);
|
|
2526
|
+
this.drawNodeShadow(node, this.baseDrawYForNode(node.index), false);
|
|
2527
|
+
node.box.x = origX;
|
|
2528
|
+
}
|
|
2161
2529
|
const repaint = canShift
|
|
2162
2530
|
? this.shiftListViewport(node, drawY, deltaY, bg)
|
|
2163
2531
|
: (() => {
|
|
@@ -2170,7 +2538,10 @@ export class PreviewUIRuntime {
|
|
|
2170
2538
|
const listClip = this.intersectClipRect(scrollClip, { x: node.box.x, y: drawY, w: node.box.w, h: node.box.h });
|
|
2171
2539
|
this.gfx.withClipRect(listClip, () => {
|
|
2172
2540
|
const tx = node.box.x + node.box.w - 4;
|
|
2173
|
-
|
|
2541
|
+
// Halve the track color at the ACTIVE depth (device parity: the
|
|
2542
|
+
// runtime's UI_DIM_MASK is 0x7BEF on 565, 0x7F7F7F on 888 — the old
|
|
2543
|
+
// 565-only mask channel-shifted the track on rgb888 targets).
|
|
2544
|
+
const trackColor = dimRuntimeColor(node.fg);
|
|
2174
2545
|
this.gfx.fillRect(tx, drawY, 3, node.box.h, trackColor);
|
|
2175
2546
|
const thumbH = Math.max(8, Math.trunc((node.box.h * node.box.h) / listContentH));
|
|
2176
2547
|
const maxScroll = Math.max(1, listContentH - node.box.h);
|
|
@@ -2307,7 +2678,11 @@ export class PreviewUIRuntime {
|
|
|
2307
2678
|
}
|
|
2308
2679
|
}
|
|
2309
2680
|
}
|
|
2310
|
-
drawButtonNode(node, displayText,
|
|
2681
|
+
drawButtonNode(node, displayText, bColorIn, fillBgIn, drawY, ts) {
|
|
2682
|
+
// HTML disabled: halve the drawn colors (web-like faded control;
|
|
2683
|
+
// device parity: NODE_BUTTON does the same).
|
|
2684
|
+
const bColor = node.disabled ? dimDisabledColor(bColorIn) : bColorIn;
|
|
2685
|
+
const fillBg = node.disabled ? dimDisabledColor(fillBgIn) : fillBgIn;
|
|
2311
2686
|
if (node.borderRadius > 0 && node.hasBg)
|
|
2312
2687
|
this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, fillBg);
|
|
2313
2688
|
else if (node.hasBg)
|
|
@@ -2334,7 +2709,9 @@ export class PreviewUIRuntime {
|
|
|
2334
2709
|
// selected option), so clear the previous text rect before redrawing.
|
|
2335
2710
|
drawSelectNode(node, displayText, bColor, fillBg, drawY, ts) {
|
|
2336
2711
|
const insets = this.textInsets(node);
|
|
2337
|
-
|
|
2712
|
+
// The right end reserves 14px for the dropdown chevron — the label wraps
|
|
2713
|
+
// against the reduced width, never under the chevron.
|
|
2714
|
+
const contentW = Math.max(1, node.box.w - insets.left - insets.right - 14);
|
|
2338
2715
|
const layout = this.textLayout(node, displayText, contentW, ts);
|
|
2339
2716
|
const clearW = Math.max(node.box.w, node.lastTextWidth ?? 0, layout.width + insets.left + insets.right);
|
|
2340
2717
|
const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height + insets.top + insets.bottom);
|
|
@@ -2351,34 +2728,75 @@ export class PreviewUIRuntime {
|
|
|
2351
2728
|
this.drawNodeBorder(node, node.box.x, drawY, bColor);
|
|
2352
2729
|
const textX = node.box.x + insets.left;
|
|
2353
2730
|
const textY = drawY + insets.top;
|
|
2354
|
-
|
|
2731
|
+
// Reserve the right end for the dropdown chevron so the label never
|
|
2732
|
+
// collides with it (mirrors NODE_SELECT on device).
|
|
2733
|
+
const textW = Math.max(1, node.box.w - insets.left - insets.right - 14);
|
|
2355
2734
|
const textH = Math.max(1, node.box.h - insets.top - insets.bottom);
|
|
2356
2735
|
const top = textY + Math.trunc((textH - layout.height) / 2);
|
|
2357
2736
|
const glyphBg = node.opacity < 100 ? blendRuntime(node.bg, this.parentClearColor(node), node.opacity) : (node.hasBg ? node.bg : node.clearColor);
|
|
2358
2737
|
this.drawTextLines(node, displayText, textX, top, textW, ts, node.fg, glyphBg, node.textAlign);
|
|
2738
|
+
// Dropdown chevron: a small solid ▾ at the right end — the affordance
|
|
2739
|
+
// that the control opens/cycles options.
|
|
2740
|
+
const chX = node.box.x + node.box.w - insets.right - 11;
|
|
2741
|
+
const chY = drawY + Math.trunc((node.box.h - 5) / 2);
|
|
2742
|
+
this.gfx.fillRect(chX, chY, 9, 1, node.fg);
|
|
2743
|
+
this.gfx.fillRect(chX + 1, chY + 1, 7, 1, node.fg);
|
|
2744
|
+
this.gfx.fillRect(chX + 2, chY + 2, 5, 1, node.fg);
|
|
2745
|
+
this.gfx.fillRect(chX + 3, chY + 3, 3, 1, node.fg);
|
|
2746
|
+
this.gfx.fillRect(chX + 4, chY + 4, 1, 1, node.fg);
|
|
2359
2747
|
}
|
|
2360
2748
|
drawCheckNode(node, displayText, drawY, ts) {
|
|
2361
2749
|
const layout = this.textLayout(node, displayText, Math.max(0, node.box.w - 22), ts);
|
|
2362
2750
|
const clearW = Math.max(node.box.w, node.lastTextWidth);
|
|
2363
2751
|
const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height);
|
|
2364
|
-
|
|
2752
|
+
// border-radius > 0 (kit .switch pills): clear the full text rect to the
|
|
2753
|
+
// backdrop, then paint the rounded box. Device parity: NODE_CHECK does the
|
|
2754
|
+
// same via ui_display_fill_round_rect. The :checked pair (kit wires it to
|
|
2755
|
+
// --primary/--primary-foreground) swaps the TRACK color when on.
|
|
2756
|
+
const onFill = node.value && node.checkedBg >= 0 ? node.checkedBg : node.bg;
|
|
2757
|
+
if (node.borderRadius > 0 && node.hasBg) {
|
|
2758
|
+
this.gfx.fillRect(node.box.x, drawY, clearW, clearH, this.parentClearColor(node));
|
|
2759
|
+
this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, onFill);
|
|
2760
|
+
}
|
|
2761
|
+
else {
|
|
2762
|
+
this.gfx.fillRect(node.box.x, drawY, clearW, clearH, node.hasBg ? node.bg : node.clearColor);
|
|
2763
|
+
}
|
|
2365
2764
|
node.lastTextWidth = 22 + layout.width;
|
|
2366
2765
|
node.lastTextHeight = Math.max(layout.height, 16);
|
|
2367
|
-
|
|
2766
|
+
// Pills inset the 16px indicator from the left edge so the knob doesn't
|
|
2767
|
+
// touch the rounded end; plain checkboxes keep it flush (unchanged look).
|
|
2768
|
+
// The knob SLIDES with state — left when off, right when on — the switch
|
|
2769
|
+
// affordance (static jump; no travel animation).
|
|
2770
|
+
const knobSlide = node.borderRadius > 0 ? Math.max(0, node.box.w - 16 - 6) : 0;
|
|
2771
|
+
const cbX = (node.borderRadius > 0 ? node.box.x + 3 : node.box.x) + (node.value ? knobSlide : 0);
|
|
2368
2772
|
// Vertically center the 16px indicator within the box so a tall
|
|
2369
2773
|
// (touch-friendly) checkbox doesn't pin the indicator to the top.
|
|
2370
2774
|
// (box.h - 16) / 2 is 0 for the default 16px-tall box.
|
|
2371
2775
|
const checkOff = Math.max(0, ((node.box.h - 16) / 2) | 0);
|
|
2372
2776
|
const cbY = drawY + checkOff;
|
|
2373
2777
|
if (node.value) {
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2778
|
+
if (node.borderRadius > 0) {
|
|
2779
|
+
// Switch pill: the knob stays a knob — a solid circle when on, no
|
|
2780
|
+
// checkbox square + checkmark. Same geometry as the radio indicator.
|
|
2781
|
+
// The knob carries the :checked color (primary-foreground).
|
|
2782
|
+
this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.checkedFg >= 0 ? node.checkedFg : node.fg);
|
|
2783
|
+
}
|
|
2784
|
+
else {
|
|
2785
|
+
// Checked face carries the :checked background (primary), the
|
|
2786
|
+
// checkmark its color (primary-foreground).
|
|
2787
|
+
this.gfx.fillRect(cbX, cbY, 16, 16, node.checkedBg >= 0 ? node.checkedBg : node.fg);
|
|
2788
|
+
const inv = node.checkedFg >= 0 ? node.checkedFg : (node.hasBg ? node.bg : node.clearColor);
|
|
2789
|
+
this.gfx.drawLine(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
|
|
2790
|
+
this.gfx.drawLine(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
|
|
2791
|
+
this.gfx.drawLine(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
|
|
2792
|
+
this.gfx.drawLine(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
|
|
2793
|
+
this.gfx.drawLine(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
|
|
2794
|
+
this.gfx.drawLine(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
else if (node.borderRadius > 0) {
|
|
2798
|
+
// Off-state pill: hollow circular knob outline on the track.
|
|
2799
|
+
this.gfx.drawCircle(cbX + 8, cbY + 8, 7, node.fg);
|
|
2382
2800
|
}
|
|
2383
2801
|
else {
|
|
2384
2802
|
this.gfx.drawRect(cbX, cbY, 16, 16, node.fg);
|
|
@@ -2397,7 +2815,8 @@ export class PreviewUIRuntime {
|
|
|
2397
2815
|
const radioOff = Math.max(0, ((node.box.h - 16) / 2) | 0);
|
|
2398
2816
|
const cbY = drawY + radioOff;
|
|
2399
2817
|
if (node.value) {
|
|
2400
|
-
|
|
2818
|
+
// The selected ring carries the :checked background (primary).
|
|
2819
|
+
this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.checkedBg >= 0 ? node.checkedBg : node.fg);
|
|
2401
2820
|
this.gfx.fillCircle(cbX + 8, cbY + 8, 3, node.hasBg ? node.bg : node.clearColor);
|
|
2402
2821
|
}
|
|
2403
2822
|
else {
|
|
@@ -2436,7 +2855,7 @@ export class PreviewUIRuntime {
|
|
|
2436
2855
|
const bh = node.box.h;
|
|
2437
2856
|
const fgCol = node.fg;
|
|
2438
2857
|
const bgCol = node.hasBg ? node.bg : node.clearColor;
|
|
2439
|
-
const dimFg = (fgCol
|
|
2858
|
+
const dimFg = dimRuntimeColor(fgCol);
|
|
2440
2859
|
const trackY = by + Math.trunc(bh / 2);
|
|
2441
2860
|
const rangeMin = node.rangeMin;
|
|
2442
2861
|
const rangeMax = node.rangeMax;
|
|
@@ -2477,13 +2896,16 @@ export class PreviewUIRuntime {
|
|
|
2477
2896
|
const by = drawY;
|
|
2478
2897
|
const bw = node.box.w;
|
|
2479
2898
|
const bh = node.box.h;
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
const
|
|
2899
|
+
// HTML disabled: halve the drawn colors (web-like faded control;
|
|
2900
|
+
// device parity: NODE_INPUT does the same).
|
|
2901
|
+
const dis = node.disabled === true;
|
|
2902
|
+
const bgCol = node.hasBg ? (dis ? dimDisabledColor(node.bg) : node.bg) : (dis ? dimDisabledColor(node.clearColor) : node.clearColor);
|
|
2903
|
+
const fgCol = dis ? dimDisabledColor(node.fg) : node.fg;
|
|
2904
|
+
const border = dis ? dimDisabledColor(node.borderColor || node.fg) : (node.borderColor || fgCol);
|
|
2483
2905
|
const borderStyle = node.borderStyle || 1;
|
|
2484
2906
|
const borderWidth = node.borderWidth || 1;
|
|
2485
2907
|
const displayText = node.textBuffer || node.placeholder || node.text || "";
|
|
2486
|
-
const textColor = node.textBuffer ? fgCol : (
|
|
2908
|
+
const textColor = node.textBuffer ? fgCol : dimRuntimeColor(fgCol);
|
|
2487
2909
|
const ts = this.nodeTextSize(node);
|
|
2488
2910
|
const clippedText = this.clipTextToWidth(displayText, Math.max(0, bw - 8), ts, node.fontFace, node.letterSpacing);
|
|
2489
2911
|
if (node.borderRadius > 0)
|
|
@@ -2494,13 +2916,25 @@ export class PreviewUIRuntime {
|
|
|
2494
2916
|
this.drawText(clippedText, bx + 4, by + Math.trunc((bh - this.textHeight(ts, node.fontFace)) / 2), textColor, bgCol, ts, node.fontAntialias, node.fontFace, node.letterSpacing);
|
|
2495
2917
|
}
|
|
2496
2918
|
hitTest(tx, ty) {
|
|
2497
|
-
|
|
2919
|
+
// Phase 1 — the event target: the TOPMOST node containing the tap
|
|
2920
|
+
// (stacking order), regardless of handlers. Filtering for
|
|
2921
|
+
// handler-bearing nodes here let a tap fall through SIBLING layers — a
|
|
2922
|
+
// modal card (no handler) over a click-to-close scrim (handler) sent
|
|
2923
|
+
// every card tap to the scrim, so any tap dismissed the dialog. The DOM
|
|
2924
|
+
// rule: the click targets the topmost element, then bubbles to
|
|
2925
|
+
// ANCESTORS only — never sideways to a covered sibling.
|
|
2926
|
+
let target = -1;
|
|
2498
2927
|
for (let i = 0; i < this.nodes.length; i++) {
|
|
2499
2928
|
const node = this.nodes[i];
|
|
2500
2929
|
if (!this.isEffectivelyVisible(node))
|
|
2501
2930
|
continue;
|
|
2502
2931
|
if (!this.isActiveNode(node))
|
|
2503
2932
|
continue;
|
|
2933
|
+
if (this.insideClosedDrawer(i))
|
|
2934
|
+
continue;
|
|
2935
|
+
// HTML disabled: not a tap target (device parity: ui_hit_test skips it).
|
|
2936
|
+
if (node.disabled)
|
|
2937
|
+
continue;
|
|
2504
2938
|
const drawX = this.drawXForNode(i);
|
|
2505
2939
|
const drawY = this.drawYForNode(i);
|
|
2506
2940
|
if (tx >= drawX && tx < drawX + node.box.w && ty >= drawY && ty < drawY + node.box.h) {
|
|
@@ -2511,11 +2945,23 @@ export class PreviewUIRuntime {
|
|
|
2511
2945
|
const clip = this.scrollClipForNode(i);
|
|
2512
2946
|
if (clip && (tx < clip.x || tx >= clip.x + clip.w || ty < clip.y || ty >= clip.y + clip.h))
|
|
2513
2947
|
continue;
|
|
2514
|
-
if (
|
|
2515
|
-
|
|
2948
|
+
if (target < 0 || this.drawsBefore(this.nodes[target], node))
|
|
2949
|
+
target = i;
|
|
2516
2950
|
}
|
|
2517
2951
|
}
|
|
2518
|
-
|
|
2952
|
+
// Phase 2 — bubbling: from the target up through its ancestors.
|
|
2953
|
+
// Interactive kinds match even with no JS handler wired (:pressed/
|
|
2954
|
+
// transition feedback); otherwise the first handler-bearing ancestor.
|
|
2955
|
+
let n = target;
|
|
2956
|
+
while (n >= 0) {
|
|
2957
|
+
const node = this.nodes[n];
|
|
2958
|
+
if (this.hasAnyHandler(n))
|
|
2959
|
+
return n;
|
|
2960
|
+
if (node.parentIndex < 0 || node.parentIndex >= this.nodes.length)
|
|
2961
|
+
break;
|
|
2962
|
+
n = node.parentIndex;
|
|
2963
|
+
}
|
|
2964
|
+
return -1;
|
|
2519
2965
|
}
|
|
2520
2966
|
// Unified scroll hit-scan: one pass over scrollable nodes. Lists are
|
|
2521
2967
|
// scrollable (UA rule) and their contentHeight is seeded on the node by
|
|
@@ -2712,6 +3158,16 @@ export class PreviewUIRuntime {
|
|
|
2712
3158
|
this.lastTouchTime = now;
|
|
2713
3159
|
return;
|
|
2714
3160
|
}
|
|
3161
|
+
// Modal <select> list: route taps to the option rows; swallow normal
|
|
3162
|
+
// hit-testing/scrolling while open (device parity: ui_touch_up).
|
|
3163
|
+
if (this.selectMenuNode >= 0) {
|
|
3164
|
+
if (this.touchState === 0 && now - this.lastTouchTime >= UI_TOUCH_DEBOUNCE_MS) {
|
|
3165
|
+
this.touchState = 1;
|
|
3166
|
+
this.touchDownTime = now;
|
|
3167
|
+
}
|
|
3168
|
+
this.lastTouchTime = now;
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
2715
3171
|
if (this.touchState === 0) {
|
|
2716
3172
|
if (now - this.lastTouchTime < UI_TOUCH_DEBOUNCE_MS)
|
|
2717
3173
|
return;
|
|
@@ -2788,6 +3244,16 @@ export class PreviewUIRuntime {
|
|
|
2788
3244
|
this.lastReleaseTime = now;
|
|
2789
3245
|
return;
|
|
2790
3246
|
}
|
|
3247
|
+
if (this.selectMenuNode >= 0) {
|
|
3248
|
+
this.selectMenuHandleTouch(this.lastTouchX, this.lastTouchY);
|
|
3249
|
+
this.touchState = 0;
|
|
3250
|
+
this.touchNode = -1;
|
|
3251
|
+
this.isDragging = false;
|
|
3252
|
+
this.scrollNode = -1;
|
|
3253
|
+
this.rangeNode = -1;
|
|
3254
|
+
this.lastReleaseTime = now;
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
2791
3257
|
const elapsed = now - this.touchDownTime;
|
|
2792
3258
|
const node = this.touchNode;
|
|
2793
3259
|
// Release the scroll owner: arm a bounded settle (bounce-back / edge-snap).
|
|
@@ -2813,6 +3279,19 @@ export class PreviewUIRuntime {
|
|
|
2813
3279
|
this.setPressed(node, false);
|
|
2814
3280
|
this.markDirty(node);
|
|
2815
3281
|
}
|
|
3282
|
+
// Open drawers close on outside taps (shadcn drawer behavior). Taps
|
|
3283
|
+
// inside an open drawer hit its controls normally.
|
|
3284
|
+
for (const [idx, state] of this.drawerStates) {
|
|
3285
|
+
if (!state.open || state.progress < 1)
|
|
3286
|
+
continue;
|
|
3287
|
+
const drawer = this.nodes[idx];
|
|
3288
|
+
const dx = this.drawXForNode(idx);
|
|
3289
|
+
const dy = this.drawYForNode(idx);
|
|
3290
|
+
if (this.lastTouchX < dx || this.lastTouchX >= dx + drawer.box.w ||
|
|
3291
|
+
this.lastTouchY < dy || this.lastTouchY >= dy + drawer.box.h) {
|
|
3292
|
+
state.open = false;
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
2816
3295
|
// Resume any `await ui.onTap()` awaiter. Runs for EVERY completed tap —
|
|
2817
3296
|
// including empty-space taps (node == -1) and holds released above — so
|
|
2818
3297
|
// "wake on any touch" works. After the click/release dispatch so onClick
|
|
@@ -2833,9 +3312,9 @@ export class PreviewUIRuntime {
|
|
|
2833
3312
|
this.markDirty(nodeIndex);
|
|
2834
3313
|
}
|
|
2835
3314
|
else if (node.tag === "select") {
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
this.
|
|
3315
|
+
// Tap opens a modal option list (device parity: ui_select_menu_open);
|
|
3316
|
+
// selecting a row in the modal sets the value.
|
|
3317
|
+
this.openSelectMenu(nodeIndex);
|
|
2839
3318
|
}
|
|
2840
3319
|
else if (node.tag === "radio") {
|
|
2841
3320
|
for (const candidate of this.nodes) {
|
|
@@ -2861,6 +3340,317 @@ export class PreviewUIRuntime {
|
|
|
2861
3340
|
}
|
|
2862
3341
|
return node.inputType === "number" ? DEFAULT_NUMBER_KEYBOARD : DEFAULT_ALPHA_KEYBOARD;
|
|
2863
3342
|
}
|
|
3343
|
+
// ── <drawer>: slide-in panel modals ─────────────────────────────────────
|
|
3344
|
+
// Drawers are author-styled absolute panels (position/left/top/width/height
|
|
3345
|
+
// in CSS); the runtime slides the subtree in from the configured edge via
|
|
3346
|
+
// per-node transform offsets, hides the subtree while closed, and closes on
|
|
3347
|
+
// outside taps. Sliding mutates offsets + dirties the subtree exactly like
|
|
3348
|
+
// the keyframe transform path, so all existing clear/repair machinery runs.
|
|
3349
|
+
/** node index -> { open, progress } for every <drawer>; absent = closed. */
|
|
3350
|
+
drawerStates = new Map();
|
|
3351
|
+
// ── Debug overlay data (preview-only; the client canvas draws the boxes) ──
|
|
3352
|
+
/** True while the client wants per-frame debug geometry + paint rects. */
|
|
3353
|
+
debugCapture = false;
|
|
3354
|
+
/** Node paint rects drawn during this tick (cleared at tick start). */
|
|
3355
|
+
debugPaintedRects = [];
|
|
3356
|
+
/** Drawer animation step per tick (~180ms full slide). */
|
|
3357
|
+
/** <toast> auto-close windows, keyed by drawer slot node index. */
|
|
3358
|
+
toastElapsed = new Map();
|
|
3359
|
+
applyDrawers(deltaMs) {
|
|
3360
|
+
// <toast duration>: once fully open, count up and auto-close.
|
|
3361
|
+
for (const [idx, state] of this.drawerStates) {
|
|
3362
|
+
const node = this.nodes[idx];
|
|
3363
|
+
const duration = node.toastDuration ?? 0;
|
|
3364
|
+
if (duration > 0 && state.open && state.progress === 1) {
|
|
3365
|
+
const elapsed = (this.toastElapsed.get(idx) ?? 0) + deltaMs;
|
|
3366
|
+
if (elapsed >= duration) {
|
|
3367
|
+
state.open = false;
|
|
3368
|
+
this.toastElapsed.delete(idx);
|
|
3369
|
+
}
|
|
3370
|
+
else {
|
|
3371
|
+
this.toastElapsed.set(idx, elapsed);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
else if (!state.open) {
|
|
3375
|
+
this.toastElapsed.delete(idx);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
for (const [idx, state] of this.drawerStates) {
|
|
3379
|
+
const target = state.open ? 1 : 0;
|
|
3380
|
+
if (state.progress === target)
|
|
3381
|
+
continue;
|
|
3382
|
+
const step = Math.min(1, deltaMs / 180);
|
|
3383
|
+
const next = state.progress + Math.sign(target - state.progress) * Math.min(step, Math.abs(target - state.progress));
|
|
3384
|
+
this.setDrawerProgress(idx, next);
|
|
3385
|
+
if (next === 0 && !state.open)
|
|
3386
|
+
this.drawerStates.delete(idx);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
drawerNodeById(id) {
|
|
3390
|
+
return this.nodes.find((n) => (n.drawerSide ?? -1) >= 0 && n.id === id);
|
|
3391
|
+
}
|
|
3392
|
+
drawerOpen(id) {
|
|
3393
|
+
const node = this.drawerNodeById(id);
|
|
3394
|
+
if (!node)
|
|
3395
|
+
return;
|
|
3396
|
+
this.toastElapsed.delete(node.index); // <toast>: restart the auto-close window
|
|
3397
|
+
if (!this.drawerStates.has(node.index)) {
|
|
3398
|
+
this.drawerStates.set(node.index, { open: true, progress: 0 });
|
|
3399
|
+
this.setDrawerProgress(node.index, 0);
|
|
3400
|
+
}
|
|
3401
|
+
else {
|
|
3402
|
+
this.drawerStates.get(node.index).open = true;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
drawerClose(id) {
|
|
3406
|
+
if (id !== undefined) {
|
|
3407
|
+
const node = this.drawerNodeById(id);
|
|
3408
|
+
if (node) {
|
|
3409
|
+
const st = this.drawerStates.get(node.index);
|
|
3410
|
+
if (st)
|
|
3411
|
+
st.open = false;
|
|
3412
|
+
}
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
for (const st of this.drawerStates.values())
|
|
3416
|
+
st.open = false;
|
|
3417
|
+
}
|
|
3418
|
+
/** Slide extent for a drawer or toast: the panel must clear the display
|
|
3419
|
+
* edge completely — its own height parks a bottom:0 panel's top row at
|
|
3420
|
+
* the fold (a visible sliver after close). Centered dialogs don't slide. */
|
|
3421
|
+
drawerExtent(node) {
|
|
3422
|
+
const n = node;
|
|
3423
|
+
const side = n.drawerSide ?? 0;
|
|
3424
|
+
const extent = side === 2 || side === 3 ? node.box.w : node.box.h;
|
|
3425
|
+
if (side === 4)
|
|
3426
|
+
return 0;
|
|
3427
|
+
// Rest position (box minus ancestor scroll; transform offsets hold the
|
|
3428
|
+
// current slide), then clear the display edge along the travel axis.
|
|
3429
|
+
let restX = node.box.x;
|
|
3430
|
+
let restY = node.box.y;
|
|
3431
|
+
let parent = node.parentIndex;
|
|
3432
|
+
while (parent >= 0 && this.nodes[parent]) {
|
|
3433
|
+
const pn = this.nodes[parent];
|
|
3434
|
+
if (pn.scrollable)
|
|
3435
|
+
restY -= pn.scrollY;
|
|
3436
|
+
parent = pn.parentIndex;
|
|
3437
|
+
}
|
|
3438
|
+
const need = side === 0 ? this.snapshot.program.height - restY
|
|
3439
|
+
: side === 1 ? restY + node.box.h
|
|
3440
|
+
: side === 2 ? restX + node.box.w
|
|
3441
|
+
: this.snapshot.program.width - restX;
|
|
3442
|
+
return Math.max(extent, need);
|
|
3443
|
+
}
|
|
3444
|
+
/** Apply a slide progress to the drawer subtree: offsets shift from the
|
|
3445
|
+
* edge; the subtree is hidden at 0. Erases the previous frame's region via
|
|
3446
|
+
* the standard subtree clear + marks descendants dirty (same contract as
|
|
3447
|
+
* animated transform changes). */
|
|
3448
|
+
setDrawerProgress(nodeIndex, progress) {
|
|
3449
|
+
const state = this.drawerStates.get(nodeIndex);
|
|
3450
|
+
if (!state)
|
|
3451
|
+
return;
|
|
3452
|
+
const node = this.nodes[nodeIndex];
|
|
3453
|
+
const side = node.drawerSide ?? 0;
|
|
3454
|
+
const travel = Math.round((1 - progress) * this.drawerExtent(node));
|
|
3455
|
+
const dx = side === 2 ? -travel : side === 3 ? travel : 0;
|
|
3456
|
+
const dy = side === 1 ? -travel : side === 0 ? travel : 0;
|
|
3457
|
+
// Erase the subtree's current paint before moving it. The clear fills
|
|
3458
|
+
// with the parent background only — everything the drawer COVERED
|
|
3459
|
+
// (siblings underneath) must repaint too, so mark every active-screen
|
|
3460
|
+
// node outside the drawer subtree dirty. Full-screen redraw per slide
|
|
3461
|
+
// frame is the same contract as the select modal's close path.
|
|
3462
|
+
const rect = this.currentSubtreePaintRect(node);
|
|
3463
|
+
if (rect)
|
|
3464
|
+
this.clearNodePaintRect(node, rect);
|
|
3465
|
+
for (const n of this.nodes) {
|
|
3466
|
+
if (!this.isActiveNode(n) || n.index >= node.index && n.index < node.subtreeEnd)
|
|
3467
|
+
continue;
|
|
3468
|
+
if (this.insideClosedDrawer(n.index))
|
|
3469
|
+
continue;
|
|
3470
|
+
n.dirty = true;
|
|
3471
|
+
}
|
|
3472
|
+
for (let i = node.index; i < node.subtreeEnd; i++) {
|
|
3473
|
+
const n = this.nodes[i];
|
|
3474
|
+
if (!this.isActiveNode(n))
|
|
3475
|
+
continue;
|
|
3476
|
+
n.transformOffsetX = i === node.index ? dx : n.transformOffsetX;
|
|
3477
|
+
n.transformOffsetY = i === node.index ? dy : n.transformOffsetY;
|
|
3478
|
+
if (i !== node.index) {
|
|
3479
|
+
// Descendants keep a private offset delta (they don't inherit the
|
|
3480
|
+
// drawer's transformOffset), so store the slide delta per node.
|
|
3481
|
+
n.__drawerDx = dx;
|
|
3482
|
+
n.__drawerDy = dy;
|
|
3483
|
+
}
|
|
3484
|
+
n.dirty = true;
|
|
3485
|
+
}
|
|
3486
|
+
state.progress = progress;
|
|
3487
|
+
this.directFrameChanged = true;
|
|
3488
|
+
}
|
|
3489
|
+
/** True while the node sits inside a fully-closed drawer (skip in draws and
|
|
3490
|
+
* hit tests). Drawers with no state entry are closed. */
|
|
3491
|
+
insideClosedDrawer(nodeIndex) {
|
|
3492
|
+
// Centered overlay roots (<dialog>, side 4) hide via the closed-state
|
|
3493
|
+
// gate: their travel is 0, so offsets can't move them off-panel. Edge
|
|
3494
|
+
// drawer/toast roots keep offset-only hiding (the bottom peek).
|
|
3495
|
+
const self = this.nodes[nodeIndex];
|
|
3496
|
+
if ((self.drawerSide ?? -1) === 4) {
|
|
3497
|
+
const st = this.drawerStates.get(nodeIndex);
|
|
3498
|
+
return !st || (!st.open && st.progress === 0);
|
|
3499
|
+
}
|
|
3500
|
+
for (const [idx, state] of this.drawerStates) {
|
|
3501
|
+
const drawer = this.nodes[idx];
|
|
3502
|
+
if (nodeIndex >= drawer.index && nodeIndex < drawer.subtreeEnd && state.progress === 0 && !state.open)
|
|
3503
|
+
return true;
|
|
3504
|
+
}
|
|
3505
|
+
return this.drawerAncestorClosed(nodeIndex);
|
|
3506
|
+
}
|
|
3507
|
+
/** Walk ancestors: any drawer ancestor without an open/animating state. */
|
|
3508
|
+
drawerAncestorClosed(nodeIndex) {
|
|
3509
|
+
let parent = this.nodes[nodeIndex]?.parentIndex ?? -1;
|
|
3510
|
+
while (parent >= 0 && this.nodes[parent]) {
|
|
3511
|
+
const n = this.nodes[parent];
|
|
3512
|
+
if ((n.drawerSide ?? -1) >= 0) {
|
|
3513
|
+
const st = this.drawerStates.get(parent);
|
|
3514
|
+
return !st || (!st.open && st.progress === 0);
|
|
3515
|
+
}
|
|
3516
|
+
parent = this.nodes[parent].parentIndex;
|
|
3517
|
+
}
|
|
3518
|
+
return false;
|
|
3519
|
+
}
|
|
3520
|
+
/** Apply the closed slide offsets to every drawer subtree (initial seed and
|
|
3521
|
+
* post-navigate reset) so closed drawers never paint at rest position. */
|
|
3522
|
+
seedDrawersClosed() {
|
|
3523
|
+
for (const n of this.nodes) {
|
|
3524
|
+
const node = n;
|
|
3525
|
+
if ((node.drawerSide ?? -1) < 0)
|
|
3526
|
+
continue;
|
|
3527
|
+
if (this.drawerStates.has(node.index))
|
|
3528
|
+
continue;
|
|
3529
|
+
const side = node.drawerSide ?? 0;
|
|
3530
|
+
const travel = this.drawerExtent(node);
|
|
3531
|
+
const dx = side === 2 ? -travel : side === 3 ? travel : 0;
|
|
3532
|
+
const dy = side === 1 ? -travel : side === 0 ? travel : 0;
|
|
3533
|
+
node.transformOffsetX = (node.transformOffsetX ?? 0) + dx;
|
|
3534
|
+
node.transformOffsetY = (node.transformOffsetY ?? 0) + dy;
|
|
3535
|
+
for (let i = node.index + 1; i < node.subtreeEnd; i++) {
|
|
3536
|
+
const d = this.nodes[i];
|
|
3537
|
+
d.__drawerDx = dx;
|
|
3538
|
+
d.__drawerDy = dy;
|
|
3539
|
+
d.dirty = false;
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
/** Shared geometry of the select modal: a centered list panel sized to
|
|
3544
|
+
* the options (capped to ~60% of the screen height). Rows are option
|
|
3545
|
+
* text, the current one inverted (fill = select bg, text = inverted). */
|
|
3546
|
+
selectMenuGeometry() {
|
|
3547
|
+
const node = this.nodes[this.selectMenuNode];
|
|
3548
|
+
const options = node.options ?? [];
|
|
3549
|
+
const rowH = 22;
|
|
3550
|
+
const rows = options.length;
|
|
3551
|
+
// Anchor to the select itself: same left edge and width, so the modal
|
|
3552
|
+
// reads as the control's own dropdown (never overhangs toward a
|
|
3553
|
+
// scrollbar). Clamp to the screen for selects that extend past it.
|
|
3554
|
+
let x = node.box.x;
|
|
3555
|
+
let w = Math.max(node.box.w, 120);
|
|
3556
|
+
if (x + w > this.gfx.width)
|
|
3557
|
+
w = this.gfx.width - x;
|
|
3558
|
+
if (x < 0)
|
|
3559
|
+
x = 0;
|
|
3560
|
+
const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
|
|
3561
|
+
const y = Math.trunc((this.gfx.height - h) / 2);
|
|
3562
|
+
return { x, y, w, rowH, rows };
|
|
3563
|
+
}
|
|
3564
|
+
openSelectMenu(nodeIndex) {
|
|
3565
|
+
const node = this.nodes[nodeIndex];
|
|
3566
|
+
if (!node.options || node.options.length === 0)
|
|
3567
|
+
return;
|
|
3568
|
+
this.selectMenuNode = nodeIndex;
|
|
3569
|
+
this.selectMenuDirty = true;
|
|
3570
|
+
}
|
|
3571
|
+
/** Close the modal. When `repaint`, mark the whole tree dirty so the
|
|
3572
|
+
* overlay is erased by a full redraw (navigate() clears the screen
|
|
3573
|
+
* itself, so it passes false). */
|
|
3574
|
+
closeSelectMenu(repaint) {
|
|
3575
|
+
if (this.selectMenuNode < 0)
|
|
3576
|
+
return;
|
|
3577
|
+
this.selectMenuNode = -1;
|
|
3578
|
+
this.selectMenuDirty = false;
|
|
3579
|
+
if (repaint) {
|
|
3580
|
+
for (const node of this.nodes)
|
|
3581
|
+
node.dirty = true;
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
/** Stamp the modal overlay on top of the current framebuffer. Idempotent
|
|
3585
|
+
* per frame — the close path full-repaints beneath it. */
|
|
3586
|
+
drawSelectMenu() {
|
|
3587
|
+
if (this.selectMenuNode < 0)
|
|
3588
|
+
return;
|
|
3589
|
+
const node = this.nodes[this.selectMenuNode];
|
|
3590
|
+
if (!node.options || node.options.length === 0)
|
|
3591
|
+
return;
|
|
3592
|
+
const { x, y, w, rowH, rows } = this.selectMenuGeometry();
|
|
3593
|
+
const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
|
|
3594
|
+
// Themed panel: the select's own radius/border/bg — the modal reads as
|
|
3595
|
+
// the control's popover, matching the surrounding UI (shadcn tokens on
|
|
3596
|
+
// the .select class flow through: radius, border color, background).
|
|
3597
|
+
const radius = Math.min(node.borderRadius || 6, Math.trunc(Math.min(w, h) / 2));
|
|
3598
|
+
const panel = this.selectMenuPanelColor();
|
|
3599
|
+
const borderCol = node.borderColor || node.fg;
|
|
3600
|
+
this.gfx.fillRoundRect(x, y, w, h, radius, panel);
|
|
3601
|
+
this.gfx.drawRoundRect(x, y, w, h, radius, borderCol);
|
|
3602
|
+
const ts = this.nodeTextSize(node);
|
|
3603
|
+
const rowInset = Math.max(2, Math.trunc(radius / 2));
|
|
3604
|
+
// The selected row carries the :checked pair (the kit wires it to
|
|
3605
|
+
// --accent/--accent-foreground, shadcn's SelectItem selected state).
|
|
3606
|
+
const rowBg = node.checkedBg >= 0 ? node.checkedBg : node.fg;
|
|
3607
|
+
const rowFg = node.checkedFg >= 0 ? node.checkedFg : panel;
|
|
3608
|
+
for (let r = 0; r < rows; r++) {
|
|
3609
|
+
const ry = y + 4 + r * rowH;
|
|
3610
|
+
const current = r === node.value;
|
|
3611
|
+
if (current) {
|
|
3612
|
+
this.gfx.fillRoundRect(x + rowInset, ry, w - 2 * rowInset, rowH, Math.min(radius, 6), rowBg);
|
|
3613
|
+
}
|
|
3614
|
+
const text = clampText(node.options[r]?.text ?? "");
|
|
3615
|
+
const fg = current ? rowFg : node.fg;
|
|
3616
|
+
this.drawText(text, x + 22, ry + Math.trunc((rowH - 8 * ts) / 2), fg, panel, ts, node.fontAntialias, node.fontFace, 0);
|
|
3617
|
+
if (current) {
|
|
3618
|
+
// Check mark on the current row (the checked pair's color).
|
|
3619
|
+
const cx = x + 7;
|
|
3620
|
+
const cy = ry + Math.trunc(rowH / 2);
|
|
3621
|
+
this.gfx.drawLine(cx, cy, cx + 3, cy + 3, rowFg);
|
|
3622
|
+
this.gfx.drawLine(cx + 3, cy + 3, cx + 8, cy - 4, rowFg);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
this.selectMenuDirty = false;
|
|
3626
|
+
}
|
|
3627
|
+
/** Panel fill: the select's effective background, falling back to a
|
|
3628
|
+
* near-black card on unstyled selects. */
|
|
3629
|
+
selectMenuPanelColor() {
|
|
3630
|
+
const node = this.nodes[this.selectMenuNode];
|
|
3631
|
+
return node.hasBg ? node.bg : node.clearColor;
|
|
3632
|
+
}
|
|
3633
|
+
/** Route a tap while the modal is open: a row selects + closes; anything
|
|
3634
|
+
* else just closes (dismiss on outside tap). */
|
|
3635
|
+
selectMenuHandleTouch(tx, ty) {
|
|
3636
|
+
const node = this.nodes[this.selectMenuNode];
|
|
3637
|
+
if (!node.options) {
|
|
3638
|
+
this.closeSelectMenu(true);
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
const { x, y, w, rowH, rows } = this.selectMenuGeometry();
|
|
3642
|
+
const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
|
|
3643
|
+
if (tx >= x && tx < x + w && ty >= y && ty < y + h) {
|
|
3644
|
+
const row = Math.trunc((ty - y - 4) / rowH);
|
|
3645
|
+
if (row >= 0 && row < rows) {
|
|
3646
|
+
node.value = row;
|
|
3647
|
+
node.hasTextBinding = true;
|
|
3648
|
+
node.textBuffer = clampText(node.options[row]?.text ?? "");
|
|
3649
|
+
this.markDirty(node.index);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
this.closeSelectMenu(true);
|
|
3653
|
+
}
|
|
2864
3654
|
keyboardOpen(nodeIndex) {
|
|
2865
3655
|
const node = this.nodes[nodeIndex];
|
|
2866
3656
|
const template = this.keyboardTemplateForNode(node);
|
|
@@ -3183,15 +3973,80 @@ export class PreviewUIRuntime {
|
|
|
3183
3973
|
// Rewrite bare references to module-scoped variables into moduleScope.NAME
|
|
3184
3974
|
// so reads/writes hit the shared mutable binding (mirrors the device hoisting
|
|
3185
3975
|
// them to globals). Skip property accesses (foo.bar) so `screen.gauge.value`
|
|
3186
|
-
// etc. are untouched
|
|
3976
|
+
// etc. are untouched — and skip STRING/TEMPLATE LITERAL CONTENTS: prose like
|
|
3977
|
+
// "taps inside:" inside a template literal contains the identifier `taps`
|
|
3978
|
+
// as a whole word and must not be rewritten (only ${...} segments of
|
|
3979
|
+
// templates are code).
|
|
3187
3980
|
const names = this.moduleVarNames();
|
|
3188
3981
|
if (names.length) {
|
|
3189
3982
|
const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
3190
3983
|
const re = new RegExp(`(?<![\\w.$])\\b(${alt})\\b(?!\\s*:)`, "g");
|
|
3191
|
-
out =
|
|
3984
|
+
out = this.rewriteOutsideLiterals(out, re);
|
|
3192
3985
|
}
|
|
3193
3986
|
return out;
|
|
3194
3987
|
}
|
|
3988
|
+
/** Apply `re` to code segments only: skip '..'/".." literal contents, and
|
|
3989
|
+
* inside `..` templates recurse into ${...} interpolations (their
|
|
3990
|
+
* expressions are code; the literal text between them is not). */
|
|
3991
|
+
rewriteOutsideLiterals(text, re) {
|
|
3992
|
+
let out = "";
|
|
3993
|
+
let code = "";
|
|
3994
|
+
let i = 0;
|
|
3995
|
+
const flush = () => { out += code.replace(re, "moduleScope.$1"); code = ""; };
|
|
3996
|
+
while (i < text.length) {
|
|
3997
|
+
const ch = text[i];
|
|
3998
|
+
if (ch === "'" || ch === '"') {
|
|
3999
|
+
flush();
|
|
4000
|
+
let j = i + 1;
|
|
4001
|
+
while (j < text.length && text[j] !== ch) {
|
|
4002
|
+
if (text[j] === "\\")
|
|
4003
|
+
j++;
|
|
4004
|
+
j++;
|
|
4005
|
+
}
|
|
4006
|
+
out += text.slice(i, Math.min(j + 1, text.length));
|
|
4007
|
+
i = j + 1;
|
|
4008
|
+
continue;
|
|
4009
|
+
}
|
|
4010
|
+
if (ch === "`") {
|
|
4011
|
+
flush();
|
|
4012
|
+
// Emit the template piece by piece: literal runs verbatim (never
|
|
4013
|
+
// rewritten), ${...} interpolations recursed as code.
|
|
4014
|
+
out += "`";
|
|
4015
|
+
let j = i + 1;
|
|
4016
|
+
while (j < text.length && text[j] !== "`") {
|
|
4017
|
+
if (text[j] === "\\") {
|
|
4018
|
+
out += text.slice(j, j + 2);
|
|
4019
|
+
j += 2;
|
|
4020
|
+
continue;
|
|
4021
|
+
}
|
|
4022
|
+
if (text[j] === "$" && text[j + 1] === "{") {
|
|
4023
|
+
let depth = 1;
|
|
4024
|
+
let k = j + 2;
|
|
4025
|
+
while (k < text.length && depth > 0) {
|
|
4026
|
+
if (text[k] === "{")
|
|
4027
|
+
depth++;
|
|
4028
|
+
else if (text[k] === "}")
|
|
4029
|
+
depth--;
|
|
4030
|
+
k++;
|
|
4031
|
+
}
|
|
4032
|
+
out += "${" + this.rewriteOutsideLiterals(text.slice(j + 2, k - 1), re) + "}";
|
|
4033
|
+
j = k;
|
|
4034
|
+
continue;
|
|
4035
|
+
}
|
|
4036
|
+
out += text[j];
|
|
4037
|
+
j++;
|
|
4038
|
+
}
|
|
4039
|
+
if (j < text.length)
|
|
4040
|
+
out += "`";
|
|
4041
|
+
i = j + 1;
|
|
4042
|
+
continue;
|
|
4043
|
+
}
|
|
4044
|
+
code += ch;
|
|
4045
|
+
i++;
|
|
4046
|
+
}
|
|
4047
|
+
flush();
|
|
4048
|
+
return out;
|
|
4049
|
+
}
|
|
3195
4050
|
/** Rewrite signal getter/setter syntax to plain variable reads/writes. See
|
|
3196
4051
|
* normalizeScript for the lowering contract; this is the preview counterpart
|
|
3197
4052
|
* of ui-callback-lowering.ts's rules 2 and 3. */
|
|
@@ -3276,6 +4131,32 @@ export class PreviewUIRuntime {
|
|
|
3276
4131
|
return fn;
|
|
3277
4132
|
},
|
|
3278
4133
|
navigate: (screenIdx) => this.navigate(screenIdx),
|
|
4134
|
+
// Device parity with the SDL-only ui.window.* lowering
|
|
4135
|
+
// (ui_window_set_title / ui_window_set_icon). In the preview, setTitle
|
|
4136
|
+
// updates the browser tab title when a DOM is present (Node tests have
|
|
4137
|
+
// none); setIcon has no browser equivalent and no-ops, matching the
|
|
4138
|
+
// hardware targets' no-op.
|
|
4139
|
+
window: {
|
|
4140
|
+
setTitle: (title) => {
|
|
4141
|
+
const doc = globalThis.document;
|
|
4142
|
+
if (doc)
|
|
4143
|
+
doc.title = String(title);
|
|
4144
|
+
},
|
|
4145
|
+
setIcon: (_path) => { },
|
|
4146
|
+
},
|
|
4147
|
+
// <drawer> controls: open by element id, close by id or all open
|
|
4148
|
+
// drawers. Device parity with ui_drawer_open / ui_drawer_close.
|
|
4149
|
+
drawer: {
|
|
4150
|
+
open: (id) => this.drawerOpen(id),
|
|
4151
|
+
close: (id) => this.drawerClose(id),
|
|
4152
|
+
},
|
|
4153
|
+
// <dialog>/<toast> controls: same slot machinery as drawers (a dialog
|
|
4154
|
+
// is a centered drawer, a toast auto-closes via its duration attr).
|
|
4155
|
+
dialog: {
|
|
4156
|
+
open: (id) => this.drawerOpen(id),
|
|
4157
|
+
close: (id) => this.drawerClose(id),
|
|
4158
|
+
},
|
|
4159
|
+
toast: (id) => this.drawerOpen(id),
|
|
3279
4160
|
};
|
|
3280
4161
|
}
|
|
3281
4162
|
}
|