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