@qontinui/ui-bridge-native 0.6.10 → 0.6.12

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.
@@ -1,3 +1,176 @@
1
+ // src/core/vision-fields.ts
2
+ var INTERACTIVE_HANDLER_NAMES = /* @__PURE__ */ new Set([
3
+ "onPress",
4
+ "onPressIn",
5
+ "onPressOut",
6
+ "onLongPress",
7
+ "onChangeText",
8
+ "onValueChange",
9
+ "onChange",
10
+ "onSubmitEditing",
11
+ "onClick",
12
+ "onTouchStart",
13
+ "onTouchEnd"
14
+ ]);
15
+ var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
16
+ "button",
17
+ "input",
18
+ "switch",
19
+ "checkbox",
20
+ "radio",
21
+ "touchable",
22
+ "pressable",
23
+ "listItem"
24
+ ]);
25
+ function roleForType(type) {
26
+ switch (type) {
27
+ case "button":
28
+ case "touchable":
29
+ case "pressable":
30
+ return "button";
31
+ case "input":
32
+ return "textbox";
33
+ case "text":
34
+ return "text";
35
+ case "switch":
36
+ return "switch";
37
+ case "checkbox":
38
+ return "checkbox";
39
+ case "radio":
40
+ return "radio";
41
+ case "image":
42
+ return "img";
43
+ case "list":
44
+ return "list";
45
+ case "listItem":
46
+ return "listitem";
47
+ case "modal":
48
+ return "dialog";
49
+ case "scroll":
50
+ case "view":
51
+ case "custom":
52
+ default:
53
+ return void 0;
54
+ }
55
+ }
56
+ function deriveInteractable(type, handlerNames) {
57
+ if (INTERACTIVE_TYPES.has(type)) return true;
58
+ if (handlerNames) {
59
+ for (const name of handlerNames) {
60
+ if (INTERACTIVE_HANDLER_NAMES.has(name)) return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+ function deriveText(state, label) {
66
+ const candidates = [state.value, state.textContent, label];
67
+ for (const c of candidates) {
68
+ if (typeof c === "string") {
69
+ const trimmed = c.trim();
70
+ if (trimmed.length > 0) return trimmed;
71
+ }
72
+ }
73
+ return void 0;
74
+ }
75
+ var NAMED_COLORS = {
76
+ black: { r: 0, g: 0, b: 0 },
77
+ white: { r: 255, g: 255, b: 255 },
78
+ red: { r: 255, g: 0, b: 0 },
79
+ green: { r: 0, g: 128, b: 0 },
80
+ blue: { r: 0, g: 0, b: 255 },
81
+ gray: { r: 128, g: 128, b: 128 },
82
+ grey: { r: 128, g: 128, b: 128 },
83
+ silver: { r: 192, g: 192, b: 192 },
84
+ transparent: { r: 0, g: 0, b: 0 },
85
+ orange: { r: 255, g: 165, b: 0 },
86
+ yellow: { r: 255, g: 255, b: 0 },
87
+ purple: { r: 128, g: 0, b: 128 },
88
+ navy: { r: 0, g: 0, b: 128 }
89
+ };
90
+ var clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
91
+ function parseColorToRgb(input) {
92
+ if (typeof input !== "string") return void 0;
93
+ const raw = input.trim().toLowerCase();
94
+ if (raw.length === 0) return void 0;
95
+ if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, raw)) {
96
+ return NAMED_COLORS[raw];
97
+ }
98
+ if (raw.startsWith("#")) {
99
+ const hex = raw.slice(1);
100
+ if (hex.length === 3 || hex.length === 4) {
101
+ const r = parseInt(hex[0] + hex[0], 16);
102
+ const g = parseInt(hex[1] + hex[1], 16);
103
+ const b = parseInt(hex[2] + hex[2], 16);
104
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
105
+ return void 0;
106
+ }
107
+ if (hex.length === 6 || hex.length === 8) {
108
+ const r = parseInt(hex.slice(0, 2), 16);
109
+ const g = parseInt(hex.slice(2, 4), 16);
110
+ const b = parseInt(hex.slice(4, 6), 16);
111
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
112
+ return void 0;
113
+ }
114
+ return void 0;
115
+ }
116
+ const rgbMatch = raw.match(/^rgba?\(([^)]+)\)$/);
117
+ if (rgbMatch) {
118
+ const parts = rgbMatch[1].split(",").map((p) => p.trim());
119
+ if (parts.length < 3) return void 0;
120
+ const channel = (p) => {
121
+ if (p.endsWith("%")) {
122
+ const pct = parseFloat(p.slice(0, -1));
123
+ if (!Number.isFinite(pct)) return void 0;
124
+ return clamp255(pct / 100 * 255);
125
+ }
126
+ const n = parseFloat(p);
127
+ if (!Number.isFinite(n)) return void 0;
128
+ return clamp255(n);
129
+ };
130
+ const r = channel(parts[0]);
131
+ const g = channel(parts[1]);
132
+ const b = channel(parts[2]);
133
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
134
+ return { r, g, b };
135
+ }
136
+ return void 0;
137
+ }
138
+ function parseFontSizePx(input) {
139
+ if (typeof input === "number") {
140
+ return Number.isFinite(input) && input > 0 ? input : void 0;
141
+ }
142
+ if (typeof input === "string") {
143
+ const m = input.trim().match(/^(-?\d*\.?\d+)/);
144
+ if (m) {
145
+ const n = parseFloat(m[1]);
146
+ return Number.isFinite(n) && n > 0 ? n : void 0;
147
+ }
148
+ }
149
+ return void 0;
150
+ }
151
+ function projectVisionFields(args) {
152
+ const { type, label, handlerNames, state, flatStyle } = args;
153
+ const fields = {
154
+ interactable: deriveInteractable(type, handlerNames)
155
+ };
156
+ const text = deriveText(state, label);
157
+ if (text !== void 0) fields.text = text;
158
+ const role = roleForType(type);
159
+ if (role !== void 0) fields.role = role;
160
+ if (flatStyle) {
161
+ const fg = parseColorToRgb(flatStyle.color);
162
+ if (fg !== void 0) fields.fg_color = fg;
163
+ const bg = parseColorToRgb(flatStyle.backgroundColor);
164
+ if (bg !== void 0) fields.bg_color = bg;
165
+ const size = parseFontSizePx(flatStyle.fontSize);
166
+ if (size !== void 0) fields.font_size_px = size;
167
+ if (typeof flatStyle.fontFamily === "string" && flatStyle.fontFamily.trim().length > 0) {
168
+ fields.font_family = flatStyle.fontFamily;
169
+ }
170
+ }
171
+ return fields;
172
+ }
173
+
1
174
  // src/core/registry.ts
2
175
  function extractHandlerNames(props) {
3
176
  if (!props) return [];
@@ -7,6 +180,25 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
7
180
  if (registrationRoute == null || registrationRoute === "") return true;
8
181
  return registrationRoute === currentRoute;
9
182
  }
183
+ function projectBbox(state, visibility, pixelRatio = 1) {
184
+ if (visibility !== "visible") return void 0;
185
+ const layout = state.layout;
186
+ if (!layout) return void 0;
187
+ const ratio = pixelRatio > 0 && Number.isFinite(pixelRatio) ? pixelRatio : 1;
188
+ const toPx = (v) => Math.max(0, Math.round(v * ratio));
189
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
190
+ const originX = hasPage ? layout.pageX : layout.x;
191
+ const originY = hasPage ? layout.pageY : layout.y;
192
+ if (typeof originX !== "number" || typeof originY !== "number" || typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(originX) || !Number.isFinite(originY) || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
193
+ return void 0;
194
+ }
195
+ return {
196
+ x: toPx(originX),
197
+ y: toPx(originY),
198
+ w: toPx(layout.width),
199
+ h: toPx(layout.height)
200
+ };
201
+ }
10
202
  function inferActions(type) {
11
203
  const baseActions = ["focus", "blur"];
12
204
  switch (type) {
@@ -71,6 +263,15 @@ var NativeUIBridgeRegistry = class {
71
263
  * already installs.
72
264
  */
73
265
  this.routeProvider = null;
266
+ /**
267
+ * Device pixel ratio used to project `state.layout` (logical dp) into the
268
+ * physical-pixel `bbox` emitted on each snapshot element. Defaults to `1`
269
+ * (dp ≈ px) so callers/tests that never inject a ratio still get a usable
270
+ * box; the React provider wires the real `PixelRatio.get()` via
271
+ * {@link setPixelRatio}. Read from a non-React core module on purpose — see
272
+ * the `projectBbox` doc comment for why we don't import `react-native` here.
273
+ */
274
+ this.pixelRatio = 1;
74
275
  /**
75
276
  * Sticky flag: flips `true` the first time any element is registered and
76
277
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -108,6 +309,21 @@ var NativeUIBridgeRegistry = class {
108
309
  getRouteProvider() {
109
310
  return this.routeProvider;
110
311
  }
312
+ /**
313
+ * Set the device pixel ratio used to project per-element `bbox` geometry
314
+ * into physical pixels. The React provider passes `PixelRatio.get()` here
315
+ * once at startup. Ignores non-finite / non-positive values (keeps the
316
+ * previous ratio) so a bad call can't zero out every bbox.
317
+ */
318
+ setPixelRatio(ratio) {
319
+ if (typeof ratio === "number" && Number.isFinite(ratio) && ratio > 0) {
320
+ this.pixelRatio = ratio;
321
+ }
322
+ }
323
+ /** Read-only accessor for the device pixel ratio used for bbox projection. */
324
+ getPixelRatio() {
325
+ return this.pixelRatio;
326
+ }
111
327
  // ============================================================================
112
328
  // Snapshot Enricher Slots
113
329
  // ============================================================================
@@ -333,6 +549,103 @@ var NativeUIBridgeRegistry = class {
333
549
  console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
334
550
  }
335
551
  }
552
+ /**
553
+ * Re-measure every mounted-visible element's geometry via its stored React
554
+ * ref and write the fresh coordinates into the registry ("measure-on-
555
+ * snapshot").
556
+ *
557
+ * Why: element geometry is otherwise captured ONCE at mount time
558
+ * (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
559
+ * `onLayout` on pure translation — scrolling a virtualized list
560
+ * (FlashList), recycling a cell, a keyboard shift or an orientation change
561
+ * all move elements WITHOUT updating the stored `state.layout`. Snapshot
562
+ * consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
563
+ * operate on stale positions. Re-measuring at READ time is correct
564
+ * regardless of what moved the element and needs zero per-consumer wiring:
565
+ * every registry entry already stores its ref (see `registerElement`).
566
+ *
567
+ * Per-element outcomes:
568
+ * - non-zero dims → write a fresh full layout (same state shape as the
569
+ * mount-time measure loop in `useUIElement`); counted as `measured`.
570
+ * - zero dims AND the element previously had a non-null `state.layout` →
571
+ * the view collapsed / left the screen; clear to
572
+ * `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
573
+ * writes); counted as `cleared`.
574
+ * - zero dims and it never had a layout → leave state untouched (element
575
+ * in the mount→first-onLayout gap; don't demote `likely-visible`);
576
+ * counted as `skipped`.
577
+ * - no ref / no callable `measureInWindow` (test fixtures, web) → leave
578
+ * untouched; counted as `skipped`.
579
+ *
580
+ * The whole sweep races an overall timeout (default 250 ms) so a dead ref
581
+ * whose `measureInWindow` never calls back can never hang a snapshot: the
582
+ * returned promise resolves at the deadline with whatever counts have
583
+ * accumulated. Late callbacks may still write state afterwards — harmless,
584
+ * freshest data wins. Never throws.
585
+ *
586
+ * Non-React core module on purpose — `measureInWindow` is duck-typed off
587
+ * the stored ref rather than imported from `react-native` (see the
588
+ * `projectBbox` doc comment for why this file must not import RN).
589
+ */
590
+ async refreshMeasurements(options) {
591
+ const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
592
+ const counts = { measured: 0, cleared: 0, skipped: 0 };
593
+ const pending = [];
594
+ for (const element of this.getMountedVisibleElements()) {
595
+ const node = element.ref?.current;
596
+ if (!node || typeof node.measureInWindow !== "function") {
597
+ counts.skipped++;
598
+ continue;
599
+ }
600
+ const id = element.id;
601
+ pending.push(
602
+ new Promise((resolve) => {
603
+ let settled = false;
604
+ try {
605
+ node.measureInWindow((pageX, pageY, w, h) => {
606
+ if (settled) return;
607
+ settled = true;
608
+ try {
609
+ if (w > 0 && h > 0) {
610
+ this.updateElementState(id, {
611
+ mounted: true,
612
+ visible: true,
613
+ enabled: true,
614
+ focused: false,
615
+ layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
616
+ });
617
+ counts.measured++;
618
+ } else if (this.elements.get(id)?.getState().layout != null) {
619
+ this.updateElementState(id, { visible: false, layout: null });
620
+ counts.cleared++;
621
+ } else {
622
+ counts.skipped++;
623
+ }
624
+ } finally {
625
+ resolve();
626
+ }
627
+ });
628
+ } catch {
629
+ if (!settled) {
630
+ settled = true;
631
+ counts.skipped++;
632
+ resolve();
633
+ }
634
+ }
635
+ })
636
+ );
637
+ }
638
+ if (pending.length > 0) {
639
+ await new Promise((resolve) => {
640
+ const timer = setTimeout(resolve, timeoutMs);
641
+ void Promise.all(pending).then(() => {
642
+ clearTimeout(timer);
643
+ resolve();
644
+ });
645
+ });
646
+ }
647
+ return counts;
648
+ }
336
649
  /**
337
650
  * Update element state
338
651
  */
@@ -647,6 +960,18 @@ var NativeUIBridgeRegistry = class {
647
960
  const handlers = extractHandlerNames(e.props);
648
961
  const state = e.getState();
649
962
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
963
+ const bbox = projectBbox(state, visibility, this.pixelRatio);
964
+ const vision = projectVisionFields({
965
+ type: e.type,
966
+ label: e.label,
967
+ // Real interactivity signal: the names of props that are actually
968
+ // functions (onPress/onChangeText/...). NOT `e.actions`, which
969
+ // `inferActions` synthesizes press/click onto for every element —
970
+ // using it would mark plain text/view nodes interactable.
971
+ handlerNames: handlers,
972
+ state,
973
+ flatStyle: e.flatStyle
974
+ });
650
975
  return {
651
976
  id: e.id,
652
977
  type: e.type,
@@ -657,7 +982,9 @@ var NativeUIBridgeRegistry = class {
657
982
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
658
983
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
659
984
  registrationRoute: e.registrationRoute,
660
- visibility
985
+ visibility,
986
+ ...bbox ? { bbox } : {},
987
+ ...vision
661
988
  };
662
989
  }),
663
990
  components: this.getAllComponents().map((c) => ({
@@ -872,6 +1199,6 @@ function matchesIdentifier(identifier, criteria) {
872
1199
  return true;
873
1200
  }
874
1201
 
875
- export { NativeUIBridgeRegistry, buildTreePath, createNativeElementIdentifier, extractHandlerNames, findAllByPattern, findElementByIdentifier, getGlobalRegistry, matchesCurrentRoute, matchesIdentifier, parseTreePath, resetGlobalRegistry, setGlobalRegistry };
1202
+ export { NativeUIBridgeRegistry, buildTreePath, createNativeElementIdentifier, extractHandlerNames, findAllByPattern, findElementByIdentifier, getGlobalRegistry, matchesCurrentRoute, matchesIdentifier, parseTreePath, projectBbox, resetGlobalRegistry, setGlobalRegistry };
876
1203
  //# sourceMappingURL=index.mjs.map
877
1204
  //# sourceMappingURL=index.mjs.map