@swiftbrowser/web 0.1.0

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.
Files changed (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
@@ -0,0 +1,3581 @@
1
+ /**
2
+ * The layout pass: SwiftUI's propose / report / place protocol over the
3
+ * renderer's element tree (docs/ops-protocol.md, "Layout engine semantics").
4
+ *
5
+ * `measure(node, proposal)` reports the size a node wants for a proposal and
6
+ * is memoized per (node, proposal); `place(node, proposal, x, y)` records the
7
+ * frame and places the children using the same memoized plans. Context (font,
8
+ * line limit, text alignment, enclosing stack axis, list row) flows down the
9
+ * tree like SwiftUI's environment and is a pure function of the node's
10
+ * ancestors, so it is not part of the memo key.
11
+ *
12
+ * Phase 9: a `LayoutPass` lives across commits. The renderer tells it which
13
+ * nodes changed (`invalidate`, `remove`); a changed node, its ancestors (their
14
+ * sizes may change) and its descendants (their context may change) lose their
15
+ * memo and their last placement. `place` returns at once for a node that was
16
+ * placed before with the same proposal at the same origin and was not
17
+ * invalidated: its whole subtree's frames, chrome and text are still those of
18
+ * the last pass. `run` reports the ids it touched so the DOM pass only visits
19
+ * those.
20
+ */
21
+
22
+ import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
23
+ import { wrapText } from './text';
24
+ import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
25
+ import {
26
+ CHART_LEGEND_GAP,
27
+ CHART_LEGEND_GLYPH,
28
+ annotationFrame,
29
+ chartSize,
30
+ layoutChart,
31
+ legendGlyphSize,
32
+ unitRect,
33
+ type ChartAxisInput,
34
+ type ChartGeometry,
35
+ type ChartItemPosition,
36
+ type ChartLegendInput,
37
+ } from './chart';
38
+ import type {
39
+ EdgeInsets,
40
+ LayoutChildSizes,
41
+ LayoutEnvironment,
42
+ LayoutNode,
43
+ LayoutRecord,
44
+ LayoutResult,
45
+ Proposal,
46
+ Rect,
47
+ ResolvedFont,
48
+ Size,
49
+ TextAlignment,
50
+ TextLayout,
51
+ TextMeasurer,
52
+ } from './types';
53
+
54
+ type Axis = 'horizontal' | 'vertical';
55
+
56
+ export interface Context {
57
+ font: ResolvedFont;
58
+ lineLimit: number | null;
59
+ textAlignment: TextAlignment;
60
+ /** The enclosing stack's axis (through `styled` wrappers), for spacer/divider. */
61
+ stackAxis: Axis | null;
62
+ /** True for a list row element (through `styled` wrappers): navlinks reserve a chevron. */
63
+ listRow: boolean;
64
+ /**
65
+ * Height of the bar covering the bottom of the screen above the subtree
66
+ * (a `tabview`'s bar plus the bottom safe area), 0 outside tabs. Scrolling
67
+ * views that reach the screen bottom inset their content by it.
68
+ */
69
+ bottomBar: number;
70
+ /** Phase 6 `imageScale`: symbol images measure this fraction of the line height (1 by default). */
71
+ imageScale: number;
72
+ /** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
73
+ splitSidebar: boolean;
74
+ /**
75
+ * Phase 8: the y (screen coordinates) where content clears the bars above
76
+ * it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
77
+ * large title / search field) inside one. `.ignoresSafeArea` grows up to 0
78
+ * from here.
79
+ */
80
+ safeTop: number;
81
+ /**
82
+ * Phase 12: the size of the nearest container (`containerRelativeFrame`):
83
+ * the screen at the root, a scroll view's viewport inside one.
84
+ */
85
+ container: Size;
86
+ }
87
+
88
+ // Constants from the spec.
89
+ const DEFAULT_SPACING = 8;
90
+ const DEFAULT_SPACER_MIN = 8;
91
+ /** Phase 5: the square an `image` whose catalog name is unknown measures. */
92
+ const UNKNOWN_IMAGE_SIDE = 44;
93
+ const SHAPE_DEFAULT = 10;
94
+ const BORDERED_PAD_V = 7;
95
+ const BORDERED_PAD_H = 14;
96
+ const BORDERED_MIN_HEIGHT = 34;
97
+ const SWITCH_WIDTH = 51;
98
+ const SWITCH_HEIGHT = 31;
99
+ const TOGGLE_GAP = 8;
100
+ const TEXTFIELD_DEFAULT_WIDTH = 200;
101
+ const TEXTFIELD_HEIGHT_ROUNDED = 34;
102
+ const TEXTFIELD_HEIGHT_PLAIN = 22;
103
+ const NAV_BAR_HEIGHT = 44;
104
+ const LARGE_TITLE_HEIGHT = 52;
105
+ /** Chevron glyph plus its gap, in px. */
106
+ const BACK_CHEVRON_WIDTH = 24;
107
+ const SHEET_TOP_GAP = 10;
108
+ const SHEET_GRABBER_ROW = 20;
109
+ const SHEET_GRABBER: Size = { width: 36, height: 5 };
110
+ const SHEET_GRABBER_TOP = 8;
111
+ const LIST_INSET = 16;
112
+ const LIST_CELL_PADDING = 16;
113
+ const LIST_ROW_VPAD = 11;
114
+ const LIST_ROW_MIN_HEIGHT = 44;
115
+ const LIST_TOP = 20;
116
+ const LIST_BOTTOM = 20;
117
+ const LIST_HEADER_GAP = 8;
118
+ const LIST_FOOTER_GAP = 8;
119
+ const LIST_SECTION_GAP = 35;
120
+ const LIST_CHEVRON_WIDTH = 20;
121
+ // Phase 4.
122
+ const TAB_BAR_HEIGHT = 49;
123
+ const PICKER_SEGMENTED_HEIGHT = 32;
124
+ const PICKER_SEGMENT_PAD = 20;
125
+ const PICKER_SEGMENT_FONT_SIZE = 13;
126
+ const PICKER_MENU_ROW_HEIGHT = 44;
127
+ const PICKER_MENU_HEIGHT = 34;
128
+ /** `chevron.up.chevron.down` glyph width plus the gap to the value text. */
129
+ const PICKER_MENU_CHEVRON_WIDTH = 20;
130
+ const PICKER_MENU_GAP = 12;
131
+ // Phase 6.
132
+ const GAUGE_BAR_HEIGHT = 4;
133
+ const GAUGE_LABEL_GAP = 4;
134
+ const GAUGE_DEFAULT_WIDTH = 100;
135
+ const GRID_ITEM_DEFAULT_MIN = 10;
136
+ // Phase 6 (docs/phase6/chrome.md).
137
+ const PICKER_INLINE_ROW_HEIGHT = 44;
138
+ /** Checkmark glyph (20) plus its gap to the option title. */
139
+ const PICKER_INLINE_CHECK_WIDTH = 28;
140
+ /** Space between a list row's content and its trailing badge. */
141
+ const BADGE_GAP = 8;
142
+ const NAV_ITEM_SPACING = 16;
143
+ const NAV_ITEM_MARGIN = 16;
144
+ const BOTTOM_BAR_HEIGHT = 49;
145
+ const SEARCH_FIELD_HEIGHT = 36;
146
+ const SEARCH_FIELD_MARGIN_H = 16;
147
+ const SEARCH_FIELD_PAD_V = 8;
148
+ /** 8 above the 36pt field and 8 below: what a search field adds to the bar. */
149
+ const SEARCH_AREA_HEIGHT = SEARCH_FIELD_HEIGHT + 2 * SEARCH_FIELD_PAD_V;
150
+ const SEARCH_FIELD_DEFAULT_WIDTH = 200;
151
+ const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
152
+ // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
153
+ const NAVSPLIT_SIDEBAR_WIDTH = 320;
154
+ /** The sidebar never takes more than this share of the split view's width. */
155
+ const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
156
+ const NAVSPLIT_SEPARATOR_WIDTH = 1;
157
+ const EPSILON = 1e-6;
158
+ // Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
159
+ /** The segmented −/+ control of a `stepper`, trailing. */
160
+ const STEPPER_WIDTH = 94;
161
+ const STEPPER_HEIGHT = 32;
162
+ const STEPPER_GAP = 8;
163
+ /** A `slider` is 44 high (its label row, when present, sits above). */
164
+ const SLIDER_HEIGHT = 44;
165
+ const SLIDER_TRACK_HEIGHT = 4;
166
+ const SLIDER_THUMB = 27;
167
+ const SLIDER_DEFAULT_WIDTH = 200;
168
+ /** Gap between a min / max value label and the track. */
169
+ const SLIDER_LABEL_GAP = 8;
170
+ /** A compact `datepicker` is 34 high; its capsules pad the text by 11 and sit 8 apart. */
171
+ const DATEPICKER_HEIGHT = 34;
172
+ const DATEPICKER_CAPSULE_PAD_H = 11;
173
+ const DATEPICKER_CAPSULE_GAP = 8;
174
+ const DATEPICKER_GAP = 8;
175
+ /** A `texteditor` fills the proposal; 100 high when proposed nil. */
176
+ const TEXTEDITOR_DEFAULT_HEIGHT = 100;
177
+ /** `alert` style: a 270 wide centered card. */
178
+ const ALERT_WIDTH = 270;
179
+ const ALERT_PAD_TOP = 20;
180
+ const ALERT_PAD_H = 16;
181
+ const ALERT_PAD_BOTTOM = 20;
182
+ /** Gap between the alert title and its message. */
183
+ const ALERT_TITLE_GAP = 4;
184
+ const ALERT_BUTTON_HEIGHT = 44;
185
+ /** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
186
+ const DIALOG_MARGIN = 8;
187
+ const DIALOG_BUTTON_HEIGHT = 57;
188
+ const DIALOG_HEADER_PAD_V = 14;
189
+ const DIALOG_HEADER_PAD_H = 16;
190
+ /** Gap between the dialog's button card and its cancel card. */
191
+ const DIALOG_CARD_GAP = 8;
192
+ const DIALOG_BUTTON_FONT_SIZE = 20;
193
+ const SAFE_AREA_EDGES = ['top', 'leading', 'bottom', 'trailing'] as const;
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Prop readers (props are untrusted JSON)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function obj(value: unknown): Record<string, unknown> | null {
200
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
201
+ }
202
+ function num(value: unknown): number | null {
203
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
204
+ }
205
+ function str(value: unknown): string | null {
206
+ return typeof value === 'string' ? value : null;
207
+ }
208
+ /** A frame dimension: a number or the string "infinity". */
209
+ function dim(value: unknown): number | null {
210
+ if (value === 'infinity') return Infinity;
211
+ return num(value);
212
+ }
213
+
214
+ function styleOf(node: LayoutNode): Record<string, unknown> {
215
+ return obj(node.props['style']) ?? {};
216
+ }
217
+
218
+ function readInsets(value: unknown): EdgeInsets | null {
219
+ const o = obj(value);
220
+ if (!o) return null;
221
+ return {
222
+ top: num(o['top']) ?? 0,
223
+ leading: num(o['leading']) ?? 0,
224
+ bottom: num(o['bottom']) ?? 0,
225
+ trailing: num(o['trailing']) ?? 0,
226
+ };
227
+ }
228
+
229
+ interface FrameSpec {
230
+ width: number | null;
231
+ height: number | null;
232
+ minWidth: number | null;
233
+ maxWidth: number | null;
234
+ minHeight: number | null;
235
+ maxHeight: number | null;
236
+ alignment: string;
237
+ }
238
+
239
+ function readFrame(value: unknown): FrameSpec | null {
240
+ const o = obj(value);
241
+ if (!o) return null;
242
+ return {
243
+ width: num(o['width']),
244
+ height: num(o['height']),
245
+ minWidth: dim(o['minWidth']),
246
+ maxWidth: dim(o['maxWidth']),
247
+ minHeight: dim(o['minHeight']),
248
+ maxHeight: dim(o['maxHeight']),
249
+ alignment: str(o['alignment']) ?? 'center',
250
+ };
251
+ }
252
+
253
+ /** Phase 12 `containerRelativeFrame` hint: `a × container + b` on the marked axes. */
254
+ interface ContainerRelativeFrameSpec {
255
+ horizontal: boolean;
256
+ vertical: boolean;
257
+ a: number;
258
+ b: number;
259
+ alignment: string;
260
+ }
261
+
262
+ function readContainerRelativeFrame(value: unknown): ContainerRelativeFrameSpec | null {
263
+ const o = obj(value);
264
+ if (!o) return null;
265
+ const horizontal = o['horizontal'] === true;
266
+ const vertical = o['vertical'] === true;
267
+ if (!horizontal && !vertical) return null;
268
+ return { horizontal, vertical, a: num(o['a']) ?? 1, b: num(o['b']) ?? 0, alignment: str(o['alignment']) ?? 'center' };
269
+ }
270
+
271
+ interface AspectRatioSpec {
272
+ /** The ratio (width ÷ height), or null for the child's own ideal ratio. */
273
+ ratio: number | null;
274
+ fill: boolean;
275
+ }
276
+
277
+ function readAspectRatio(value: unknown): AspectRatioSpec | null {
278
+ const o = obj(value);
279
+ if (!o) return null;
280
+ const ratio = num(o['ratio']);
281
+ return { ratio: ratio !== null && ratio > 0 ? ratio : null, fill: o['contentMode'] === 'fill' };
282
+ }
283
+
284
+ /** `layoutPriority` of a stack child, read through its `styled` wrappers (outermost wins). */
285
+ function layoutPriorityOf(node: LayoutNode): number {
286
+ let current: LayoutNode | undefined = node;
287
+ while (current && current.kind === 'styled') {
288
+ const priority = num(styleOf(current)['layoutPriority']);
289
+ if (priority !== null) return priority;
290
+ current = current.children.length === 1 ? current.children[0] : undefined;
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ /** The element under a chain of `styled` wrappers. */
296
+ function unwrapStyled(node: LayoutNode): LayoutNode {
297
+ let current = node;
298
+ while (current.kind === 'styled' && current.children.length === 1) {
299
+ current = current.children[0] as LayoutNode;
300
+ }
301
+ return current;
302
+ }
303
+
304
+ /** A `{x, y}` pair of finite numbers. */
305
+ function readPoint(value: unknown): { x: number; y: number } | null {
306
+ const o = obj(value);
307
+ if (!o) return null;
308
+ const x = num(o['x']);
309
+ const y = num(o['y']);
310
+ return x !== null && y !== null ? { x, y } : null;
311
+ }
312
+
313
+ /** A `{width, height}` proposal from props (`"infinity"` → Infinity, null → null). */
314
+ function readProposal(value: unknown): Proposal | null {
315
+ const o = obj(value);
316
+ if (!o) return null;
317
+ const axis = (v: unknown): number | null | undefined => {
318
+ if (v === null) return null;
319
+ if (v === 'infinity') return Infinity;
320
+ const n = num(v);
321
+ return n === null ? undefined : n;
322
+ };
323
+ const width = axis(o['width']);
324
+ const height = axis(o['height']);
325
+ if (width === undefined || height === undefined) return null;
326
+ return { width, height };
327
+ }
328
+
329
+ /** Whether two proposals agree on both axes (within the 2-decimal rounding Swift applies). */
330
+ function sameProposal(a: Proposal, b: Proposal | null): boolean {
331
+ if (!b) return false;
332
+ const same = (x: number | null, y: number | null): boolean =>
333
+ x === y || (x !== null && y !== null && (x === Infinity ? y === Infinity : Math.abs(x - y) < 0.01));
334
+ return same(a.width, b.width) && same(a.height, b.height);
335
+ }
336
+
337
+ /** A `{width, height}` whose dimensions may be `"infinity"`. */
338
+ function readFlexSize(value: unknown): Size | null {
339
+ const o = obj(value);
340
+ if (!o) return null;
341
+ const axis = (v: unknown): number | null => (v === 'infinity' ? Infinity : num(v));
342
+ const width = axis(o['width']);
343
+ const height = axis(o['height']);
344
+ return width !== null && height !== null ? { width: Math.max(0, width), height: Math.max(0, height) } : null;
345
+ }
346
+
347
+ /** A custom layout's `{min, ideal, max}` sizes from its props. */
348
+ function readLayoutSizes(value: unknown): { min: Size; ideal: Size; max: Size } | null {
349
+ const o = obj(value);
350
+ if (!o) return null;
351
+ const min = readFlexSize(o['min']);
352
+ const ideal = readFlexSize(o['ideal']);
353
+ const max = readFlexSize(o['max']);
354
+ return min && ideal && max ? { min, ideal, max } : null;
355
+ }
356
+
357
+ /** A `{width, height}` pair of finite, non-negative numbers. */
358
+ function readSize(value: unknown): Size | null {
359
+ const o = obj(value);
360
+ if (!o) return null;
361
+ const width = num(o['width']);
362
+ const height = num(o['height']);
363
+ return width !== null && height !== null ? { width: Math.max(0, width), height: Math.max(0, height) } : null;
364
+ }
365
+
366
+ /** A `{x, y, width, height}` rect of finite numbers. */
367
+ function readRect(value: unknown): Rect | null {
368
+ const point = readPoint(value);
369
+ const size = readSize(value);
370
+ return point && size ? { ...point, ...size } : null;
371
+ }
372
+
373
+ /** A style key of a grid cell, read through its `styled` wrappers (outermost wins). */
374
+ function cellStyle(node: LayoutNode, key: string): unknown {
375
+ let current: LayoutNode | undefined = node;
376
+ while (current && current.kind === 'styled') {
377
+ const value = styleOf(current)[key];
378
+ if (value !== undefined && value !== null) return value;
379
+ current = current.children.length === 1 ? current.children[0] : undefined;
380
+ }
381
+ return undefined;
382
+ }
383
+
384
+ /** `gridCellColumns` of a grid cell (at least 1). */
385
+ function gridSpanOf(node: LayoutNode): number {
386
+ const span = num(cellStyle(node, 'gridCellColumns'));
387
+ return span !== null && span >= 1 ? Math.floor(span) : 1;
388
+ }
389
+
390
+ /** `gridCellAnchor` of a grid cell as [horizontal, vertical] unit anchors. */
391
+ function gridAnchorOf(node: LayoutNode): [number, number] | null {
392
+ const point = readPoint(cellStyle(node, 'gridCellAnchor'));
393
+ return point ? [point.x, point.y] : null;
394
+ }
395
+
396
+ /** Vertical anchor of a `gridrow` alignment (null → the grid's). */
397
+ function rowAnchor(alignment: string | null, gridAlignment: string): number {
398
+ switch (alignment) {
399
+ case 'top':
400
+ case 'firstTextBaseline':
401
+ return 0;
402
+ case 'center':
403
+ return 0.5;
404
+ case 'bottom':
405
+ case 'lastTextBaseline':
406
+ return 1;
407
+ default:
408
+ return alignmentAnchors(gridAlignment)[1];
409
+ }
410
+ }
411
+
412
+ interface GridItemSpec {
413
+ kind: 'fixed' | 'flexible' | 'adaptive';
414
+ value: number;
415
+ minimum: number;
416
+ maximum: number;
417
+ spacing: number;
418
+ alignment: string | null;
419
+ }
420
+
421
+ /** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible column. */
422
+ function readGridItems(value: unknown): GridItemSpec[] {
423
+ const items: GridItemSpec[] = [];
424
+ if (Array.isArray(value)) {
425
+ for (const raw of value) {
426
+ const o = obj(raw);
427
+ if (!o) continue;
428
+ const size = obj(o['size']) ?? {};
429
+ const kind = str(size['kind']);
430
+ const minimum = Math.max(0, num(size['minimum']) ?? GRID_ITEM_DEFAULT_MIN);
431
+ const maximum = Math.max(minimum, dim(size['maximum']) ?? Infinity);
432
+ items.push({
433
+ kind: kind === 'fixed' ? 'fixed' : kind === 'adaptive' ? 'adaptive' : 'flexible',
434
+ value: Math.max(0, num(size['value']) ?? 0),
435
+ minimum,
436
+ maximum,
437
+ spacing: num(o['spacing']) ?? DEFAULT_SPACING,
438
+ alignment: str(o['alignment']),
439
+ });
440
+ }
441
+ }
442
+ if (items.length === 0) {
443
+ items.push({
444
+ kind: 'flexible',
445
+ value: 0,
446
+ minimum: GRID_ITEM_DEFAULT_MIN,
447
+ maximum: Infinity,
448
+ spacing: DEFAULT_SPACING,
449
+ alignment: null,
450
+ });
451
+ }
452
+ return items;
453
+ }
454
+
455
+ interface RowHints {
456
+ /** `.badge(_:)` text (non-empty), or null. */
457
+ badge: string | null;
458
+ /** `.listRowInsets(_:)`, or null for the defaults. */
459
+ insets: EdgeInsets | null;
460
+ }
461
+
462
+ /**
463
+ * Phase 6 list row hints, read through the row's `styled` chain (the
464
+ * outermost wins, like `layoutPriority`): `badge` and `listRowInsets`.
465
+ */
466
+ function rowHintsOf(node: LayoutNode): RowHints {
467
+ const hints: RowHints = { badge: null, insets: null };
468
+ let current: LayoutNode | undefined = node;
469
+ // `NavigationLink(value:) { OrderRow().badge(n) }`: the hints sit on the
470
+ // link's label, which is the row's content. Phase 8 wrappers (`listrow`,
471
+ // `swipeactions`, `contextmenu`, `gesture`) are looked through the same way.
472
+ while (current) {
473
+ if (current.kind === 'styled') {
474
+ const style = styleOf(current);
475
+ if (hints.badge === null) {
476
+ const badge = str(style['badge']);
477
+ if (badge !== null && badge.length > 0) hints.badge = badge;
478
+ }
479
+ if (hints.insets === null) hints.insets = readInsets(style['listRowInsets']);
480
+ current = current.children.length === 1 ? current.children[0] : undefined;
481
+ } else if (current.kind === 'navlink' && current.children.length === 1) {
482
+ current = current.children[0];
483
+ } else if (ROW_WRAPPERS.has(current.kind) && current.children.length >= 1) {
484
+ current = current.children[0];
485
+ } else {
486
+ break;
487
+ }
488
+ }
489
+ return hints;
490
+ }
491
+
492
+ /** Phase 8 wrappers whose first child is the row content they stand for. */
493
+ const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture']);
494
+
495
+ /**
496
+ * Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
497
+ * other wrappers), or null for a plain row. Edit mode shifts such rows.
498
+ */
499
+ function listRowOf(node: LayoutNode): LayoutNode | null {
500
+ let current: LayoutNode | undefined = node;
501
+ while (current) {
502
+ if (current.kind === 'listrow') return current;
503
+ if (current.kind === 'styled' || ROW_WRAPPERS.has(current.kind)) current = current.children[0];
504
+ else return null;
505
+ }
506
+ return null;
507
+ }
508
+
509
+ /** Phase 8 edit mode: the width of a `listrow`'s leading control, by which its content shifts right. */
510
+ export const LIST_EDIT_CONTROL_WIDTH = 44;
511
+
512
+ /** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
513
+ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
514
+ if (scale === 1) return font;
515
+ const size = font.size * scale;
516
+ const lineHeight = font.lineHeight * scale;
517
+ const px = (v: number): string => `${Math.round(v * 100) / 100}px`;
518
+ return { ...font, size, lineHeight, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Geometry helpers
523
+ // ---------------------------------------------------------------------------
524
+
525
+ function clamp(value: number, min: number | null, max: number | null): number {
526
+ let v = value;
527
+ if (max !== null) v = Math.min(v, max);
528
+ if (min !== null) v = Math.max(v, min);
529
+ return v;
530
+ }
531
+
532
+ function finite(value: number | null): value is number {
533
+ return value !== null && Number.isFinite(value);
534
+ }
535
+
536
+ /** Subtract a length from a proposal axis (null and Infinity pass through). */
537
+ function shrink(value: number | null, by: number): number | null {
538
+ if (!finite(value)) return value;
539
+ return Math.max(0, value - by);
540
+ }
541
+
542
+ function axisOf(size: Size, axis: Axis): number {
543
+ return axis === 'horizontal' ? size.width : size.height;
544
+ }
545
+ function crossOf(size: Size, axis: Axis): number {
546
+ return axis === 'horizontal' ? size.height : size.width;
547
+ }
548
+ function makeProposal(axis: Axis, along: number | null, cross: number | null): Proposal {
549
+ return axis === 'horizontal' ? { width: along, height: cross } : { width: cross, height: along };
550
+ }
551
+
552
+ /** Horizontal (0/0.5/1) and vertical (0/0.5/1) anchors for a SwiftUI alignment. */
553
+ function alignmentAnchors(alignment: string): [number, number] {
554
+ switch (alignment) {
555
+ case 'leading':
556
+ return [0, 0.5];
557
+ case 'trailing':
558
+ return [1, 0.5];
559
+ case 'top':
560
+ return [0.5, 0];
561
+ case 'bottom':
562
+ return [0.5, 1];
563
+ case 'topLeading':
564
+ return [0, 0];
565
+ case 'topTrailing':
566
+ return [1, 0];
567
+ case 'bottomLeading':
568
+ return [0, 1];
569
+ case 'bottomTrailing':
570
+ return [1, 1];
571
+ default:
572
+ return [0.5, 0.5];
573
+ }
574
+ }
575
+
576
+ function alignIn(container: Size, child: Size, alignment: string): { dx: number; dy: number } {
577
+ const [ax, ay] = alignmentAnchors(alignment);
578
+ return {
579
+ dx: safeOffset(container.width, child.width, ax),
580
+ dy: safeOffset(container.height, child.height, ay),
581
+ };
582
+ }
583
+
584
+ function safeOffset(container: number, child: number, anchor: number): number {
585
+ if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
586
+ return (container - child) * anchor;
587
+ }
588
+
589
+ const ZERO: Size = { width: 0, height: 0 };
590
+ const NIL_PROPOSAL: Proposal = { width: null, height: null };
591
+ const MIN_PROPOSAL: Proposal = { width: 0, height: 0 };
592
+ const MAX_PROPOSAL: Proposal = { width: Infinity, height: Infinity };
593
+
594
+ function square(side: number): Size {
595
+ return { width: side, height: side };
596
+ }
597
+
598
+ function proposalKey(p: Proposal): string {
599
+ return `${p.width === null ? 'n' : p.width}|${p.height === null ? 'n' : p.height}`;
600
+ }
601
+
602
+ /**
603
+ * Snap a frame to whole CSS pixels: both edges floor (with an epsilon for
604
+ * float noise), so nested and adjacent frames never overlap by a fraction and
605
+ * integer sizes survive fractional origins.
606
+ */
607
+ function snapRect(r: Rect): Rect {
608
+ const x = Math.floor(fin(r.x) + EPSILON);
609
+ const y = Math.floor(fin(r.y) + EPSILON);
610
+ const right = Math.floor(fin(r.x) + fin(r.width) + EPSILON);
611
+ const bottom = Math.floor(fin(r.y) + fin(r.height) + EPSILON);
612
+ return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
613
+ }
614
+ function fin(v: number): number {
615
+ return Number.isFinite(v) ? v : 0;
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Plans (memoized per node + proposal)
620
+ // ---------------------------------------------------------------------------
621
+
622
+ interface StackPlan {
623
+ size: Size;
624
+ childSizes: Size[];
625
+ childProposals: Proposal[];
626
+ }
627
+
628
+ interface StyledPlan {
629
+ size: Size;
630
+ /** The child's reported size (0×0 without a child). */
631
+ childSize: Size;
632
+ childProposal: Proposal;
633
+ /** Rect the child is aligned in, relative to the styled frame. */
634
+ contentBox: Rect;
635
+ alignment: string;
636
+ /** Phase 6 `.position(x:y:)`: the child's center inside the content box, and its ideal size. */
637
+ position: { x: number; y: number; ideal: Size } | null;
638
+ }
639
+
640
+ interface TextPlan {
641
+ size: Size;
642
+ layout: TextLayout;
643
+ }
644
+
645
+ // Phase 8 controls.
646
+ interface SliderPlan {
647
+ size: Size;
648
+ label: LayoutNode | null;
649
+ min: LayoutNode | null;
650
+ max: LayoutNode | null;
651
+ labelSize: Size;
652
+ minSize: Size;
653
+ maxSize: Size;
654
+ /** Height of the label row above the 44 track row (0 without a label). */
655
+ labelRow: number;
656
+ cctx: Context;
657
+ }
658
+
659
+ interface DatePickerPlan {
660
+ size: Size;
661
+ /** Null when there is no label child or `labelsHidden` is set. */
662
+ label: LayoutNode | null;
663
+ labelProposal: Proposal;
664
+ labelSize: Size;
665
+ /** Capsule widths (text + 2 × 11), 0 for an absent component. */
666
+ dateWidth: number;
667
+ timeWidth: number;
668
+ capsulesWidth: number;
669
+ cctx: Context;
670
+ }
671
+
672
+ interface ListRowPlan {
673
+ node: LayoutNode;
674
+ proposal: Proposal;
675
+ /** Content size reported by the row element. */
676
+ contentSize: Size;
677
+ /** Full-width row rect, relative to the list origin at scroll 0. */
678
+ row: Rect;
679
+ /** Where the row element is placed. */
680
+ content: Rect;
681
+ /** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
682
+ badge: { rect: Rect; layout: TextLayout } | null;
683
+ /** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
684
+ edit: { listRow: LayoutNode; control: Rect } | null;
685
+ }
686
+
687
+ interface ListGroupPlan {
688
+ section: LayoutNode | null;
689
+ frame: Rect;
690
+ header: { rect: Rect; layout: TextLayout } | null;
691
+ footer: { rect: Rect; layout: TextLayout } | null;
692
+ card: Rect;
693
+ rows: ListRowPlan[];
694
+ }
695
+
696
+ interface ListPlan {
697
+ size: Size;
698
+ contentSize: Size;
699
+ groups: ListGroupPlan[];
700
+ }
701
+
702
+ interface ScrollPlan {
703
+ size: Size;
704
+ content: LayoutNode | null;
705
+ contentProposal: Proposal;
706
+ contentSize: Size;
707
+ }
708
+
709
+ interface PickerPlan {
710
+ size: Size;
711
+ style: 'segmented' | 'menu' | 'inline';
712
+ font: ResolvedFont;
713
+ options: string[];
714
+ selected: number;
715
+ /** Menu style: the label's and the value's (text + chevron) widths. */
716
+ labelWidth: number;
717
+ valueWidth: number;
718
+ /** Inline style: whether a label row precedes the options. */
719
+ labelRow: boolean;
720
+ }
721
+
722
+ // Phase 6 plans.
723
+
724
+ interface GridCellPlan {
725
+ node: LayoutNode;
726
+ column: number;
727
+ span: number;
728
+ /** The cell box width: the spanned columns plus the spacing between them. */
729
+ width: number;
730
+ size: Size;
731
+ /** Full-span cell of a non-`gridrow` child (measured with the grid width). */
732
+ full: boolean;
733
+ }
734
+
735
+ interface GridRowPlan {
736
+ /** The `gridrow` element, or null for a full-span child. */
737
+ node: LayoutNode | null;
738
+ cells: GridCellPlan[];
739
+ y: number;
740
+ height: number;
741
+ alignment: string | null;
742
+ }
743
+
744
+ interface GridPlan {
745
+ size: Size;
746
+ columns: number[];
747
+ columnX: number[];
748
+ rows: GridRowPlan[];
749
+ }
750
+
751
+ interface LazyColumnPlan {
752
+ x: number;
753
+ width: number;
754
+ alignment: string | null;
755
+ }
756
+
757
+ interface LazyGridPlan {
758
+ size: Size;
759
+ columns: LazyColumnPlan[];
760
+ rows: { y: number; height: number; cells: { node: LayoutNode; column: number; size: Size }[] }[];
761
+ }
762
+
763
+ interface GaugePlan {
764
+ size: Size;
765
+ label: LayoutNode | null;
766
+ current: LayoutNode | null;
767
+ labelSize: Size;
768
+ currentSize: Size;
769
+ /** Height of the label row (0 without labels). */
770
+ labelRow: number;
771
+ labelCtx: Context;
772
+ }
773
+
774
+ /** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
775
+ interface ChartItemPlan {
776
+ node: LayoutNode;
777
+ role: string;
778
+ size: Size;
779
+ /** Frame relative to the chart's origin. */
780
+ frame: Rect;
781
+ }
782
+
783
+ interface ChartPlan {
784
+ size: Size;
785
+ geometry: ChartGeometry;
786
+ /** The axis / legend font (`caption`). */
787
+ font: ResolvedFont;
788
+ items: ChartItemPlan[];
789
+ /** Text labels in `geometry.xLabels` / `geometry.yLabels` order. */
790
+ xLabelTexts: string[];
791
+ yLabelTexts: string[];
792
+ childCtx: Context;
793
+ }
794
+
795
+ /** `options` of a picker as strings (untrusted JSON). */
796
+ function readOptions(node: LayoutNode): string[] {
797
+ const raw = node.props['options'];
798
+ return Array.isArray(raw) ? raw.map((o) => (typeof o === 'string' ? o : String(o))) : [];
799
+ }
800
+
801
+ /** `selected` clamped to `0 ..< count` (0 when there is nothing to select). */
802
+ function readSelected(node: LayoutNode, count: number): number {
803
+ const raw = num(node.props['selected']) ?? 0;
804
+ return count === 0 ? 0 : Math.min(count - 1, Math.max(0, Math.floor(raw)));
805
+ }
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // The pass
809
+ // ---------------------------------------------------------------------------
810
+
811
+ export class LayoutPass {
812
+ readonly frames = new Map<number, Rect>();
813
+ readonly text = new Map<number, TextLayout>();
814
+ readonly fonts = new Map<number, ResolvedFont>();
815
+ readonly contentSizes = new Map<number, Size>();
816
+ readonly chrome = new Map<number, Record<string, Rect>>();
817
+ readonly chromeText = new Map<number, Record<string, TextLayout>>();
818
+ readonly hidden = new Set<number>();
819
+ readonly layouts = new Map<number, LayoutRecord>();
820
+ /** Previous screen title per navscreen node, for the back button label. */
821
+ private readonly previousTitles = new Map<LayoutNode, string>();
822
+
823
+ private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
824
+ private readonly implicit = new Map<LayoutNode, LayoutNode>();
825
+ private implicitIds = -1;
826
+ private readonly rootContext: Context;
827
+ /** Phase 9: where (and for what proposal) every node was last placed. */
828
+ private readonly placed = new Map<LayoutNode, { key: string; x: number; y: number }>();
829
+ /** Phase 9: the ids each node hid when it was last placed (menu items, tabs, ...). */
830
+ private readonly hiddenBy = new Map<LayoutNode, number[]>();
831
+ /** Phase 9: ids placed or hidden in the current `run`; what the DOM pass must visit. */
832
+ private touched = new Set<number>();
833
+ /** Phase 9: the run that last wrote each node's chrome (a node's own place drops older entries). */
834
+ private readonly chromeRun = new Map<number, number>();
835
+ private runId = 0;
836
+ /** Phase 9: how many nodes `run` placed from scratch and skipped (for tests and profiling). */
837
+ stats = { placed: 0, skipped: 0 };
838
+
839
+ constructor(
840
+ private readonly env: LayoutEnvironment,
841
+ private readonly measurer: TextMeasurer,
842
+ /** Phase 9: the parent of a node in the renderer's tree (for `invalidate`); null at the root. */
843
+ private readonly parentOf: (node: LayoutNode) => LayoutNode | null = () => null,
844
+ ) {
845
+ this.rootContext = {
846
+ font: defaultFont(env),
847
+ lineLimit: null,
848
+ textAlignment: 'leading',
849
+ stackAxis: null,
850
+ listRow: false,
851
+ bottomBar: 0,
852
+ imageScale: 1,
853
+ splitSidebar: false,
854
+ safeTop: env.safeArea.top,
855
+ container: { width: env.screen.width, height: env.screen.height },
856
+ };
857
+ }
858
+
859
+ run(roots: readonly LayoutNode[]): LayoutResult {
860
+ this.runId += 1;
861
+ this.touched = new Set();
862
+ this.stats = { placed: 0, skipped: 0 };
863
+ const { screen, safeArea } = this.env;
864
+ const ctx = this.rootContext;
865
+ for (const root of roots) {
866
+ if (root.kind === 'navstack' || root.kind === 'tabview' || root.kind === 'navsplit' || root.kind === 'alert') {
867
+ // All three ignore the safe areas: the tab bar and navigation bars
868
+ // extend into them and the content is inset instead (a `navsplit`
869
+ // hands the horizontal insets to its columns, see `placeNavSplit`).
870
+ // Phase 8: an `alert` is a full-screen layer too (scrim + card).
871
+ this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
872
+ } else if (root.kind === 'sheet') {
873
+ const proposal: Proposal = { width: screen.width, height: null };
874
+ const size = this.measure(root, ctx, proposal);
875
+ this.place(root, ctx, proposal, 0, screen.height - size.height);
876
+ } else {
877
+ const safe: Size = {
878
+ width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
879
+ height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
880
+ };
881
+ const proposal: Proposal = { width: safe.width, height: safe.height };
882
+ const size = this.measure(root, ctx, proposal);
883
+ const { dx, dy } = alignIn(safe, size, 'center');
884
+ this.place(root, ctx, proposal, safeArea.leading + dx, safeArea.top + dy);
885
+ }
886
+ }
887
+ return this.finish();
888
+ }
889
+
890
+ private finish(): LayoutResult {
891
+ // Only what this run produced needs snapping; the rest was snapped when
892
+ // it was produced.
893
+ for (const id of this.touched) {
894
+ const rect = this.frames.get(id);
895
+ if (rect) this.frames.set(id, snapRect(rect));
896
+ const rects = this.chrome.get(id);
897
+ if (rects) {
898
+ const snapped: Record<string, Rect> = {};
899
+ for (const key of Object.keys(rects)) snapped[key] = snapRect(rects[key] as Rect);
900
+ this.chrome.set(id, snapped);
901
+ }
902
+ const size = this.contentSizes.get(id);
903
+ if (size) {
904
+ this.contentSizes.set(id, {
905
+ width: Math.round(fin(size.width) + EPSILON),
906
+ height: Math.round(fin(size.height) + EPSILON),
907
+ });
908
+ }
909
+ }
910
+ return {
911
+ frames: this.frames,
912
+ text: this.text,
913
+ fonts: this.fonts,
914
+ contentSizes: this.contentSizes,
915
+ chrome: this.chrome,
916
+ chromeText: this.chromeText,
917
+ hidden: this.hidden,
918
+ layouts: this.layouts,
919
+ changed: this.touched,
920
+ };
921
+ }
922
+
923
+ // -------------------------------------------------------------------------
924
+ // Phase 9: invalidation across runs
925
+ // -------------------------------------------------------------------------
926
+
927
+ /**
928
+ * `node`'s props changed (or it gained / lost a child): it, its ancestors
929
+ * and its descendants are measured and placed again on the next run.
930
+ */
931
+ invalidate(node: LayoutNode): void {
932
+ this.invalidateUp(node);
933
+ this.invalidateDown(node);
934
+ }
935
+
936
+ /** `node` (or a descendant) changed in a way that can change its size. */
937
+ invalidateUp(node: LayoutNode): void {
938
+ for (let current: LayoutNode | null = node; current; current = this.parentOf(current)) {
939
+ this.forgetPlan(current);
940
+ }
941
+ }
942
+
943
+ /** `node` moved under a new ancestor or its context may have changed: its whole subtree. */
944
+ invalidateDown(node: LayoutNode): void {
945
+ this.forgetPlan(node);
946
+ for (const child of node.children) this.invalidateDown(child);
947
+ }
948
+
949
+ /** `node` left the tree: drop everything its subtree produced. */
950
+ remove(node: LayoutNode): void {
951
+ this.forgetPlan(node);
952
+ if (node.id >= 0) {
953
+ this.frames.delete(node.id);
954
+ this.text.delete(node.id);
955
+ this.fonts.delete(node.id);
956
+ this.contentSizes.delete(node.id);
957
+ this.chrome.delete(node.id);
958
+ this.chromeRun.delete(node.id);
959
+ this.chromeText.delete(node.id);
960
+ this.layouts.delete(node.id);
961
+ this.hidden.delete(node.id);
962
+ }
963
+ this.previousTitles.delete(node);
964
+ for (const child of node.children) this.remove(child);
965
+ }
966
+
967
+ private forgetPlan(node: LayoutNode): void {
968
+ this.memo.delete(node);
969
+ this.placed.delete(node);
970
+ const implicit = this.implicit.get(node);
971
+ if (implicit) {
972
+ this.memo.delete(implicit);
973
+ this.placed.delete(implicit);
974
+ this.implicit.delete(node);
975
+ }
976
+ }
977
+
978
+ /**
979
+ * `child` is not shown by `node` this time (a menu item, an unselected tab,
980
+ * an unchosen `viewthatfits` candidate). Tabs keep their frame; anything
981
+ * else loses its subtree's frames, which would otherwise outlive it.
982
+ */
983
+ private hide(child: LayoutNode, by: LayoutNode): void {
984
+ if (child.id < 0) return;
985
+ this.hidden.add(child.id);
986
+ this.touched.add(child.id);
987
+ const list = this.hiddenBy.get(by);
988
+ if (list) list.push(child.id);
989
+ else this.hiddenBy.set(by, [child.id]);
990
+ if (child.kind !== 'tab') this.dropFrames(child);
991
+ }
992
+
993
+ private dropFrames(node: LayoutNode): void {
994
+ this.placed.delete(node);
995
+ if (node.id >= 0 && this.frames.delete(node.id)) {
996
+ this.text.delete(node.id);
997
+ this.chrome.delete(node.id);
998
+ this.chromeText.delete(node.id);
999
+ this.layouts.delete(node.id);
1000
+ this.touched.add(node.id);
1001
+ }
1002
+ for (const child of node.children) this.dropFrames(child);
1003
+ }
1004
+
1005
+ // -------------------------------------------------------------------------
1006
+ // Memoization and context
1007
+ // -------------------------------------------------------------------------
1008
+
1009
+ private plan<T>(node: LayoutNode, proposal: Proposal, compute: () => T): T {
1010
+ let byProposal = this.memo.get(node);
1011
+ if (!byProposal) {
1012
+ byProposal = new Map();
1013
+ this.memo.set(node, byProposal);
1014
+ }
1015
+ const key = proposalKey(proposal);
1016
+ const cached = byProposal.get(key);
1017
+ if (cached !== undefined) return cached as T;
1018
+ const value = compute();
1019
+ byProposal.set(key, value);
1020
+ return value;
1021
+ }
1022
+
1023
+ /** Context for the children of `node`. */
1024
+ private childContext(node: LayoutNode, ctx: Context): Context {
1025
+ switch (node.kind) {
1026
+ case 'styled': {
1027
+ const style = styleOf(node);
1028
+ let next = ctx;
1029
+ const font = resolveFont(style['font'], ctx.font, this.env);
1030
+ if (font !== ctx.font) next = { ...next, font };
1031
+ if ('lineLimit' in style) {
1032
+ const limit = num(style['lineLimit']);
1033
+ next = { ...next, lineLimit: limit !== null && limit >= 1 ? Math.floor(limit) : null };
1034
+ }
1035
+ const alignment = str(style['multilineTextAlignment']);
1036
+ if (alignment === 'leading' || alignment === 'center' || alignment === 'trailing') {
1037
+ next = { ...next, textAlignment: alignment };
1038
+ }
1039
+ const imageScale = str(style['imageScale']);
1040
+ if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
1041
+ return next;
1042
+ }
1043
+ case 'vstack':
1044
+ return { ...ctx, stackAxis: 'vertical', listRow: false };
1045
+ case 'hstack':
1046
+ return { ...ctx, stackAxis: 'horizontal', listRow: false };
1047
+ case 'gesture':
1048
+ case 'swipeactions':
1049
+ case 'contextmenu':
1050
+ case 'listrow':
1051
+ // Phase 8 wrappers are transparent: a `navlink` under them is still
1052
+ // the list row (its chevron, its selected fill).
1053
+ return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
1054
+ default:
1055
+ if (ctx.stackAxis === null && !ctx.listRow) return ctx;
1056
+ return { ...ctx, stackAxis: null, listRow: false };
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * A container's single content child: the child itself, or an implicit
1062
+ * stack when a TupleView produced several children (vertical by default,
1063
+ * like SwiftUI's implicit VStack).
1064
+ */
1065
+ private contentOf(node: LayoutNode, axis: Axis = 'vertical', children: LayoutNode[] = node.children): LayoutNode | null {
1066
+ if (children.length === 0) return null;
1067
+ if (children.length === 1) return children[0] as LayoutNode;
1068
+ let implicit = this.implicit.get(node);
1069
+ if (!implicit) {
1070
+ implicit = {
1071
+ id: this.implicitIds--,
1072
+ kind: axis === 'vertical' ? 'vstack' : 'hstack',
1073
+ props: { spacing: null, alignment: 'center' },
1074
+ children,
1075
+ };
1076
+ this.implicit.set(node, implicit);
1077
+ }
1078
+ return implicit;
1079
+ }
1080
+
1081
+ /**
1082
+ * Phase 6: a `navscreen`'s children are `[content, ...extras]`, the extras
1083
+ * being `toolbar`s and a `searchfield`. The content is whatever is not an
1084
+ * extra (an implicit vstack when there are several such children).
1085
+ */
1086
+ private navScreenParts(node: LayoutNode): { content: LayoutNode | null; toolbars: LayoutNode[]; search: LayoutNode | null } {
1087
+ const toolbars: LayoutNode[] = [];
1088
+ let search: LayoutNode | null = null;
1089
+ const rest: LayoutNode[] = [];
1090
+ for (const child of node.children) {
1091
+ if (child.kind === 'toolbar') toolbars.push(child);
1092
+ else if (child.kind === 'searchfield') search = search ?? child;
1093
+ else rest.push(child);
1094
+ }
1095
+ return { content: this.contentOf(node, 'vertical', rest), toolbars, search };
1096
+ }
1097
+
1098
+ private setFrame(node: LayoutNode, x: number, y: number, size: Size): void {
1099
+ if (node.id < 0) return;
1100
+ this.frames.set(node.id, { x, y, width: size.width, height: size.height });
1101
+ this.touched.add(node.id);
1102
+ }
1103
+
1104
+ private setChrome(node: LayoutNode, rects: Record<string, Rect>): void {
1105
+ if (node.id < 0) return;
1106
+ this.chrome.set(node.id, rects);
1107
+ this.chromeRun.set(node.id, this.runId);
1108
+ this.touched.add(node.id);
1109
+ }
1110
+
1111
+ // -------------------------------------------------------------------------
1112
+ // Measure
1113
+ // -------------------------------------------------------------------------
1114
+
1115
+ measure(node: LayoutNode, ctx: Context, p: Proposal): Size {
1116
+ switch (node.kind) {
1117
+ case 'text':
1118
+ return this.textPlan(node, ctx, p).size;
1119
+ case 'image':
1120
+ return this.measureImage(node, ctx, p);
1121
+ case 'spacer':
1122
+ return this.measureSpacer(node, ctx, p);
1123
+ case 'divider':
1124
+ if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
1125
+ return { width: finite(p.width) ? p.width : 0, height: 1 };
1126
+ case 'vstack':
1127
+ return this.stackPlan(node, ctx, p, 'vertical').size;
1128
+ case 'hstack':
1129
+ return this.stackPlan(node, ctx, p, 'horizontal').size;
1130
+ case 'zstack':
1131
+ return this.measureZStack(node, ctx, p);
1132
+ case 'styled':
1133
+ return this.styledPlan(node, ctx, p).size;
1134
+ case 'button':
1135
+ return this.measureButton(node, ctx, p);
1136
+ case 'toggle':
1137
+ return this.measureToggle(node, ctx, p);
1138
+ case 'textfield': {
1139
+ const style = str(node.props['style']);
1140
+ return {
1141
+ width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
1142
+ height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1143
+ };
1144
+ }
1145
+ case 'color':
1146
+ case 'shape':
1147
+ return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1148
+ case 'scrollview':
1149
+ return this.scrollPlan(node, ctx, p).size;
1150
+ case 'list':
1151
+ return this.listPlan(node, ctx, p).size;
1152
+ case 'navlink':
1153
+ return this.measureNavLink(node, ctx, p);
1154
+ case 'navstack':
1155
+ case 'navscreen':
1156
+ case 'tabview':
1157
+ case 'tab':
1158
+ case 'navsplit':
1159
+ return {
1160
+ width: p.width ?? this.env.screen.width,
1161
+ height: p.height ?? this.env.screen.height,
1162
+ };
1163
+ case 'viewthatfits':
1164
+ return this.viewThatFitsPlan(node, ctx, p).size;
1165
+ case 'picker':
1166
+ return this.pickerPlan(node, ctx, p).size;
1167
+ case 'geometry':
1168
+ // GeometryReader: the proposal, 10 on unconstrained axes.
1169
+ return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1170
+ case 'sheet':
1171
+ return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
1172
+ // Phase 8 controls.
1173
+ case 'alert':
1174
+ return { width: p.width ?? this.env.screen.width, height: p.height ?? this.env.screen.height };
1175
+ case 'stepper':
1176
+ return this.measureStepper(node, ctx, p);
1177
+ case 'slider':
1178
+ return this.sliderPlan(node, ctx, p).size;
1179
+ case 'datepicker':
1180
+ return this.datePickerPlan(node, ctx, p).size;
1181
+ case 'texteditor':
1182
+ // A textarea filling the proposal (flexible on an infinite axis, like a shape).
1183
+ return { width: p.width ?? TEXTFIELD_DEFAULT_WIDTH, height: p.height ?? TEXTEDITOR_DEFAULT_HEIGHT };
1184
+ case 'layout':
1185
+ return this.measureLayout(node, ctx, p);
1186
+ case 'grid':
1187
+ return this.gridPlan(node, ctx, p).size;
1188
+ case 'lazyvgrid':
1189
+ return this.lazyGridPlan(node, ctx, p).size;
1190
+ case 'gauge':
1191
+ return this.gaugePlan(node, ctx, p).size;
1192
+ case 'chart':
1193
+ return this.chartPlan(node, ctx, p).size;
1194
+ case 'decorated':
1195
+ case 'chartitem': {
1196
+ // Phase 7 `chartitem`: its one view's size (a chart measures it at its ideal size).
1197
+ const content = node.children[0];
1198
+ return content ? this.measure(content, this.childContext(node, ctx), p) : ZERO;
1199
+ }
1200
+ case 'menu': {
1201
+ // Phase 6: the label (the first child) is the only laid-out child.
1202
+ const label = node.children[0];
1203
+ return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1204
+ }
1205
+ case 'searchfield':
1206
+ // Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field.
1207
+ return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: SEARCH_FIELD_HEIGHT };
1208
+ case 'swipeactions':
1209
+ case 'contextmenu': {
1210
+ // Phase 8: the content (the first child) alone; the actions / items
1211
+ // are hidden (the renderer shows them behind the row / in a popover).
1212
+ // A `swipeactions` that is a list row spans the row: the whole row
1213
+ // swipes and its actions sit at the row's trailing edge.
1214
+ const content = node.children[0];
1215
+ const contentSize = content ? this.measure(content, this.childContext(node, ctx), p) : ZERO;
1216
+ if (node.kind === 'swipeactions' && ctx.listRow && finite(p.width)) {
1217
+ return { width: p.width, height: contentSize.height };
1218
+ }
1219
+ return contentSize;
1220
+ }
1221
+ case 'gesture':
1222
+ case 'listrow':
1223
+ case 'tapgesture':
1224
+ default: {
1225
+ // `tapgesture` (its child's size), `toolbar` outside a navscreen,
1226
+ // `section` outside a list and unknown kinds: lay the children out as a vstack.
1227
+ const content = this.contentOf(node);
1228
+ return content ? this.measure(content, this.childContext(node, ctx), p) : ZERO;
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
1234
+ return this.plan(node, p, () => {
1235
+ const text = str(node.props['text']) ?? '';
1236
+ const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1237
+ return {
1238
+ size: { width: wrapped.width, height: wrapped.lines.length * ctx.font.lineHeight },
1239
+ layout: {
1240
+ font: ctx.font,
1241
+ lines: wrapped.lines,
1242
+ alignment: ctx.textAlignment,
1243
+ truncated: wrapped.truncated,
1244
+ },
1245
+ };
1246
+ });
1247
+ }
1248
+
1249
+ private measureSpacer(node: LayoutNode, ctx: Context, p: Proposal): Size {
1250
+ const min = num(node.props['minLength']);
1251
+ if (ctx.stackAxis === null) {
1252
+ // Outside a stack: fills both axes, minimum 0.
1253
+ return { width: finite(p.width) ? p.width : 0, height: finite(p.height) ? p.height : 0 };
1254
+ }
1255
+ const minLength = min ?? DEFAULT_SPACER_MIN;
1256
+ const along = p[ctx.stackAxis === 'horizontal' ? 'width' : 'height'];
1257
+ let length: number;
1258
+ if (along === null) length = minLength;
1259
+ else if (along === Infinity) length = Infinity;
1260
+ else length = Math.max(minLength, along);
1261
+ return ctx.stackAxis === 'horizontal' ? { width: length, height: 0 } : { width: 0, height: length };
1262
+ }
1263
+
1264
+ private measureZStack(node: LayoutNode, ctx: Context, p: Proposal): Size {
1265
+ const cctx = this.childContext(node, ctx);
1266
+ let width = 0;
1267
+ let height = 0;
1268
+ for (const child of node.children) {
1269
+ const size = this.measure(child, cctx, p);
1270
+ width = Math.max(width, size.width);
1271
+ height = Math.max(height, size.height);
1272
+ }
1273
+ return { width, height };
1274
+ }
1275
+
1276
+ private buttonIsBordered(node: LayoutNode): boolean {
1277
+ const style = str(node.props['style']);
1278
+ return style === 'bordered' || style === 'borderedProminent';
1279
+ }
1280
+
1281
+ private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
1282
+ const label = this.contentOf(node);
1283
+ const cctx = this.childContext(node, ctx);
1284
+ if (!this.buttonIsBordered(node)) return label ? this.measure(label, cctx, p) : ZERO;
1285
+ const inner: Proposal = {
1286
+ width: shrink(p.width, 2 * BORDERED_PAD_H),
1287
+ height: shrink(p.height, 2 * BORDERED_PAD_V),
1288
+ };
1289
+ const size = label ? this.measure(label, cctx, inner) : ZERO;
1290
+ return {
1291
+ width: size.width + 2 * BORDERED_PAD_H,
1292
+ height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V),
1293
+ };
1294
+ }
1295
+
1296
+ private toggleLabelProposal(p: Proposal): Proposal {
1297
+ return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
1298
+ }
1299
+
1300
+ private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
1301
+ const label = this.contentOf(node);
1302
+ const labelSize = label
1303
+ ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
1304
+ : ZERO;
1305
+ return {
1306
+ width: finite(p.width) ? p.width : labelSize.width + TOGGLE_GAP + SWITCH_WIDTH,
1307
+ height: Math.max(labelSize.height, SWITCH_HEIGHT),
1308
+ };
1309
+ }
1310
+
1311
+ private navLinkLabelProposal(ctx: Context, p: Proposal): Proposal {
1312
+ return ctx.listRow ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
1313
+ }
1314
+
1315
+ private measureNavLink(node: LayoutNode, ctx: Context, p: Proposal): Size {
1316
+ const label = this.contentOf(node);
1317
+ const labelSize = label
1318
+ ? this.measure(label, this.childContext(node, ctx), this.navLinkLabelProposal(ctx, p))
1319
+ : ZERO;
1320
+ if (ctx.listRow && finite(p.width)) return { width: p.width, height: labelSize.height };
1321
+ return labelSize;
1322
+ }
1323
+
1324
+ private sheetHeight(node: LayoutNode): number {
1325
+ const detents = node.props['detents'];
1326
+ const first = Array.isArray(detents) ? str(detents[0]) : null;
1327
+ const { screen, safeArea } = this.env;
1328
+ if (first === 'medium') return screen.height / 2;
1329
+ // Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
1330
+ if (first === 'full') return screen.height;
1331
+ return screen.height - safeArea.top - SHEET_TOP_GAP;
1332
+ }
1333
+
1334
+ /** Phase 8: a sheet whose first detent is `full` has no grabber row. */
1335
+ private sheetIsFull(node: LayoutNode): boolean {
1336
+ const detents = node.props['detents'];
1337
+ return Array.isArray(detents) && str(detents[0]) === 'full';
1338
+ }
1339
+
1340
+ // -------------------------------------------------------------------------
1341
+ // Picker (Phase 4)
1342
+ // -------------------------------------------------------------------------
1343
+
1344
+ /**
1345
+ * `segmented`: 32 high; the width is the proposal, or every segment label
1346
+ * plus 20 when none is given. `menu`: 44 high in a list row, 34 elsewhere;
1347
+ * the width is the proposal, or label + gap + value + chevron. `inline`
1348
+ * (Phase 6): one 44 row per option, plus a label row when the label is
1349
+ * non-empty; the width is the proposal, or the widest row.
1350
+ */
1351
+ private pickerPlan(node: LayoutNode, ctx: Context, p: Proposal): PickerPlan {
1352
+ return this.plan(node, p, () => {
1353
+ const options = readOptions(node);
1354
+ const selected = readSelected(node, options.length);
1355
+ const style = str(node.props['style']);
1356
+ if (style === 'inline') {
1357
+ const font = ctx.font;
1358
+ const label = str(node.props['label']) ?? '';
1359
+ const labelRow = label.length > 0;
1360
+ let natural = labelRow ? this.measurer.measureWidth(label, font) : 0;
1361
+ for (const option of options) {
1362
+ natural = Math.max(natural, this.measurer.measureWidth(option, font) + PICKER_INLINE_CHECK_WIDTH);
1363
+ }
1364
+ const rows = options.length + (labelRow ? 1 : 0);
1365
+ return {
1366
+ size: { width: finite(p.width) ? p.width : natural, height: rows * PICKER_INLINE_ROW_HEIGHT },
1367
+ style: 'inline',
1368
+ font,
1369
+ options,
1370
+ selected,
1371
+ labelWidth: 0,
1372
+ valueWidth: 0,
1373
+ labelRow,
1374
+ };
1375
+ }
1376
+ if (style === 'segmented') {
1377
+ const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
1378
+ let natural = 0;
1379
+ for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
1380
+ return {
1381
+ size: { width: finite(p.width) ? p.width : natural, height: PICKER_SEGMENTED_HEIGHT },
1382
+ style: 'segmented',
1383
+ font,
1384
+ options,
1385
+ selected,
1386
+ labelWidth: 0,
1387
+ valueWidth: 0,
1388
+ labelRow: false,
1389
+ };
1390
+ }
1391
+ const font = ctx.font;
1392
+ const labelWidth = this.measurer.measureWidth(str(node.props['label']) ?? '', font);
1393
+ const value = options[selected] ?? '';
1394
+ const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
1395
+ const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
1396
+ return {
1397
+ size: {
1398
+ width: finite(p.width) ? p.width : natural,
1399
+ height: ctx.listRow ? PICKER_MENU_ROW_HEIGHT : PICKER_MENU_HEIGHT,
1400
+ },
1401
+ style: 'menu',
1402
+ font,
1403
+ options,
1404
+ selected,
1405
+ labelWidth,
1406
+ valueWidth,
1407
+ labelRow: false,
1408
+ };
1409
+ });
1410
+ }
1411
+
1412
+ private placePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
1413
+ const plan = this.pickerPlan(node, ctx, p);
1414
+ // Inside a list the row chrome was set before placing; keep it.
1415
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
1416
+ const chrome: Record<string, Rect> = { ...existing };
1417
+ if (plan.style === 'segmented') {
1418
+ const n = Math.max(1, plan.options.length);
1419
+ const segmentWidth = size.width / n;
1420
+ for (let i = 0; i < plan.options.length; i++) {
1421
+ chrome[`segment${i}`] = { x: x + i * segmentWidth, y, width: segmentWidth, height: size.height };
1422
+ }
1423
+ } else if (plan.style === 'inline') {
1424
+ // Rows span the list row when there is one (the hit area and the
1425
+ // separators match the other rows), else the control's frame.
1426
+ const row = existing?.['row'];
1427
+ const rowX = row ? row.x : x;
1428
+ const rowWidth = row ? row.width : size.width;
1429
+ let rowY = y;
1430
+ if (plan.labelRow) {
1431
+ chrome['label'] = { x: rowX, y: rowY, width: rowWidth, height: PICKER_INLINE_ROW_HEIGHT };
1432
+ rowY += PICKER_INLINE_ROW_HEIGHT;
1433
+ }
1434
+ for (let i = 0; i < plan.options.length; i++) {
1435
+ chrome[`option${i}`] = { x: rowX, y: rowY, width: rowWidth, height: PICKER_INLINE_ROW_HEIGHT };
1436
+ rowY += PICKER_INLINE_ROW_HEIGHT;
1437
+ }
1438
+ } else {
1439
+ const valueWidth = Math.min(plan.valueWidth, size.width);
1440
+ const labelWidth = Math.min(plan.labelWidth, Math.max(0, size.width - valueWidth - PICKER_MENU_GAP));
1441
+ chrome['label'] = { x, y, width: labelWidth, height: size.height };
1442
+ chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
1443
+ }
1444
+ this.setChrome(node, chrome);
1445
+ if (node.id >= 0) this.fonts.set(node.id, plan.font);
1446
+ }
1447
+
1448
+ // -------------------------------------------------------------------------
1449
+ // Stacks
1450
+ // -------------------------------------------------------------------------
1451
+
1452
+ private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
1453
+ return this.plan(node, p, () => {
1454
+ const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
1455
+ return this.layoutStack(node.children, this.childContext(node, ctx), p, axis, spacing);
1456
+ });
1457
+ }
1458
+
1459
+ /**
1460
+ * SwiftUI's stack algorithm. With an axis proposal, every child's minimum
1461
+ * and maximum along the axis (proposals 0 and Infinity, cross fixed) give
1462
+ * its flexibility; space is handed out by descending `layoutPriority`, then
1463
+ * least flexible first. A child is offered `remaining / childrenLeft`, where
1464
+ * `remaining` excludes the minimum sizes of every lower-priority child and
1465
+ * `childrenLeft` counts only the unsized children of its own priority group
1466
+ * with *finite* flexibility: infinitely flexible children (spacers, colors,
1467
+ * `maxWidth: infinity` frames, text fields) only reserve their minimum and
1468
+ * share whatever is left once everything else is sized, which is why a
1469
+ * `Spacer` never makes its sibling text wrap. With a `null` axis proposal
1470
+ * every child is proposed `null` and the stack sizes to the sum.
1471
+ */
1472
+ private layoutStack(
1473
+ children: readonly LayoutNode[],
1474
+ cctx: Context,
1475
+ p: Proposal,
1476
+ axis: Axis,
1477
+ spacing: number,
1478
+ ): StackPlan {
1479
+ const n = children.length;
1480
+ if (n === 0) return { size: ZERO, childSizes: [], childProposals: [] };
1481
+ const along = axis === 'horizontal' ? p.width : p.height;
1482
+ const cross = axis === 'horizontal' ? p.height : p.width;
1483
+ const totalSpacing = spacing * (n - 1);
1484
+ const childSizes: Size[] = new Array<Size>(n);
1485
+ const childProposals: Proposal[] = new Array<Proposal>(n);
1486
+
1487
+ if (along === null) {
1488
+ const proposal = makeProposal(axis, null, cross);
1489
+ for (let i = 0; i < n; i++) {
1490
+ childProposals[i] = proposal;
1491
+ childSizes[i] = this.measure(children[i] as LayoutNode, cctx, proposal);
1492
+ }
1493
+ } else {
1494
+ const mins = new Array<number>(n);
1495
+ const flex = new Array<number>(n);
1496
+ const priorities = new Array<number>(n);
1497
+ const minProposal = makeProposal(axis, 0, cross);
1498
+ const maxProposal = makeProposal(axis, Infinity, cross);
1499
+ for (let i = 0; i < n; i++) {
1500
+ const child = children[i] as LayoutNode;
1501
+ const min = axisOf(this.measure(child, cctx, minProposal), axis);
1502
+ const max = axisOf(this.measure(child, cctx, maxProposal), axis);
1503
+ mins[i] = min;
1504
+ flex[i] = Number.isFinite(max) ? Math.max(0, max - min) : Infinity;
1505
+ priorities[i] = layoutPriorityOf(child);
1506
+ }
1507
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => {
1508
+ const pa = priorities[a] as number;
1509
+ const pb = priorities[b] as number;
1510
+ if (pa !== pb) return pb - pa;
1511
+ const fa = flex[a] as number;
1512
+ const fb = flex[b] as number;
1513
+ if (fa !== fb) return fa < fb ? -1 : 1;
1514
+ return a - b;
1515
+ });
1516
+
1517
+ let remaining = Math.max(0, along - totalSpacing);
1518
+ let i = 0;
1519
+ while (i < n) {
1520
+ // One priority group at a time (sorted least flexible first, so the
1521
+ // infinitely flexible children of a group come last).
1522
+ const priority = priorities[order[i] as number] as number;
1523
+ let end = i;
1524
+ while (end < n && priorities[order[end] as number] === priority) end++;
1525
+ let lowerMin = 0;
1526
+ for (let j = end; j < n; j++) lowerMin += mins[order[j] as number] as number;
1527
+ let finiteLeft = 0;
1528
+ let infiniteLeft = 0;
1529
+ let infiniteMin = 0;
1530
+ for (let k = i; k < end; k++) {
1531
+ const index = order[k] as number;
1532
+ if (Number.isFinite(flex[index])) finiteLeft++;
1533
+ else {
1534
+ infiniteLeft++;
1535
+ infiniteMin += mins[index] as number;
1536
+ }
1537
+ }
1538
+ for (let k = i; k < end; k++) {
1539
+ const index = order[k] as number;
1540
+ const isInfinite = !Number.isFinite(flex[index]);
1541
+ let offer: number;
1542
+ if (!Number.isFinite(remaining)) offer = Infinity;
1543
+ else if (isInfinite) offer = Math.max(0, remaining - lowerMin) / infiniteLeft;
1544
+ else offer = Math.max(0, remaining - lowerMin - infiniteMin) / finiteLeft;
1545
+ const proposal = makeProposal(axis, offer, cross);
1546
+ const size = this.measure(children[index] as LayoutNode, cctx, proposal);
1547
+ childProposals[index] = proposal;
1548
+ childSizes[index] = size;
1549
+ if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
1550
+ if (isInfinite) {
1551
+ infiniteLeft--;
1552
+ infiniteMin -= mins[index] as number;
1553
+ } else finiteLeft--;
1554
+ }
1555
+ i = end;
1556
+ }
1557
+ }
1558
+
1559
+ let total = totalSpacing;
1560
+ let crossSize = 0;
1561
+ for (let i = 0; i < n; i++) {
1562
+ const size = childSizes[i] as Size;
1563
+ total += axisOf(size, axis);
1564
+ crossSize = Math.max(crossSize, crossOf(size, axis));
1565
+ }
1566
+ const size: Size =
1567
+ axis === 'horizontal' ? { width: total, height: crossSize } : { width: crossSize, height: total };
1568
+ return { size, childSizes, childProposals };
1569
+ }
1570
+
1571
+ private placeStack(
1572
+ children: readonly LayoutNode[],
1573
+ cctx: Context,
1574
+ plan: StackPlan,
1575
+ axis: Axis,
1576
+ alignment: string,
1577
+ spacing: number,
1578
+ x: number,
1579
+ y: number,
1580
+ size: Size,
1581
+ ): void {
1582
+ const anchors = alignmentAnchors(alignment);
1583
+ const crossAnchor = axis === 'horizontal' ? anchors[1] : anchors[0];
1584
+ const crossSize = crossOf(size, axis);
1585
+ let cursor = 0;
1586
+ for (let i = 0; i < children.length; i++) {
1587
+ const child = children[i] as LayoutNode;
1588
+ const childSize = plan.childSizes[i] as Size;
1589
+ const proposal = plan.childProposals[i] as Proposal;
1590
+ const crossOffset = safeOffset(crossSize, crossOf(childSize, axis), crossAnchor);
1591
+ if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
1592
+ else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
1593
+ cursor += axisOf(childSize, axis) + spacing;
1594
+ }
1595
+ }
1596
+
1597
+ // -------------------------------------------------------------------------
1598
+ // Image
1599
+ // -------------------------------------------------------------------------
1600
+
1601
+ /**
1602
+ * An SF Symbol (`systemName`) and a catalog `symbol` are glyphs: a square
1603
+ * of the font's line height. A catalog `image` has an intrinsic point size;
1604
+ * `.resizable()` makes it fill whatever is proposed (the intrinsic size on
1605
+ * a nil axis, like SwiftUI). A name the manifest does not know measures a
1606
+ * fixed 44×44 so the placeholder is visible.
1607
+ */
1608
+ private measureImage(node: LayoutNode, ctx: Context, p: Proposal): Size {
1609
+ const name = str(node.props['name']);
1610
+ if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
1611
+ const asset = this.env.assets?.images[name];
1612
+ if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1613
+ if (asset.kind === 'symbol') return square(ctx.font.lineHeight * ctx.imageScale);
1614
+ const width = num(asset.width) ?? 0;
1615
+ const height = num(asset.height) ?? 0;
1616
+ if (node.props['resizable'] !== true) return { width, height };
1617
+ return { width: p.width ?? width, height: p.height ?? height };
1618
+ }
1619
+
1620
+ // -------------------------------------------------------------------------
1621
+ // Styled
1622
+ // -------------------------------------------------------------------------
1623
+
1624
+ /**
1625
+ * One `styled` element may carry several layout effects; they nest as
1626
+ * frame (outermost) > padding > fixedSize > aspectRatio > child.
1627
+ */
1628
+ private styledPlan(node: LayoutNode, ctx: Context, p: Proposal): StyledPlan {
1629
+ return this.plan(node, p, () => {
1630
+ const style = styleOf(node);
1631
+ let frame = readFrame(style['frame']);
1632
+ // Phase 12 `.containerRelativeFrame`: an affine size against the nearest
1633
+ // container (screen or scroll viewport), taking the place of a fixed frame.
1634
+ const relative = readContainerRelativeFrame(style['containerRelativeFrame']);
1635
+ if (relative) {
1636
+ const base: FrameSpec = frame ?? { width: null, height: null, minWidth: null, maxWidth: null, minHeight: null, maxHeight: null, alignment: relative.alignment };
1637
+ frame = {
1638
+ ...base,
1639
+ alignment: frame?.alignment ?? relative.alignment,
1640
+ width: relative.horizontal ? Math.max(0, relative.a * ctx.container.width + relative.b) : base.width,
1641
+ height: relative.vertical ? Math.max(0, relative.a * ctx.container.height + relative.b) : base.height,
1642
+ };
1643
+ }
1644
+ const padding = readInsets(style['padding']);
1645
+ const aspect = readAspectRatio(style['aspectRatio']);
1646
+ const fixed = obj(style['fixedSize']);
1647
+ const fixedH = fixed?.['horizontal'] === true;
1648
+ const fixedV = fixed?.['vertical'] === true;
1649
+ const cctx = this.childContext(node, ctx);
1650
+ const child = this.contentOf(node);
1651
+
1652
+ // Frame step: the proposal handed to the padded content.
1653
+ let innerW: number | null = p.width;
1654
+ let innerH: number | null = p.height;
1655
+ if (frame) {
1656
+ if (frame.width !== null) innerW = frame.width;
1657
+ else if (frame.minWidth !== null || frame.maxWidth !== null)
1658
+ innerW = p.width === null ? null : clamp(p.width, frame.minWidth, frame.maxWidth);
1659
+ if (frame.height !== null) innerH = frame.height;
1660
+ else if (frame.minHeight !== null || frame.maxHeight !== null)
1661
+ innerH = p.height === null ? null : clamp(p.height, frame.minHeight, frame.maxHeight);
1662
+ }
1663
+ const framedProposal: Proposal = { width: innerW, height: innerH };
1664
+
1665
+ // Padding step.
1666
+ const padW = padding ? padding.leading + padding.trailing : 0;
1667
+ const padH = padding ? padding.top + padding.bottom : 0;
1668
+ let childProposal: Proposal = {
1669
+ width: fixedH ? null : shrink(framedProposal.width, padW),
1670
+ height: fixedV ? null : shrink(framedProposal.height, padH),
1671
+ };
1672
+ // Aspect ratio step: the child is proposed the fitted (or filling) size.
1673
+ if (aspect && child) childProposal = this.aspectProposal(child, cctx, childProposal, aspect);
1674
+ // Phase 6 `.position(x:y:)`: the box fills the proposal (the child's
1675
+ // ideal size on nil axes); the child is measured with a nil proposal.
1676
+ const point = readPoint(style['position']);
1677
+ let position: StyledPlan['position'] = null;
1678
+ let childSize: Size;
1679
+ if (point && child) {
1680
+ const ideal = this.measure(child, cctx, NIL_PROPOSAL);
1681
+ position = { x: point.x, y: point.y, ideal };
1682
+ childSize = {
1683
+ width: finite(childProposal.width) ? childProposal.width : ideal.width,
1684
+ height: finite(childProposal.height) ? childProposal.height : ideal.height,
1685
+ };
1686
+ } else {
1687
+ childSize = child ? this.measure(child, cctx, childProposal) : ZERO;
1688
+ }
1689
+ let padded: Size = { width: childSize.width + padW, height: childSize.height + padH };
1690
+
1691
+ // Report back through the frame.
1692
+ let width = padded.width;
1693
+ let height = padded.height;
1694
+ if (frame) {
1695
+ if (frame.width !== null) width = frame.width;
1696
+ else if (frame.minWidth !== null || frame.maxWidth !== null)
1697
+ width = clamp(p.width === null ? padded.width : p.width, frame.minWidth, frame.maxWidth);
1698
+ if (frame.height !== null) height = frame.height;
1699
+ else if (frame.minHeight !== null || frame.maxHeight !== null)
1700
+ height = clamp(p.height === null ? padded.height : p.height, frame.minHeight, frame.maxHeight);
1701
+ // A flexible frame proposes its own resolved size to the child (so
1702
+ // `Rectangle().frame(minHeight: 100)` fills 100, as in SwiftUI). Only
1703
+ // when a nil-proposed axis was clamped up to a minimum does the child
1704
+ // see a proposal it has not seen yet.
1705
+ const grewW = frame.width === null && p.width === null && width > padded.width;
1706
+ const grewH = frame.height === null && p.height === null && height > padded.height;
1707
+ if ((grewW || grewH) && child && !position) {
1708
+ const again: Proposal = {
1709
+ width: grewW ? Math.max(0, width - padW) : childProposal.width,
1710
+ height: grewH ? Math.max(0, height - padH) : childProposal.height,
1711
+ };
1712
+ childProposal = again;
1713
+ childSize = this.measure(child, cctx, again);
1714
+ padded = { width: childSize.width + padW, height: childSize.height + padH };
1715
+ }
1716
+ }
1717
+ const contentBox: Rect = {
1718
+ x: padding ? padding.leading : 0,
1719
+ y: padding ? padding.top : 0,
1720
+ width: Math.max(0, width - padW),
1721
+ height: Math.max(0, height - padH),
1722
+ };
1723
+ return {
1724
+ size: { width, height },
1725
+ childSize,
1726
+ childProposal,
1727
+ contentBox,
1728
+ alignment: frame?.alignment ?? 'center',
1729
+ position,
1730
+ };
1731
+ });
1732
+ }
1733
+
1734
+ /**
1735
+ * `.aspectRatio(_:contentMode:)`: the size with the ratio (the child's ideal
1736
+ * ratio when `ratio` is null) that fits inside the proposal, or covers it
1737
+ * for `fill`. One nil/infinite axis is derived from the other; with neither
1738
+ * axis given the child's ideal size is proposed. The child reports its own
1739
+ * size for that proposal (a resizable image or a shape takes it exactly).
1740
+ */
1741
+ private aspectProposal(child: LayoutNode, cctx: Context, p: Proposal, aspect: AspectRatioSpec): Proposal {
1742
+ const ideal = (): Size => this.measure(child, cctx, { width: null, height: null });
1743
+ let ratio = aspect.ratio;
1744
+ if (ratio === null) {
1745
+ const size = ideal();
1746
+ ratio = size.width > 0 && size.height > 0 && Number.isFinite(size.width / size.height) ? size.width / size.height : 1;
1747
+ }
1748
+ // An unbounded box on both axes: the child is unbounded too (a flexible
1749
+ // child fills it, so `Color.aspectRatio(1)` is not pinned to its ideal
1750
+ // when a custom layout probes its maximum).
1751
+ if (p.width === Infinity && p.height === Infinity) return { width: Infinity, height: Infinity };
1752
+ const pw = finite(p.width) ? p.width : null;
1753
+ const ph = finite(p.height) ? p.height : null;
1754
+ if (pw !== null && ph !== null) {
1755
+ // Fit: the larger of the two candidate sizes still inside the box;
1756
+ // fill: the smaller one that covers it. Comparing the box's ratio to
1757
+ // the target decides which axis binds.
1758
+ const boxWider = ph === 0 ? true : pw / ph > ratio;
1759
+ const byHeight = boxWider === !aspect.fill;
1760
+ return byHeight ? { width: ph * ratio, height: ph } : { width: pw, height: pw / ratio };
1761
+ }
1762
+ if (pw !== null) return { width: pw, height: pw / ratio };
1763
+ if (ph !== null) return { width: ph * ratio, height: ph };
1764
+ const size = ideal();
1765
+ return { width: size.width, height: size.height };
1766
+ }
1767
+
1768
+ // -------------------------------------------------------------------------
1769
+ // ScrollView
1770
+ // -------------------------------------------------------------------------
1771
+
1772
+ private scrollAxes(node: LayoutNode): { horizontal: boolean; vertical: boolean } {
1773
+ const axes = str(node.props['axes']) ?? 'vertical';
1774
+ return {
1775
+ horizontal: axes === 'horizontal' || axes === 'both',
1776
+ vertical: axes === 'vertical' || axes === 'both',
1777
+ };
1778
+ }
1779
+
1780
+ private scrollPlan(node: LayoutNode, ctx: Context, p: Proposal): ScrollPlan {
1781
+ return this.plan(node, p, () => {
1782
+ const axes = this.scrollAxes(node);
1783
+ const content = this.contentOf(node, axes.horizontal && !axes.vertical ? 'horizontal' : 'vertical');
1784
+ // Phase 12: the viewport is the container `containerRelativeFrame` measures against.
1785
+ const viewport: Size = {
1786
+ width: finite(p.width) ? p.width : ctx.container.width,
1787
+ height: finite(p.height) ? p.height : ctx.container.height,
1788
+ };
1789
+ const cctx: Context = { ...this.childContext(node, ctx), container: viewport };
1790
+ // Along scroll axes the content is unconstrained; elsewhere it gets the
1791
+ // scroll view's own size (which is the proposal when one was given).
1792
+ const contentProposal: Proposal = {
1793
+ width: axes.horizontal ? null : finite(p.width) ? p.width : null,
1794
+ height: axes.vertical ? null : finite(p.height) ? p.height : null,
1795
+ };
1796
+ const contentSize = content ? this.measure(content, cctx, contentProposal) : ZERO;
1797
+ const size: Size = {
1798
+ width: p.width === null ? contentSize.width : p.width,
1799
+ height: p.height === null ? contentSize.height : p.height,
1800
+ };
1801
+ return { size, content, contentProposal, contentSize };
1802
+ });
1803
+ }
1804
+
1805
+ // -------------------------------------------------------------------------
1806
+ // List
1807
+ // -------------------------------------------------------------------------
1808
+
1809
+ private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
1810
+ return this.plan(node, p, () => {
1811
+ const { screen } = this.env;
1812
+ const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
1813
+ const layoutWidth = Number.isFinite(width) ? width : screen.width;
1814
+ // `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
1815
+ // sections are full width. `plain` also drops the top/bottom gaps and
1816
+ // the space between sections.
1817
+ const listStyle = str(node.props['style']) ?? 'insetGrouped';
1818
+ const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
1819
+ const plain = listStyle === 'plain';
1820
+ const listTop = plain ? 0 : LIST_TOP;
1821
+ const listBottom = plain ? 0 : LIST_BOTTOM;
1822
+ const sectionGap = plain ? 0 : LIST_SECTION_GAP;
1823
+ const cardX = inset;
1824
+ const cardWidth = Math.max(0, layoutWidth - 2 * inset);
1825
+ const contentX = inset + LIST_CELL_PADDING;
1826
+ const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
1827
+ const headerFont = makeFont(
1828
+ 'system',
1829
+ textStyleSize('footnote', this.env.dynamicTypeSize),
1830
+ 400,
1831
+ );
1832
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
1833
+ // Phase 8: in edit mode every `listrow` gains a 44 leading control and
1834
+ // its content shifts right by as much.
1835
+ const editing = node.props['editing'] === true;
1836
+
1837
+ // Group consecutive plain rows into an implicit section.
1838
+ const groupsSource: { section: LayoutNode | null; rows: LayoutNode[] }[] = [];
1839
+ for (const child of node.children) {
1840
+ if (child.kind === 'section') {
1841
+ groupsSource.push({ section: child, rows: child.children });
1842
+ } else {
1843
+ const last = groupsSource[groupsSource.length - 1];
1844
+ if (last && last.section === null) last.rows.push(child);
1845
+ else groupsSource.push({ section: null, rows: [child] });
1846
+ }
1847
+ }
1848
+
1849
+ const groups: ListGroupPlan[] = [];
1850
+ let y = listTop;
1851
+ const makeText = (text: string): { layout: TextLayout; size: Size } => {
1852
+ const wrapped = wrapText(text.toUpperCase(), headerFont, contentWidth, null, this.measurer);
1853
+ return {
1854
+ layout: { font: headerFont, lines: wrapped.lines, alignment: 'leading', truncated: false },
1855
+ size: { width: wrapped.width, height: wrapped.lines.length * headerFont.lineHeight },
1856
+ };
1857
+ };
1858
+ for (let g = 0; g < groupsSource.length; g++) {
1859
+ const source = groupsSource[g] as { section: LayoutNode | null; rows: LayoutNode[] };
1860
+ if (g > 0) y += sectionGap;
1861
+ const top = y;
1862
+ let header: ListGroupPlan['header'] = null;
1863
+ let footer: ListGroupPlan['footer'] = null;
1864
+ const headerText = source.section ? str(source.section.props['header']) : null;
1865
+ const footerText = source.section ? str(source.section.props['footer']) : null;
1866
+ if (headerText !== null && headerText.length > 0) {
1867
+ const { layout, size } = makeText(headerText);
1868
+ header = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
1869
+ y += size.height + LIST_HEADER_GAP;
1870
+ }
1871
+ const cardTop = y;
1872
+ const rows: ListRowPlan[] = [];
1873
+ for (const rowNode of source.rows) {
1874
+ // A picker already reports its row height (44 for a menu, 44 per
1875
+ // option inline); other rows get the 11px vertical padding. Phase 6
1876
+ // `listRowInsets` replaces all four insets; a `badge` reserves its
1877
+ // width (plus a gap) at the trailing edge.
1878
+ const hints = rowHintsOf(rowNode);
1879
+ const defaultVPad = unwrapStyled(rowNode).kind === 'picker' ? 0 : LIST_ROW_VPAD;
1880
+ const insets: EdgeInsets = hints.insets ?? {
1881
+ top: defaultVPad,
1882
+ leading: LIST_CELL_PADDING,
1883
+ bottom: defaultVPad,
1884
+ trailing: LIST_CELL_PADDING,
1885
+ };
1886
+ const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
1887
+ const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
1888
+ const listRow = editing ? listRowOf(rowNode) : null;
1889
+ const editShift = listRow ? LIST_EDIT_CONTROL_WIDTH : 0;
1890
+ const rowContentX = cardX + insets.leading + editShift;
1891
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
1892
+ const proposal: Proposal = { width: rowContentWidth, height: null };
1893
+ const contentSize = this.measure(rowNode, rowCtx, proposal);
1894
+ const rowHeight = Math.max(LIST_ROW_MIN_HEIGHT, contentSize.height + insets.top + insets.bottom);
1895
+ const row: Rect = { x: cardX, y, width: cardWidth, height: rowHeight };
1896
+ rows.push({
1897
+ node: rowNode,
1898
+ proposal,
1899
+ contentSize,
1900
+ row,
1901
+ content: {
1902
+ x: rowContentX,
1903
+ y: y + insets.top + (rowHeight - insets.top - insets.bottom - contentSize.height) / 2,
1904
+ width: contentSize.width,
1905
+ height: contentSize.height,
1906
+ },
1907
+ badge:
1908
+ hints.badge !== null
1909
+ ? {
1910
+ rect: { x: cardX + cardWidth - insets.trailing - badgeWidth, y, width: badgeWidth, height: rowHeight },
1911
+ layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
1912
+ }
1913
+ : null,
1914
+ edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
1915
+ });
1916
+ y += rowHeight;
1917
+ }
1918
+ const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
1919
+ if (footerText !== null && footerText.length > 0) {
1920
+ y += LIST_FOOTER_GAP;
1921
+ const { layout, size } = makeText(footerText);
1922
+ footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
1923
+ y += size.height;
1924
+ }
1925
+ groups.push({
1926
+ section: source.section,
1927
+ frame: { x: cardX, y: top, width: cardWidth, height: y - top },
1928
+ header,
1929
+ footer,
1930
+ card,
1931
+ rows,
1932
+ });
1933
+ }
1934
+ const contentHeight = groupsSource.length === 0 ? listTop : y + listBottom;
1935
+ const size: Size = {
1936
+ width,
1937
+ height: p.height === null ? contentHeight : p.height,
1938
+ };
1939
+ return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups };
1940
+ });
1941
+ }
1942
+
1943
+ // -------------------------------------------------------------------------
1944
+ // Place
1945
+ // -------------------------------------------------------------------------
1946
+
1947
+ place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
1948
+ // Phase 9: placed before, same proposal, same origin, nothing in the
1949
+ // subtree invalidated since: the last run's output still holds.
1950
+ const key = proposalKey(p);
1951
+ const last = this.placed.get(node);
1952
+ if (last && last.key === key && last.x === x && last.y === y) {
1953
+ this.stats.skipped += 1;
1954
+ return;
1955
+ }
1956
+ this.placed.set(node, { key, x, y });
1957
+ this.stats.placed += 1;
1958
+ if (node.id >= 0) {
1959
+ this.touched.add(node.id);
1960
+ // Chrome from an earlier run is stale (a parent's `row` / `badge` set in
1961
+ // this run just before placing the child is kept and merged into).
1962
+ if (this.chromeRun.get(node.id) !== this.runId) this.chrome.delete(node.id);
1963
+ }
1964
+ const previouslyHidden = this.hiddenBy.get(node);
1965
+ if (previouslyHidden) {
1966
+ for (const id of previouslyHidden) {
1967
+ this.hidden.delete(id);
1968
+ this.touched.add(id);
1969
+ }
1970
+ this.hiddenBy.delete(node);
1971
+ }
1972
+ const size = this.measure(node, ctx, p);
1973
+ this.setFrame(node, x, y, size);
1974
+ const cctx = this.childContext(node, ctx);
1975
+ switch (node.kind) {
1976
+ case 'text':
1977
+ if (node.id >= 0) this.text.set(node.id, this.textPlan(node, ctx, p).layout);
1978
+ return;
1979
+ case 'image':
1980
+ if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
1981
+ return;
1982
+ case 'spacer':
1983
+ case 'divider':
1984
+ case 'textfield':
1985
+ case 'color':
1986
+ case 'shape':
1987
+ return;
1988
+ case 'vstack':
1989
+ case 'hstack': {
1990
+ const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
1991
+ const plan = this.stackPlan(node, ctx, p, axis);
1992
+ const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
1993
+ const alignment = str(node.props['alignment']) ?? 'center';
1994
+ this.placeStack(node.children, cctx, plan, axis, alignment, spacing, x, y, size);
1995
+ return;
1996
+ }
1997
+ case 'zstack': {
1998
+ const alignment = str(node.props['alignment']) ?? 'center';
1999
+ for (const child of node.children) {
2000
+ const childSize = this.measure(child, cctx, p);
2001
+ const { dx, dy } = alignIn(size, childSize, alignment);
2002
+ this.place(child, cctx, p, x + dx, y + dy);
2003
+ }
2004
+ return;
2005
+ }
2006
+ case 'styled': {
2007
+ let plan = this.styledPlan(node, ctx, p);
2008
+ const child = this.contentOf(node);
2009
+ // Phase 8 `.ignoresSafeArea`: the box grows to the screen edge on the
2010
+ // sides where it touches the safe area (the parent's layout is
2011
+ // unchanged). A flexible child (a color, gradient or resizable image)
2012
+ // is proposed the grown box and fills it; a child whose size does not
2013
+ // change stays where the parent put it.
2014
+ const extension = this.safeAreaExtension(node, ctx, x, y, size);
2015
+ if (extension) {
2016
+ const grown: Size = {
2017
+ width: size.width + extension.leading + extension.trailing,
2018
+ height: size.height + extension.top + extension.bottom,
2019
+ };
2020
+ this.setFrame(node, x - extension.leading, y - extension.top, grown);
2021
+ const grownPlan = this.styledPlan(node, ctx, { width: grown.width, height: grown.height });
2022
+ if (grownPlan.childSize.width !== plan.childSize.width || grownPlan.childSize.height !== plan.childSize.height) {
2023
+ plan = grownPlan;
2024
+ x -= extension.leading;
2025
+ y -= extension.top;
2026
+ }
2027
+ }
2028
+ if (child) {
2029
+ const box = plan.contentBox;
2030
+ if (plan.position) {
2031
+ const { ideal } = plan.position;
2032
+ this.place(
2033
+ child,
2034
+ cctx,
2035
+ NIL_PROPOSAL,
2036
+ x + box.x + plan.position.x - ideal.width / 2,
2037
+ y + box.y + plan.position.y - ideal.height / 2,
2038
+ );
2039
+ return;
2040
+ }
2041
+ const { dx, dy } = alignIn(box, plan.childSize, plan.alignment);
2042
+ this.place(child, cctx, plan.childProposal, x + box.x + dx, y + box.y + dy);
2043
+ }
2044
+ return;
2045
+ }
2046
+ case 'button': {
2047
+ const label = this.contentOf(node);
2048
+ if (!label) return;
2049
+ if (!this.buttonIsBordered(node)) {
2050
+ this.place(label, cctx, p, x, y);
2051
+ return;
2052
+ }
2053
+ const inner: Proposal = {
2054
+ width: shrink(p.width, 2 * BORDERED_PAD_H),
2055
+ height: shrink(p.height, 2 * BORDERED_PAD_V),
2056
+ };
2057
+ const labelSize = this.measure(label, cctx, inner);
2058
+ const { dx, dy } = alignIn(size, labelSize, 'center');
2059
+ this.place(label, cctx, inner, x + dx, y + dy);
2060
+ // Keep the list row chrome (`row`, `badge`) a list set before placing.
2061
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2062
+ this.setChrome(node, {
2063
+ ...existing,
2064
+ label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
2065
+ });
2066
+ return;
2067
+ }
2068
+ case 'toggle': {
2069
+ const label = this.contentOf(node);
2070
+ const labelProposal = this.toggleLabelProposal(p);
2071
+ const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
2072
+ const labelY = y + (size.height - labelSize.height) / 2;
2073
+ if (label) this.place(label, cctx, labelProposal, x, labelY);
2074
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2075
+ this.setChrome(node, {
2076
+ ...existing,
2077
+ label: {
2078
+ x,
2079
+ y: labelY,
2080
+ width: Math.min(labelSize.width, Math.max(0, size.width - SWITCH_WIDTH - TOGGLE_GAP)),
2081
+ height: labelSize.height,
2082
+ },
2083
+ switch: {
2084
+ x: x + size.width - SWITCH_WIDTH,
2085
+ y: y + (size.height - SWITCH_HEIGHT) / 2,
2086
+ width: SWITCH_WIDTH,
2087
+ height: SWITCH_HEIGHT,
2088
+ },
2089
+ });
2090
+ return;
2091
+ }
2092
+ case 'navlink': {
2093
+ const label = this.contentOf(node);
2094
+ const labelProposal = this.navLinkLabelProposal(ctx, p);
2095
+ const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
2096
+ const labelY = y + (size.height - labelSize.height) / 2;
2097
+ if (label) this.place(label, cctx, labelProposal, x, labelY);
2098
+ if (ctx.listRow) {
2099
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2100
+ const chrome: Record<string, Rect> = {
2101
+ ...existing,
2102
+ label: { x, y: labelY, width: labelSize.width, height: labelSize.height },
2103
+ };
2104
+ // Phase 7: the selected row of a `navsplit` sidebar has no chevron
2105
+ // (its label still reserves the chevron's width so selecting a row
2106
+ // never reflows it); elsewhere `selected` keeps the chevron.
2107
+ if (!(node.props['selected'] === true && ctx.splitSidebar)) {
2108
+ chrome['chevron'] = {
2109
+ x: x + size.width - LIST_CHEVRON_WIDTH,
2110
+ y,
2111
+ width: LIST_CHEVRON_WIDTH,
2112
+ height: size.height,
2113
+ };
2114
+ }
2115
+ this.setChrome(node, chrome);
2116
+ }
2117
+ return;
2118
+ }
2119
+ case 'scrollview': {
2120
+ const plan = this.scrollPlan(node, ctx, p);
2121
+ const axes = this.scrollAxes(node);
2122
+ if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
2123
+ // Content sits at the scroll origin along the scroll axes and is
2124
+ // centered on the other axes when smaller than the viewport.
2125
+ const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2126
+ const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
2127
+ const content: Rect = {
2128
+ x: x + dx,
2129
+ y: y + dy,
2130
+ width: plan.contentSize.width,
2131
+ height: plan.contentSize.height,
2132
+ };
2133
+ const covered = this.coveredBottom(ctx, x, y, size);
2134
+ this.setChrome(node, covered ? { content, bottomInset: covered } : { content });
2135
+ if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2136
+ return;
2137
+ }
2138
+ case 'list':
2139
+ this.placeList(node, ctx, p, x, y);
2140
+ return;
2141
+ case 'navstack': {
2142
+ const screenProposal: Proposal = { width: size.width, height: size.height };
2143
+ // Each screen's back button is labelled with the previous screen's title.
2144
+ node.children.forEach((child, index) => {
2145
+ const previous = index > 0 ? node.children[index - 1] : undefined;
2146
+ this.previousTitles.set(child, previous ? (str(previous.props['title']) ?? '') : '');
2147
+ });
2148
+ for (const child of node.children) this.place(child, cctx, screenProposal, x, y);
2149
+ return;
2150
+ }
2151
+ case 'navscreen':
2152
+ this.placeNavScreen(node, cctx, x, y, size);
2153
+ return;
2154
+ case 'navsplit':
2155
+ this.placeNavSplit(node, cctx, x, y, size);
2156
+ return;
2157
+ case 'viewthatfits': {
2158
+ // Phase 7: only the chosen candidate is placed (at the origin, the
2159
+ // element is its size); the others have no frame and are hidden.
2160
+ const plan = this.viewThatFitsPlan(node, ctx, p);
2161
+ for (const child of node.children) {
2162
+ if (child === plan.chosen) this.place(child, cctx, p, x, y);
2163
+ else this.hide(child, node);
2164
+ }
2165
+ return;
2166
+ }
2167
+ case 'sheet':
2168
+ this.placeSheet(node, cctx, x, y, size);
2169
+ return;
2170
+ case 'tabview':
2171
+ this.placeTabView(node, cctx, x, y, size);
2172
+ return;
2173
+ case 'picker':
2174
+ this.placePicker(node, ctx, p, x, y, size);
2175
+ return;
2176
+ case 'geometry': {
2177
+ // The child is proposed the reader's size and placed top leading.
2178
+ const content = this.contentOf(node);
2179
+ if (content) this.place(content, cctx, { width: size.width, height: size.height }, x, y);
2180
+ return;
2181
+ }
2182
+ case 'layout':
2183
+ this.placeLayout(node, cctx, p, x, y, size);
2184
+ return;
2185
+ case 'grid':
2186
+ this.placeGrid(node, ctx, p, x, y);
2187
+ return;
2188
+ case 'lazyvgrid':
2189
+ this.placeLazyGrid(node, ctx, p, x, y);
2190
+ return;
2191
+ case 'gauge':
2192
+ this.placeGauge(node, ctx, p, x, y, size);
2193
+ return;
2194
+ case 'chart':
2195
+ this.placeChart(node, ctx, p, x, y);
2196
+ return;
2197
+ case 'chartitem': {
2198
+ // Outside a chart (or measured on its own): the view at the same origin.
2199
+ const content = node.children[0];
2200
+ if (content) this.place(content, cctx, p, x, y);
2201
+ return;
2202
+ }
2203
+ case 'decorated': {
2204
+ const [content, decoration] = node.children;
2205
+ if (content) this.place(content, cctx, p, x, y);
2206
+ if (decoration) {
2207
+ // The decoration is proposed the content's size and aligned inside it.
2208
+ const proposal: Proposal = { width: size.width, height: size.height };
2209
+ const decorationSize = this.measure(decoration, cctx, proposal);
2210
+ const alignment = str(node.props['alignment']) ?? 'center';
2211
+ const { dx, dy } = alignIn(size, decorationSize, alignment);
2212
+ this.place(decoration, cctx, proposal, x + dx, y + dy);
2213
+ }
2214
+ return;
2215
+ }
2216
+ case 'menu': {
2217
+ // Phase 6: the label is placed; the items are only shown in the
2218
+ // popover the renderer opens, so they are hidden and have no frames.
2219
+ const [label, ...items] = node.children;
2220
+ if (label) this.place(label, cctx, p, x, y);
2221
+ for (const item of items) this.hide(item, node);
2222
+ return;
2223
+ }
2224
+ case 'searchfield':
2225
+ return;
2226
+ // Phase 8 controls.
2227
+ case 'alert':
2228
+ this.placeAlert(node, ctx, x, y, size);
2229
+ return;
2230
+ case 'stepper':
2231
+ this.placeStepper(node, ctx, p, x, y, size);
2232
+ return;
2233
+ case 'slider':
2234
+ this.placeSlider(node, ctx, p, x, y, size);
2235
+ return;
2236
+ case 'datepicker':
2237
+ this.placeDatePicker(node, ctx, p, x, y, size);
2238
+ return;
2239
+ case 'texteditor':
2240
+ return;
2241
+ case 'swipeactions':
2242
+ case 'contextmenu': {
2243
+ // Phase 8: the content at the same origin; actions / items hidden, no frames.
2244
+ const [content, ...rest] = node.children;
2245
+ if (content) this.place(content, cctx, p, x, y);
2246
+ for (const item of rest) this.hide(item, node);
2247
+ return;
2248
+ }
2249
+ case 'gesture':
2250
+ case 'listrow':
2251
+ case 'tapgesture':
2252
+ default: {
2253
+ // `tapgesture`, `gesture`, `listrow` (child at the same origin), `toolbar` outside a navscreen,
2254
+ // `tab` outside a tabview, `section` outside a list and unknown kinds.
2255
+ const content = this.contentOf(node);
2256
+ if (content) this.place(content, cctx, p, x, y);
2257
+ return;
2258
+ }
2259
+ }
2260
+ }
2261
+
2262
+ /** The part of a scrolling view's frame that a tab bar covers (chrome `bottomInset`). */
2263
+ private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
2264
+ if (ctx.bottomBar <= 0) return null;
2265
+ const bottom = y + size.height;
2266
+ if (bottom < this.env.screen.height - EPSILON) return null;
2267
+ const height = Math.min(size.height, ctx.bottomBar);
2268
+ return { x, y: bottom - height, width: size.width, height };
2269
+ }
2270
+
2271
+ private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2272
+ const plan = this.listPlan(node, ctx, p);
2273
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2274
+ if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
2275
+ const covered = this.coveredBottom(ctx, x, y, plan.size);
2276
+ if (covered) this.setChrome(node, { bottomInset: covered });
2277
+ const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
2278
+ for (const group of plan.groups) {
2279
+ if (group.section) {
2280
+ const chrome: Record<string, Rect> = { rows: offset(group.card) };
2281
+ const texts: Record<string, TextLayout> = {};
2282
+ if (group.header) {
2283
+ chrome['header'] = offset(group.header.rect);
2284
+ texts['header'] = group.header.layout;
2285
+ }
2286
+ if (group.footer) {
2287
+ chrome['footer'] = offset(group.footer.rect);
2288
+ texts['footer'] = group.footer.layout;
2289
+ }
2290
+ this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
2291
+ this.setChrome(group.section, chrome);
2292
+ if (group.section.id >= 0) {
2293
+ this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
2294
+ if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
2295
+ }
2296
+ }
2297
+ for (const row of group.rows) {
2298
+ // Set the row chrome before placing so a navlink can merge its own keys.
2299
+ const chrome: Record<string, Rect> = { row: offset(row.row) };
2300
+ if (row.badge) {
2301
+ chrome['badge'] = offset(row.badge.rect);
2302
+ if (row.node.id >= 0) this.chromeText.set(row.node.id, { badge: row.badge.layout });
2303
+ }
2304
+ // Phase 8: the edit control belongs to the `listrow`, which is the
2305
+ // row itself or sits under its wrappers (its own place() keeps chrome).
2306
+ if (row.edit) {
2307
+ if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
2308
+ else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
2309
+ }
2310
+ this.setChrome(row.node, chrome);
2311
+ this.place(row.node, rowCtx, row.proposal, x + row.content.x, y + row.content.y);
2312
+ }
2313
+ }
2314
+ }
2315
+
2316
+ /**
2317
+ * The navigation bar is the status bar plus a 44 row (plus 52 for a large
2318
+ * title, plus 52 for a Phase 6 `searchfield`); a `bottom` toolbar is a 49
2319
+ * bar plus the bottom safe area; the content is proposed the rest. Toolbar
2320
+ * items are measured at their ideal size and laid out as a 16-spaced hstack
2321
+ * in the 44 row (`leading` after the back button, `trailing` 16 from the
2322
+ * trailing edge, `principal` centered in place of the title) or spread
2323
+ * evenly across the bottom bar. The inline title (`chrome.title`) is
2324
+ * centered and truncated to the room the bar items leave.
2325
+ */
2326
+ private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2327
+ const { safeArea, screen, dynamicTypeSize } = this.env;
2328
+ const statusBar = y <= 0 ? safeArea.top : 0;
2329
+ const displayMode = str(node.props['displayMode']) ?? 'automatic';
2330
+ const depth = num(node.props['depth']) ?? 0;
2331
+ const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2332
+ const barHeight = statusBar + NAV_BAR_HEIGHT;
2333
+ const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2334
+ const parts = this.navScreenParts(node);
2335
+ const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
2336
+ const reachesBottom = y + size.height >= screen.height - EPSILON;
2337
+ const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
2338
+ const bottomBarHeight =
2339
+ bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
2340
+ const contentTop = barHeight + largeHeight + searchHeight;
2341
+ const content: Rect = {
2342
+ x,
2343
+ y: y + contentTop,
2344
+ width: size.width,
2345
+ height: Math.max(0, size.height - contentTop - bottomBarHeight),
2346
+ };
2347
+ const chrome: Record<string, Rect> = {
2348
+ bar: { x, y, width: size.width, height: barHeight },
2349
+ content,
2350
+ };
2351
+ if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
2352
+ if (parts.search) {
2353
+ chrome['searchField'] = {
2354
+ x: x + SEARCH_FIELD_MARGIN_H,
2355
+ y: y + barHeight + largeHeight + SEARCH_FIELD_PAD_V,
2356
+ width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
2357
+ height: SEARCH_FIELD_HEIGHT,
2358
+ };
2359
+ }
2360
+ if (bottomBarHeight > 0) {
2361
+ chrome['bottomBar'] = { x, y: y + size.height - bottomBarHeight, width: size.width, height: bottomBarHeight };
2362
+ }
2363
+ const title = str(node.props['title']) ?? '';
2364
+ const titleFont = makeFont('system', textStyleSize('headline', dynamicTypeSize), 600);
2365
+ const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize), 700);
2366
+ const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize), 400);
2367
+
2368
+ // Toolbar items at their ideal size, grouped per toolbar.
2369
+ const rowY = y + statusBar;
2370
+ interface Group {
2371
+ toolbar: LayoutNode;
2372
+ placement: string;
2373
+ sizes: Size[];
2374
+ total: number;
2375
+ }
2376
+ const groups: Group[] = parts.toolbars.map((toolbar) => {
2377
+ const itemCtx = this.childContext(toolbar, cctx);
2378
+ const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
2379
+ const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
2380
+ return { toolbar, placement: str(toolbar.props['placement']) ?? 'trailing', sizes, total };
2381
+ });
2382
+ const groupWidth = (placement: string): number =>
2383
+ groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_ITEM_SPACING, 0);
2384
+ const leadingWidth = groupWidth('leading');
2385
+ const trailingWidth = groupWidth('trailing');
2386
+ const principal = groups.some((g) => g.placement === 'principal');
2387
+
2388
+ // Back button, like iOS: the previous title when it fits beside the
2389
+ // centered inline title, otherwise "Back", otherwise the chevron alone.
2390
+ let backLabel = '';
2391
+ if (depth > 0) {
2392
+ const inlineTitleWidth = large || principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
2393
+ const available =
2394
+ (large ? size.width - BACK_CHEVRON_WIDTH - 16 - 44 : (size.width - inlineTitleWidth) / 2 - 8 - 12) - leadingWidth;
2395
+ const candidates = [this.previousTitles.get(node) ?? '', 'Back', ''];
2396
+ let width = BACK_CHEVRON_WIDTH + 8;
2397
+ for (const candidate of candidates) {
2398
+ const labelWidth = candidate ? this.measurer.measureWidth(candidate, backFont) : 0;
2399
+ const needed = 8 + BACK_CHEVRON_WIDTH + labelWidth + 8;
2400
+ if (candidate === '' || needed <= available) {
2401
+ backLabel = candidate;
2402
+ width = needed;
2403
+ break;
2404
+ }
2405
+ }
2406
+ chrome['back'] = { x: x + 8, y: rowY, width, height: NAV_BAR_HEIGHT };
2407
+ }
2408
+
2409
+ // Place the toolbar groups and remember how much of the row they occupy.
2410
+ const back = chrome['back'];
2411
+ let leadingCursor = back ? back.x + back.width + 8 : x + NAV_ITEM_MARGIN;
2412
+ let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2413
+ const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2414
+ const itemCtx = this.childContext(group.toolbar, cctx);
2415
+ let cursor = startX;
2416
+ group.toolbar.children.forEach((item, i) => {
2417
+ const itemSize = group.sizes[i] as Size;
2418
+ this.place(item, itemCtx, { width: null, height: null }, cursor, top + (rowHeight - itemSize.height) / 2);
2419
+ cursor += itemSize.width + spacing;
2420
+ });
2421
+ };
2422
+ for (const group of groups) {
2423
+ switch (group.placement) {
2424
+ case 'leading': {
2425
+ this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2426
+ placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2427
+ leadingCursor += group.total + NAV_ITEM_SPACING;
2428
+ break;
2429
+ }
2430
+ case 'principal': {
2431
+ const startX = x + (size.width - group.total) / 2;
2432
+ this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2433
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2434
+ break;
2435
+ }
2436
+ case 'bottom': {
2437
+ const bar = chrome['bottomBar'] as Rect;
2438
+ this.setFrame(group.toolbar, bar.x, bar.y, bar);
2439
+ const n = group.sizes.length;
2440
+ const inner = Math.max(0, size.width - 2 * NAV_ITEM_MARGIN);
2441
+ const itemsWidth = group.total - Math.max(0, n - 1) * NAV_ITEM_SPACING;
2442
+ if (n === 1) placeGroup(group, x + (size.width - itemsWidth) / 2, bar.y, BOTTOM_BAR_HEIGHT, 0);
2443
+ else if (n > 1) {
2444
+ const gap = Math.max(NAV_ITEM_SPACING, (inner - itemsWidth) / (n - 1));
2445
+ placeGroup(group, x + NAV_ITEM_MARGIN, bar.y, BOTTOM_BAR_HEIGHT, gap);
2446
+ }
2447
+ break;
2448
+ }
2449
+ default: {
2450
+ const startX = trailingEdge - group.total;
2451
+ this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2452
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2453
+ trailingEdge = startX - NAV_ITEM_SPACING;
2454
+ break;
2455
+ }
2456
+ }
2457
+ }
2458
+ if (parts.search) {
2459
+ const field = chrome['searchField'] as Rect;
2460
+ this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
2461
+ }
2462
+
2463
+ // The inline title: centered, no wider than 56% of the bar, and clear of
2464
+ // whatever the back button and the bar items occupy on either side.
2465
+ if (!large && !principal) {
2466
+ const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_ITEM_SPACING - x : 0);
2467
+ const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_ITEM_SPACING) : 0;
2468
+ const side = Math.max(leftUsed, rightUsed) + 8;
2469
+ const available = Math.max(0, size.width - 2 * side);
2470
+ const titleWidth = Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56, available);
2471
+ chrome['title'] = { x: x + (size.width - titleWidth) / 2, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
2472
+ }
2473
+ this.setChrome(node, chrome);
2474
+
2475
+ if (node.id >= 0) {
2476
+ this.fonts.set(node.id, large ? largeTitleFont : titleFont);
2477
+ const texts: Record<string, TextLayout> = {
2478
+ title: { font: titleFont, lines: [title], alignment: 'center', truncated: false },
2479
+ };
2480
+ if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
2481
+ if (large) {
2482
+ const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
2483
+ texts['largeTitle'] = {
2484
+ font: largeTitleFont,
2485
+ lines: wrapped.lines,
2486
+ alignment: 'leading',
2487
+ truncated: wrapped.truncated,
2488
+ };
2489
+ }
2490
+ this.chromeText.set(node.id, texts);
2491
+ }
2492
+
2493
+ const child = parts.content;
2494
+ if (!child) return;
2495
+ // Under a tab bar the bar (which includes the bottom safe area) is what
2496
+ // non-scrolling content must clear; a bottom toolbar already excluded it.
2497
+ const bottomInset =
2498
+ bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2499
+ // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2500
+ // large title / search field), which is transparent over it.
2501
+ const contentCtx: Context = { ...cctx, safeTop: content.y };
2502
+ if (unwrapStyled(child).kind === 'list' || unwrapStyled(child).kind === 'scrollview') {
2503
+ this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2504
+ return;
2505
+ }
2506
+ const area: Size = { width: content.width, height: Math.max(0, content.height - bottomInset) };
2507
+ const proposal: Proposal = { width: area.width, height: area.height };
2508
+ const childSize = this.measure(child, contentCtx, proposal);
2509
+ const { dx, dy } = alignIn(area, childSize, 'center');
2510
+ this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
2511
+ }
2512
+
2513
+ /**
2514
+ * The tab bar is 49 plus the bottom safe area (when the tab view reaches
2515
+ * the screen bottom), with one equal-width item per tab. Every `tab` gets
2516
+ * the tab view's frame; only the selected one lays its content out, the
2517
+ * others are reported in `hidden`.
2518
+ */
2519
+ private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2520
+ const { screen, safeArea } = this.env;
2521
+ const bottomSafe = y + size.height >= screen.height - EPSILON ? safeArea.bottom : 0;
2522
+ const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + bottomSafe);
2523
+ const bar: Rect = { x, y: y + size.height - barHeight, width: size.width, height: barHeight };
2524
+ const chrome: Record<string, Rect> = {
2525
+ bar,
2526
+ content: { x, y, width: size.width, height: size.height - barHeight },
2527
+ };
2528
+ const tabs = node.children;
2529
+ const n = tabs.length;
2530
+ for (let i = 0; i < n; i++) {
2531
+ const itemWidth = size.width / n;
2532
+ chrome[`item${i}`] = { x: x + i * itemWidth, y: bar.y, width: itemWidth, height: Math.min(barHeight, TAB_BAR_HEIGHT) };
2533
+ }
2534
+ this.setChrome(node, chrome);
2535
+ const selected = readSelected(node, n);
2536
+ const tabCtx: Context = { ...cctx, bottomBar: barHeight };
2537
+ tabs.forEach((tab, i) => {
2538
+ this.setFrame(tab, x, y, size);
2539
+ if (i !== selected) {
2540
+ this.hide(tab, node);
2541
+ return;
2542
+ }
2543
+ this.placeTabContent(tab, this.childContext(tab, tabCtx), x, y, size, barHeight);
2544
+ });
2545
+ }
2546
+
2547
+ /**
2548
+ * The selected tab's content, like a navscreen's: a `navstack` fills the
2549
+ * whole tab (it reserves the status bar itself and its lists run under the
2550
+ * bar); a `list` or `scrollview` starts below the status bar and also runs
2551
+ * to the bottom, inset by the bar (chrome `bottomInset`); anything else is
2552
+ * centered between the status bar and the tab bar.
2553
+ */
2554
+ private placeTabContent(tab: LayoutNode, cctx: Context, x: number, y: number, size: Size, barHeight: number): void {
2555
+ const { safeArea } = this.env;
2556
+ const topInset = y <= 0 ? safeArea.top : 0;
2557
+ const area: Rect = {
2558
+ x,
2559
+ y: y + topInset,
2560
+ width: size.width,
2561
+ height: Math.max(0, size.height - topInset - barHeight),
2562
+ };
2563
+ this.setChrome(tab, { content: area });
2564
+ const child = this.contentOf(tab);
2565
+ if (!child) return;
2566
+ const kind = unwrapStyled(child).kind;
2567
+ if (kind === 'navstack') {
2568
+ this.place(child, cctx, { width: size.width, height: size.height }, x, y);
2569
+ return;
2570
+ }
2571
+ if (kind === 'list' || kind === 'scrollview') {
2572
+ const height = Math.max(0, size.height - topInset);
2573
+ this.place(child, cctx, { width: size.width, height }, x, y + topInset);
2574
+ return;
2575
+ }
2576
+ const proposal: Proposal = { width: area.width, height: area.height };
2577
+ const childSize = this.measure(child, cctx, proposal);
2578
+ const { dx, dy } = alignIn(area, childSize, 'center');
2579
+ this.place(child, cctx, proposal, area.x + dx, area.y + dy);
2580
+ }
2581
+
2582
+ /**
2583
+ * Phase 7 `NavigationSplitView` (regular size class): the children are
2584
+ * `[sidebar, detail]`, each a `navstack` that fills its column. The sidebar
2585
+ * column is 320 wide (at most 40 % of the width left by the horizontal
2586
+ * safe-area insets), then a 1px separator, then the detail column. Both
2587
+ * columns run the full height, so each navstack's screens reserve the
2588
+ * status bar and the bottom safe area themselves exactly as a root
2589
+ * navstack does (`placeNavScreen` keys on `y <= 0` and the screen bottom).
2590
+ * The leading inset belongs to the sidebar column and the trailing inset
2591
+ * to the detail column (when the split view touches those screen edges):
2592
+ * the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
2593
+ * navstacks start / end at them.
2594
+ */
2595
+ private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2596
+ const { screen, safeArea } = this.env;
2597
+ const leading = x <= 0 ? safeArea.leading : 0;
2598
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
2599
+ const usable = Math.max(0, size.width - leading - trailing);
2600
+ const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
2601
+ const separatorX = x + leading + sidebarWidth;
2602
+ const detailX = separatorX + NAVSPLIT_SEPARATOR_WIDTH;
2603
+ const detailWidth = Math.max(0, x + size.width - trailing - detailX);
2604
+ const chrome: Record<string, Rect> = {
2605
+ sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
2606
+ separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
2607
+ detail: { x: detailX, y, width: Math.max(0, x + size.width - detailX), height: size.height },
2608
+ };
2609
+ this.setChrome(node, chrome);
2610
+ const [sidebar, detail, ...rest] = node.children;
2611
+ if (sidebar) {
2612
+ const proposal: Proposal = { width: sidebarWidth, height: size.height };
2613
+ this.place(sidebar, { ...cctx, splitSidebar: true }, proposal, x + leading, y);
2614
+ }
2615
+ if (detail) {
2616
+ const proposal: Proposal = { width: detailWidth, height: size.height };
2617
+ this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
2618
+ }
2619
+ for (const extra of rest) this.hide(extra, node);
2620
+ }
2621
+
2622
+ /**
2623
+ * Phase 7 `ViewThatFits`: the first child whose ideal size fits the
2624
+ * proposal on the restricted axes (a nil or infinite proposal axis always
2625
+ * fits), else the last child; the element is that child's size for the
2626
+ * proposal. Memoized per proposal like every other plan, so the choice is
2627
+ * stable within a pass.
2628
+ */
2629
+ private viewThatFitsPlan(node: LayoutNode, ctx: Context, p: Proposal): { chosen: LayoutNode | null; size: Size } {
2630
+ return this.plan(node, p, () => {
2631
+ const axes = str(node.props['axes']) ?? 'both';
2632
+ const checkWidth = axes !== 'vertical';
2633
+ const checkHeight = axes !== 'horizontal';
2634
+ const cctx = this.childContext(node, ctx);
2635
+ const fitsAxis = (proposed: number | null, ideal: number): boolean =>
2636
+ !finite(proposed) || ideal <= proposed + EPSILON;
2637
+ let chosen: LayoutNode | null = null;
2638
+ for (const child of node.children) {
2639
+ const ideal = this.measure(child, cctx, NIL_PROPOSAL);
2640
+ if ((!checkWidth || fitsAxis(p.width, ideal.width)) && (!checkHeight || fitsAxis(p.height, ideal.height))) {
2641
+ chosen = child;
2642
+ break;
2643
+ }
2644
+ }
2645
+ if (!chosen && node.children.length > 0) chosen = node.children[node.children.length - 1] as LayoutNode;
2646
+ return { chosen, size: chosen ? this.measure(chosen, cctx, p) : ZERO };
2647
+ });
2648
+ }
2649
+
2650
+ // -------------------------------------------------------------------------
2651
+ // Phase 8: controls, alerts, safe-area bleed
2652
+ // -------------------------------------------------------------------------
2653
+
2654
+ /**
2655
+ * `.ignoresSafeArea(edges:)`: how far a `styled` box at (x, y, size) grows
2656
+ * on each listed side to reach the screen edge. A side grows only when the
2657
+ * box touches (or is inside) that safe-area inset band; null when nothing grows.
2658
+ */
2659
+ private safeAreaExtension(node: LayoutNode, ctx: Context, x: number, y: number, size: Size): EdgeInsets | null {
2660
+ const raw = styleOf(node)['ignoresSafeArea'];
2661
+ if (raw === undefined || raw === null) return null;
2662
+ const edges: string[] = raw === 'all' ? [...SAFE_AREA_EDGES] : Array.isArray(raw) ? raw.map(str).filter((e): e is string => e !== null) : [];
2663
+ if (edges.length === 0) return null;
2664
+ const { screen, safeArea } = this.env;
2665
+ // Inside a `navscreen` the bar is what the content clears (`ctx.safeTop`);
2666
+ // under a tab bar, the bar (`ctx.bottomBar`, which includes the bottom
2667
+ // safe area).
2668
+ const top = ctx.safeTop;
2669
+ const bottom = Math.max(safeArea.bottom, ctx.bottomBar);
2670
+ const ext: EdgeInsets = { top: 0, leading: 0, bottom: 0, trailing: 0 };
2671
+ if (edges.includes('top') && top > 0 && y <= top + EPSILON) ext.top = Math.max(0, y);
2672
+ if (edges.includes('leading') && safeArea.leading > 0 && x <= safeArea.leading + EPSILON) ext.leading = Math.max(0, x);
2673
+ if (edges.includes('bottom') && bottom > 0 && y + size.height >= screen.height - bottom - EPSILON) {
2674
+ ext.bottom = Math.max(0, screen.height - (y + size.height));
2675
+ }
2676
+ if (edges.includes('trailing') && safeArea.trailing > 0 && x + size.width >= screen.width - safeArea.trailing - EPSILON) {
2677
+ ext.trailing = Math.max(0, screen.width - (x + size.width));
2678
+ }
2679
+ return ext.top || ext.leading || ext.bottom || ext.trailing ? ext : null;
2680
+ }
2681
+
2682
+ private stepperLabelProposal(p: Proposal): Proposal {
2683
+ return { width: shrink(p.width, STEPPER_WIDTH + STEPPER_GAP), height: p.height };
2684
+ }
2685
+
2686
+ /** Like a toggle: the label leads, the 94 × 32 control trails; at least 32 high. */
2687
+ private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
2688
+ const label = this.contentOf(node);
2689
+ const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
2690
+ return {
2691
+ width: finite(p.width) ? p.width : labelSize.width + STEPPER_GAP + STEPPER_WIDTH,
2692
+ height: Math.max(labelSize.height, STEPPER_HEIGHT),
2693
+ };
2694
+ }
2695
+
2696
+ private placeStepper(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
2697
+ const cctx = this.childContext(node, ctx);
2698
+ const label = this.contentOf(node);
2699
+ const labelProposal = this.stepperLabelProposal(p);
2700
+ const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
2701
+ const labelY = y + (size.height - labelSize.height) / 2;
2702
+ if (label) this.place(label, cctx, labelProposal, x, labelY);
2703
+ const controlX = x + size.width - STEPPER_WIDTH;
2704
+ const controlY = y + (size.height - STEPPER_HEIGHT) / 2;
2705
+ const half = STEPPER_WIDTH / 2;
2706
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2707
+ this.setChrome(node, {
2708
+ ...existing,
2709
+ label: {
2710
+ x,
2711
+ y: labelY,
2712
+ width: Math.min(labelSize.width, Math.max(0, size.width - STEPPER_WIDTH - STEPPER_GAP)),
2713
+ height: labelSize.height,
2714
+ },
2715
+ control: { x: controlX, y: controlY, width: STEPPER_WIDTH, height: STEPPER_HEIGHT },
2716
+ decrement: { x: controlX, y: controlY, width: half, height: STEPPER_HEIGHT },
2717
+ increment: { x: controlX + half, y: controlY, width: half, height: STEPPER_HEIGHT },
2718
+ });
2719
+ }
2720
+
2721
+ /**
2722
+ * A slider is the proposed width (200 when none) and 44 high, plus a label
2723
+ * row above when a `label` child exists (its own height, leading). The
2724
+ * `min` / `max` value labels sit at the track's ends in the 44 row, 8 from
2725
+ * the track. Children are named by `labels` in order.
2726
+ */
2727
+ private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
2728
+ return this.plan(node, p, () => {
2729
+ const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
2730
+ const cctx = this.childContext(node, ctx);
2731
+ const names = Array.isArray(node.props['labels']) ? node.props['labels'].map(str) : [];
2732
+ let label: LayoutNode | null = null;
2733
+ let min: LayoutNode | null = null;
2734
+ let max: LayoutNode | null = null;
2735
+ names.forEach((name, i) => {
2736
+ const child = node.children[i] ?? null;
2737
+ if (name === 'label') label = child;
2738
+ else if (name === 'min') min = child;
2739
+ else if (name === 'max') max = child;
2740
+ });
2741
+ const labelSize = label ? this.measure(label, cctx, { width, height: null }) : ZERO;
2742
+ const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
2743
+ const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
2744
+ const labelRow = label ? labelSize.height : 0;
2745
+ return { size: { width, height: labelRow + SLIDER_HEIGHT }, label, min, max, labelSize, minSize, maxSize, labelRow, cctx };
2746
+ });
2747
+ }
2748
+
2749
+ private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
2750
+ const plan = this.sliderPlan(node, ctx, p);
2751
+ const { cctx } = plan;
2752
+ if (plan.label) this.place(plan.label, cctx, { width: size.width, height: null }, x, y);
2753
+ const rowY = y + plan.labelRow;
2754
+ let trackX = x;
2755
+ let trackEnd = x + size.width;
2756
+ if (plan.min) {
2757
+ this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (SLIDER_HEIGHT - plan.minSize.height) / 2);
2758
+ trackX += plan.minSize.width + SLIDER_LABEL_GAP;
2759
+ }
2760
+ if (plan.max) {
2761
+ this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (SLIDER_HEIGHT - plan.maxSize.height) / 2);
2762
+ trackEnd -= plan.maxSize.width + SLIDER_LABEL_GAP;
2763
+ }
2764
+ const trackWidth = Math.max(0, trackEnd - trackX);
2765
+ const trackY = rowY + (SLIDER_HEIGHT - SLIDER_TRACK_HEIGHT) / 2;
2766
+ const fraction = sliderFraction(num(node.props['value']) ?? 0, num(node.props['min']) ?? 0, num(node.props['max']) ?? 1);
2767
+ // The thumb travels inside the track: its center runs from half a thumb
2768
+ // past the track's start to half a thumb before its end.
2769
+ const travel = Math.max(0, trackWidth - SLIDER_THUMB);
2770
+ const thumbCenter = trackX + SLIDER_THUMB / 2 + fraction * travel;
2771
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2772
+ this.setChrome(node, {
2773
+ ...existing,
2774
+ track: { x: trackX, y: trackY, width: trackWidth, height: SLIDER_TRACK_HEIGHT },
2775
+ fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
2776
+ thumb: {
2777
+ x: thumbCenter - SLIDER_THUMB / 2,
2778
+ y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB) / 2,
2779
+ width: SLIDER_THUMB,
2780
+ height: SLIDER_THUMB,
2781
+ },
2782
+ });
2783
+ }
2784
+
2785
+ /**
2786
+ * Compact date picker: the label leads; the date and / or time capsule
2787
+ * (text in the body font plus 11 of padding each side, 34 high, 8 apart)
2788
+ * trail. The width is the proposal, or label + 8 + capsules.
2789
+ */
2790
+ private datePickerPlan(node: LayoutNode, ctx: Context, p: Proposal): DatePickerPlan {
2791
+ return this.plan(node, p, () => {
2792
+ const cctx = this.childContext(node, ctx);
2793
+ const seconds = num(node.props['seconds']) ?? 0;
2794
+ const components = str(node.props['components']) ?? 'dateAndTime';
2795
+ const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds);
2796
+ const timeText = components === 'date' ? null : formatPickerTime(seconds);
2797
+ const capsule = (text: string | null): number =>
2798
+ text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * DATEPICKER_CAPSULE_PAD_H;
2799
+ const dateWidth = capsule(dateText);
2800
+ const timeWidth = capsule(timeText);
2801
+ const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? DATEPICKER_CAPSULE_GAP : 0);
2802
+ const label = node.props['labelsHidden'] === true ? null : this.contentOf(node);
2803
+ const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
2804
+ const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
2805
+ const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
2806
+ return {
2807
+ size: { width, height: Math.max(labelSize.height, DATEPICKER_HEIGHT) },
2808
+ label,
2809
+ labelProposal,
2810
+ labelSize,
2811
+ dateWidth,
2812
+ timeWidth,
2813
+ capsulesWidth,
2814
+ cctx,
2815
+ };
2816
+ });
2817
+ }
2818
+
2819
+ private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
2820
+ const plan = this.datePickerPlan(node, ctx, p);
2821
+ if (!plan.label) {
2822
+ // `labelsHidden`: the label child exists but is not shown.
2823
+ for (const child of node.children) this.hide(child, node);
2824
+ }
2825
+ const labelY = y + (size.height - plan.labelSize.height) / 2;
2826
+ if (plan.label) this.place(plan.label, plan.cctx, plan.labelProposal, x, labelY);
2827
+ const capsuleY = y + (size.height - DATEPICKER_HEIGHT) / 2;
2828
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2829
+ const chrome: Record<string, Rect> = {
2830
+ ...existing,
2831
+ label: {
2832
+ x,
2833
+ y: labelY,
2834
+ width: Math.min(plan.labelSize.width, Math.max(0, size.width - plan.capsulesWidth - DATEPICKER_GAP)),
2835
+ height: plan.labelSize.height,
2836
+ },
2837
+ };
2838
+ let capsuleX = x + size.width - plan.capsulesWidth;
2839
+ if (plan.dateWidth > 0) {
2840
+ chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: DATEPICKER_HEIGHT };
2841
+ capsuleX += plan.dateWidth + DATEPICKER_CAPSULE_GAP;
2842
+ }
2843
+ if (plan.timeWidth > 0) {
2844
+ chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: DATEPICKER_HEIGHT };
2845
+ }
2846
+ this.setChrome(node, chrome);
2847
+ }
2848
+
2849
+ /**
2850
+ * `alert` style: a 270 wide card centered on the screen with the title
2851
+ * (headline), the message (footnote) and the buttons: two side by side,
2852
+ * otherwise stacked, 44 each; no buttons → an `ok` rect the renderer draws.
2853
+ * `dialog` style: an action sheet of 57 rows in a card 8 from the screen
2854
+ * edges, above a separate card for the `cancel` button, 8 above the bottom
2855
+ * safe area; the title and message are a footnote caption on top. Button
2856
+ * children get the row as their frame with the label centered in it.
2857
+ */
2858
+ private placeAlert(node: LayoutNode, ctx: Context, x: number, y: number, size: Size): void {
2859
+ const style = str(node.props['style']) === 'dialog' ? 'dialog' : 'alert';
2860
+ const title = str(node.props['title']) ?? '';
2861
+ const message = str(node.props['message']) ?? '';
2862
+ const cctx = this.childContext(node, ctx);
2863
+ const { safeArea } = this.env;
2864
+ const chrome: Record<string, Rect> = {};
2865
+ const texts: Record<string, TextLayout> = {};
2866
+ const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
2867
+
2868
+ const centered = (text: string, font: ResolvedFont, width: number): { layout: TextLayout; height: number } => {
2869
+ const wrapped = wrapText(text, font, width, null, this.measurer);
2870
+ return {
2871
+ layout: { font, lines: wrapped.lines, alignment: 'center', truncated: wrapped.truncated ?? false },
2872
+ height: wrapped.lines.length * font.lineHeight,
2873
+ };
2874
+ };
2875
+
2876
+ if (style === 'alert') {
2877
+ const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
2878
+ const textWidth = cardWidth - 2 * ALERT_PAD_H;
2879
+ const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
2880
+ const messageFont = resolveFont({ textStyle: 'footnote' }, ctx.font, this.env);
2881
+ // Heights first (the card is centered), then the rects.
2882
+ let h = ALERT_PAD_TOP;
2883
+ let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
2884
+ let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
2885
+ if (title.length > 0) {
2886
+ const block = centered(title, titleFont, textWidth);
2887
+ titleBlock = { ...block, top: h };
2888
+ h += block.height;
2889
+ }
2890
+ if (message.length > 0) {
2891
+ if (titleBlock) h += ALERT_TITLE_GAP;
2892
+ const block = centered(message, messageFont, textWidth);
2893
+ messageBlock = { ...block, top: h };
2894
+ h += block.height;
2895
+ }
2896
+ h += ALERT_PAD_BOTTOM;
2897
+ const headerHeight = h;
2898
+ const buttons = node.children;
2899
+ const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
2900
+ h += rows * ALERT_BUTTON_HEIGHT;
2901
+ const card: Rect = { x: x + (size.width - cardWidth) / 2, y: y + (size.height - h) / 2, width: cardWidth, height: h };
2902
+ chrome['card'] = card;
2903
+ if (titleBlock) {
2904
+ chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
2905
+ texts['title'] = titleBlock.layout;
2906
+ }
2907
+ if (messageBlock) {
2908
+ chrome['message'] = { x: card.x + ALERT_PAD_H, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
2909
+ texts['message'] = messageBlock.layout;
2910
+ }
2911
+ const buttonsTop = card.y + headerHeight;
2912
+ if (buttons.length === 0) {
2913
+ chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
2914
+ } else if (buttons.length === 2) {
2915
+ const half = cardWidth / 2;
2916
+ buttons.forEach((button, i) => {
2917
+ const rect: Rect = { x: card.x + i * half, y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
2918
+ chrome[`button${i}`] = rect;
2919
+ this.placeAlertButton(button, cctx, rect);
2920
+ });
2921
+ } else {
2922
+ buttons.forEach((button, i) => {
2923
+ const rect: Rect = { x: card.x, y: buttonsTop + i * ALERT_BUTTON_HEIGHT, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
2924
+ chrome[`button${i}`] = rect;
2925
+ this.placeAlertButton(button, cctx, rect);
2926
+ });
2927
+ }
2928
+ } else {
2929
+ const cardWidth = Math.max(0, size.width - 2 * DIALOG_MARGIN);
2930
+ const cardX = x + DIALOG_MARGIN;
2931
+ const textWidth = Math.max(0, cardWidth - 2 * DIALOG_HEADER_PAD_H);
2932
+ const titleFont = resolveFont({ textStyle: 'footnote', weight: 'semibold' }, ctx.font, this.env);
2933
+ const messageFont = resolveFont({ textStyle: 'footnote' }, ctx.font, this.env);
2934
+ const buttonCtx: Context = { ...cctx, font: resolveFont({ size: DIALOG_BUTTON_FONT_SIZE }, ctx.font, this.env) };
2935
+ let h = 0;
2936
+ let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
2937
+ let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
2938
+ if (title.length > 0 || message.length > 0) {
2939
+ h = DIALOG_HEADER_PAD_V;
2940
+ if (title.length > 0) {
2941
+ const block = centered(title, titleFont, textWidth);
2942
+ titleBlock = { ...block, top: h };
2943
+ h += block.height;
2944
+ }
2945
+ if (message.length > 0) {
2946
+ if (titleBlock) h += ALERT_TITLE_GAP;
2947
+ const block = centered(message, messageFont, textWidth);
2948
+ messageBlock = { ...block, top: h };
2949
+ h += block.height;
2950
+ }
2951
+ h += DIALOG_HEADER_PAD_V;
2952
+ }
2953
+ const headerHeight = h;
2954
+ const cancel = node.children.find((child) => roleOf(child) === 'cancel') ?? null;
2955
+ const others = node.children.filter((child) => child !== cancel);
2956
+ const rows = others.length === 0 && !cancel ? 1 : others.length;
2957
+ h += rows * DIALOG_BUTTON_HEIGHT;
2958
+ const bottom = y + size.height - safeArea.bottom - DIALOG_MARGIN;
2959
+ let cardBottom = bottom;
2960
+ if (cancel) {
2961
+ const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
2962
+ chrome['cancelCard'] = cancelCard;
2963
+ chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
2964
+ this.placeAlertButton(cancel, buttonCtx, cancelCard);
2965
+ cardBottom = cancelCard.y - DIALOG_CARD_GAP;
2966
+ }
2967
+ const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
2968
+ chrome['card'] = card;
2969
+ if (titleBlock) {
2970
+ chrome['title'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
2971
+ texts['title'] = titleBlock.layout;
2972
+ }
2973
+ if (messageBlock) {
2974
+ chrome['message'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
2975
+ texts['message'] = messageBlock.layout;
2976
+ }
2977
+ const buttonsTop = card.y + headerHeight;
2978
+ if (others.length === 0 && !cancel) {
2979
+ chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
2980
+ }
2981
+ others.forEach((button, i) => {
2982
+ const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
2983
+ chrome[`button${node.children.indexOf(button)}`] = rect;
2984
+ this.placeAlertButton(button, buttonCtx, rect);
2985
+ });
2986
+ }
2987
+ this.setChrome(node, chrome);
2988
+ if (node.id >= 0 && Object.keys(texts).length > 0) this.chromeText.set(node.id, texts);
2989
+ }
2990
+
2991
+ /** An alert button fills its row; its label is centered in it (any other child is centered as is). */
2992
+ private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect): void {
2993
+ const proposal: Proposal = { width: Math.max(0, rect.width - 2 * ALERT_PAD_H), height: rect.height };
2994
+ // A `cancel` button is bold: measure (and so draw) its label semibold.
2995
+ const cancel = str(unwrapStyled(child).props['role']) === 'cancel';
2996
+ const inherited = this.childContext(child, ctx);
2997
+ const cctx: Context = {
2998
+ ...inherited,
2999
+ textAlignment: 'center',
3000
+ font: cancel ? resolveFont({ weight: 'semibold' }, inherited.font, this.env) : inherited.font,
3001
+ };
3002
+ if (child.kind !== 'button') {
3003
+ const childSize = this.measure(child, ctx, proposal);
3004
+ const { dx, dy } = alignIn(rect, childSize, 'center');
3005
+ this.place(child, ctx, proposal, rect.x + dx, rect.y + dy);
3006
+ return;
3007
+ }
3008
+ const label = this.contentOf(child);
3009
+ const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
3010
+ this.setFrame(child, rect.x, rect.y, { width: rect.width, height: rect.height });
3011
+ if (label) {
3012
+ const { dx, dy } = alignIn(rect, labelSize, 'center');
3013
+ this.place(label, cctx, proposal, rect.x + dx, rect.y + dy);
3014
+ }
3015
+ const existing = child.id >= 0 ? this.chrome.get(child.id) : undefined;
3016
+ this.setChrome(child, { ...existing, label: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
3017
+ }
3018
+
3019
+ private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3020
+ // Phase 8: a `full` sheet (`.fullScreenCover`) has no grabber row.
3021
+ const full = this.sheetIsFull(node);
3022
+ const grabberRow = full ? 0 : SHEET_GRABBER_ROW;
3023
+ const content: Rect = {
3024
+ x,
3025
+ y: y + grabberRow,
3026
+ width: size.width,
3027
+ height: Math.max(0, size.height - grabberRow),
3028
+ };
3029
+ const chrome: Record<string, Rect> = { content };
3030
+ if (!full) {
3031
+ chrome['grabber'] = {
3032
+ x: x + (size.width - SHEET_GRABBER.width) / 2,
3033
+ y: y + SHEET_GRABBER_TOP,
3034
+ width: SHEET_GRABBER.width,
3035
+ height: SHEET_GRABBER.height,
3036
+ };
3037
+ }
3038
+ this.setChrome(node, chrome);
3039
+ const child = this.contentOf(node);
3040
+ if (!child) return;
3041
+ const proposal: Proposal = { width: content.width, height: content.height };
3042
+ const kind = unwrapStyled(child).kind;
3043
+ if (kind === 'list' || kind === 'scrollview' || kind === 'navstack') {
3044
+ this.place(child, cctx, proposal, content.x, content.y);
3045
+ return;
3046
+ }
3047
+ const childSize = this.measure(child, cctx, proposal);
3048
+ const { dx, dy } = alignIn(content, childSize, 'center');
3049
+ this.place(child, cctx, proposal, content.x + dx, content.y + dy);
3050
+ }
3051
+
3052
+ // -------------------------------------------------------------------------
3053
+ // Custom `Layout` (Phase 6)
3054
+ // -------------------------------------------------------------------------
3055
+
3056
+ /**
3057
+ * Swift's answer (`size`) when it has one, for any proposal; before the first
3058
+ * answer the union of the children's ideal sizes, like a zstack measured
3059
+ * with a nil proposal, so the first frame is sane.
3060
+ */
3061
+ private measureLayout(node: LayoutNode, ctx: Context, p: Proposal): Size {
3062
+ const size = readSize(node.props['size']);
3063
+ // Swift's answer holds for the proposal it was computed for. Before the
3064
+ // first answer be flexible: fill what is proposed so the parent keeps
3065
+ // proposing real sizes (a rigid fallback would trap a nested layout that
3066
+ // was first proposed a tiny size at that size).
3067
+ if (size) {
3068
+ if (sameProposal(p, readProposal(node.props['proposal']))) return size;
3069
+ // Another proposal. Swift sends the layout's own min / ideal / max with
3070
+ // its answer, so the probes parents use to learn flexibility (0, nil,
3071
+ // Infinity) are answered from those, and any other proposal is clamped
3072
+ // to min / max (what `LayoutSubview.sizeThatFits` does on the Swift
3073
+ // side) until Swift answers it in a later pass. Guessing from the
3074
+ // answered size made an enclosing custom layout see this one as rigid
3075
+ // at a stale size, and the two then traded answers forever.
3076
+ const sizes = readLayoutSizes(node.props['sizes']);
3077
+ const axis = (proposed: number | null, answered: number, dim: 'width' | 'height'): number => {
3078
+ if (sizes) {
3079
+ if (proposed === null) return sizes.ideal[dim];
3080
+ const lower = Math.min(sizes.min[dim], sizes.max[dim]);
3081
+ return Math.max(lower, Math.min(proposed, sizes.max[dim]));
3082
+ }
3083
+ return proposed === 0 ? 0 : proposed === Infinity ? Infinity : answered;
3084
+ };
3085
+ return { width: axis(p.width, size.width, 'width'), height: axis(p.height, size.height, 'height') };
3086
+ }
3087
+ const cctx = this.childContext(node, ctx);
3088
+ let width = 0;
3089
+ let height = 0;
3090
+ for (const child of node.children) {
3091
+ const ideal = this.measure(child, cctx, NIL_PROPOSAL);
3092
+ width = Math.max(width, ideal.width);
3093
+ height = Math.max(height, ideal.height);
3094
+ }
3095
+ const fill = (proposed: number | null, ideal: number): number =>
3096
+ proposed === null ? ideal : proposed; // Infinity stays Infinity: flexible
3097
+ return { width: fill(p.width, width), height: fill(p.height, height) };
3098
+ }
3099
+
3100
+ /**
3101
+ * With one frame per child (relative to the element's origin) every child
3102
+ * is proposed its frame's size and gets exactly that frame; otherwise the
3103
+ * children are centered like a zstack. Either way the record Swift needs
3104
+ * (the proposal placed with, the children's min / ideal / max) is kept.
3105
+ */
3106
+ private placeLayout(node: LayoutNode, cctx: Context, p: Proposal, x: number, y: number, size: Size): void {
3107
+ const children = node.children;
3108
+ const measured: LayoutChildSizes[] = children.map((child) => ({
3109
+ min: this.measure(child, cctx, MIN_PROPOSAL),
3110
+ ideal: this.measure(child, cctx, NIL_PROPOSAL),
3111
+ max: this.measure(child, cctx, MAX_PROPOSAL),
3112
+ }));
3113
+ if (node.id >= 0) {
3114
+ this.layouts.set(node.id, { proposal: { width: p.width, height: p.height }, children: measured });
3115
+ }
3116
+
3117
+ const raw = node.props['frames'];
3118
+ const frames = Array.isArray(raw) ? raw.map(readRect) : null;
3119
+ if (frames && frames.length === children.length && frames.every((f) => f !== null)) {
3120
+ children.forEach((child, i) => {
3121
+ const f = frames[i] as Rect;
3122
+ this.place(child, cctx, { width: f.width, height: f.height }, x + f.x, y + f.y);
3123
+ this.setFrame(child, x + f.x, y + f.y, f);
3124
+ });
3125
+ return;
3126
+ }
3127
+ const proposal: Proposal = { width: size.width, height: size.height };
3128
+ for (const child of children) {
3129
+ const childSize = this.measure(child, cctx, proposal);
3130
+ const { dx, dy } = alignIn(size, childSize, 'center');
3131
+ this.place(child, cctx, proposal, x + dx, y + dy);
3132
+ }
3133
+ }
3134
+
3135
+ // -------------------------------------------------------------------------
3136
+ // Grid (Phase 6)
3137
+ // -------------------------------------------------------------------------
3138
+
3139
+ /** Context the cells of a grid row (or a full-span child) are measured with. */
3140
+ private gridCellContext(grid: LayoutNode, row: LayoutNode | null, ctx: Context): Context {
3141
+ const cctx = this.childContext(grid, ctx);
3142
+ return row ? this.childContext(row, cctx) : cctx;
3143
+ }
3144
+
3145
+ /**
3146
+ * SwiftUI's `Grid`: the column count is the widest row (in spans). A column
3147
+ * whose non-spanning cells are all flexible (or that has none) is flexible;
3148
+ * the others take the largest ideal width of their non-spanning cells. The
3149
+ * flexible columns share what is left of the proposal width equally (their
3150
+ * ideal width with a nil proposal). Row heights come from measuring every
3151
+ * cell at its cell width; a non-`gridrow` child is one full-span cell.
3152
+ */
3153
+ private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
3154
+ return this.plan(node, p, () => {
3155
+ const hSpacing = num(node.props['horizontalSpacing']) ?? DEFAULT_SPACING;
3156
+ const vSpacing = num(node.props['verticalSpacing']) ?? DEFAULT_SPACING;
3157
+ interface Source {
3158
+ node: LayoutNode | null;
3159
+ cells: { node: LayoutNode; column: number; span: number }[];
3160
+ full: boolean;
3161
+ }
3162
+ const sources: Source[] = [];
3163
+ let columnCount = 1;
3164
+ for (const child of node.children) {
3165
+ if (child.kind !== 'gridrow') {
3166
+ sources.push({ node: null, cells: [{ node: child, column: 0, span: 1 }], full: true });
3167
+ continue;
3168
+ }
3169
+ let column = 0;
3170
+ const cells: Source['cells'] = [];
3171
+ for (const cell of child.children) {
3172
+ const span = gridSpanOf(cell);
3173
+ cells.push({ node: cell, column, span });
3174
+ column += span;
3175
+ }
3176
+ columnCount = Math.max(columnCount, column);
3177
+ sources.push({ node: child, cells, full: false });
3178
+ }
3179
+
3180
+ // Column widths.
3181
+ const columns = new Array<number>(columnCount).fill(0);
3182
+ const flexible = new Array<boolean>(columnCount).fill(true);
3183
+ const maxProposal: Proposal = { width: finite(p.width) ? p.width : Infinity, height: null };
3184
+ const threshold = finite(p.width) ? p.width : Infinity;
3185
+ for (let j = 0; j < columnCount; j++) {
3186
+ let ideal = 0;
3187
+ let allFlexible = true;
3188
+ let any = false;
3189
+ for (const source of sources) {
3190
+ if (source.full) continue;
3191
+ const cctx = this.gridCellContext(node, source.node, ctx);
3192
+ for (const cell of source.cells) {
3193
+ if (cell.column !== j || cell.span !== 1) continue;
3194
+ any = true;
3195
+ ideal = Math.max(ideal, this.measure(cell.node, cctx, NIL_PROPOSAL).width);
3196
+ if (this.measure(cell.node, cctx, maxProposal).width < threshold - EPSILON) allFlexible = false;
3197
+ }
3198
+ }
3199
+ columns[j] = ideal;
3200
+ flexible[j] = !any || allFlexible;
3201
+ }
3202
+ if (finite(p.width)) {
3203
+ let fixed = 0;
3204
+ let flexibleCount = 0;
3205
+ for (let j = 0; j < columnCount; j++) {
3206
+ if (flexible[j]) flexibleCount++;
3207
+ else fixed += columns[j] as number;
3208
+ }
3209
+ const remaining = Math.max(0, p.width - fixed - hSpacing * (columnCount - 1));
3210
+ for (let j = 0; j < columnCount; j++) {
3211
+ if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
3212
+ }
3213
+ }
3214
+ const columnX = new Array<number>(columnCount).fill(0);
3215
+ for (let j = 1; j < columnCount; j++) {
3216
+ columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
3217
+ }
3218
+ const gridWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
3219
+
3220
+ // Row heights.
3221
+ const rows: GridRowPlan[] = [];
3222
+ let y = 0;
3223
+ for (const source of sources) {
3224
+ const cctx = this.gridCellContext(node, source.node, ctx);
3225
+ const cells: GridCellPlan[] = [];
3226
+ let height = 0;
3227
+ for (const cell of source.cells) {
3228
+ let width: number;
3229
+ if (source.full) width = gridWidth;
3230
+ else {
3231
+ const end = Math.min(columnCount, cell.column + cell.span);
3232
+ width = 0;
3233
+ for (let j = cell.column; j < end; j++) width += columns[j] as number;
3234
+ width += hSpacing * Math.max(0, end - cell.column - 1);
3235
+ }
3236
+ const size = this.measure(cell.node, cctx, { width, height: null });
3237
+ height = Math.max(height, size.height);
3238
+ cells.push({ node: cell.node, column: cell.column, span: cell.span, width, size, full: source.full });
3239
+ }
3240
+ rows.push({
3241
+ node: source.node,
3242
+ cells,
3243
+ y,
3244
+ height,
3245
+ alignment: source.node ? str(source.node.props['alignment']) : null,
3246
+ });
3247
+ y += height + vSpacing;
3248
+ }
3249
+ const gridHeight = rows.length > 0 ? y - vSpacing : 0;
3250
+ return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
3251
+ });
3252
+ }
3253
+
3254
+ private placeGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3255
+ const plan = this.gridPlan(node, ctx, p);
3256
+ const gridAlignment = str(node.props['alignment']) ?? 'center';
3257
+ const [gridX] = alignmentAnchors(gridAlignment);
3258
+ for (const row of plan.rows) {
3259
+ if (row.node) this.setFrame(row.node, x, y + row.y, { width: plan.size.width, height: row.height });
3260
+ const cctx = this.gridCellContext(node, row.node, ctx);
3261
+ const rowY = rowAnchor(row.alignment, gridAlignment);
3262
+ for (const cell of row.cells) {
3263
+ const box: Size = { width: cell.width, height: row.height };
3264
+ const [ax, ay] = gridAnchorOf(cell.node) ?? [gridX, rowY];
3265
+ const dx = safeOffset(box.width, cell.size.width, ax);
3266
+ const dy = safeOffset(box.height, cell.size.height, ay);
3267
+ const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);
3268
+ this.place(cell.node, cctx, { width: cell.width, height: null }, x + cellX + dx, y + row.y + dy);
3269
+ }
3270
+ }
3271
+ }
3272
+
3273
+ // -------------------------------------------------------------------------
3274
+ // LazyVGrid (Phase 6)
3275
+ // -------------------------------------------------------------------------
3276
+
3277
+ /**
3278
+ * Columns are resolved for the proposal width: fixed items take their
3279
+ * value; flexible and adaptive items share the rest (after the fixed items
3280
+ * and the gaps between items) equally, flexible ones clamped to [min, max];
3281
+ * an adaptive item then fits as many columns as its share allows,
3282
+ * n = max(1, floor((W' + s) / (min + s))), each (W' − (n−1)·s) / n wide,
3283
+ * clamped. With no width the flexible columns take the cells' largest ideal
3284
+ * width and an adaptive item is one column. Cells fill rows left to right.
3285
+ */
3286
+ private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
3287
+ return this.plan(node, p, () => {
3288
+ const items = readGridItems(node.props['columns']);
3289
+ const rowSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
3290
+ const cctx = this.childContext(node, ctx);
3291
+ const cells = node.children;
3292
+ let idealCell: number | null = null;
3293
+ const ideal = (): number => {
3294
+ if (idealCell === null) {
3295
+ idealCell = 0;
3296
+ for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL).width);
3297
+ }
3298
+ return idealCell;
3299
+ };
3300
+ const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
3301
+ if (!finite(p.width)) {
3302
+ items.forEach((item, i) => {
3303
+ if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
3304
+ });
3305
+ } else {
3306
+ let gaps = 0;
3307
+ for (let i = 0; i < items.length - 1; i++) gaps += (items[i] as GridItemSpec).spacing;
3308
+ let fixed = 0;
3309
+ for (const item of items) if (item.kind === 'fixed') fixed += item.value;
3310
+ const sharing = items.filter((item) => item.kind !== 'fixed').length;
3311
+ const remaining = Math.max(0, p.width - fixed - gaps);
3312
+ const share = sharing > 0 ? remaining / sharing : 0;
3313
+ let flexibleTotal = 0;
3314
+ let adaptiveCount = 0;
3315
+ items.forEach((item, i) => {
3316
+ if (item.kind === 'flexible') {
3317
+ const width = clamp(share, item.minimum, item.maximum);
3318
+ widths[i] = [width];
3319
+ flexibleTotal += width;
3320
+ } else if (item.kind === 'adaptive') adaptiveCount++;
3321
+ });
3322
+ const adaptiveShare = adaptiveCount > 0 ? Math.max(0, remaining - flexibleTotal) / adaptiveCount : 0;
3323
+ items.forEach((item, i) => {
3324
+ if (item.kind !== 'adaptive') return;
3325
+ const s = item.spacing;
3326
+ const unit = item.minimum + s;
3327
+ const n = unit > 0 ? Math.max(1, Math.floor((adaptiveShare + s) / unit + EPSILON)) : 1;
3328
+ const width = clamp((adaptiveShare - (n - 1) * s) / n, item.minimum, item.maximum);
3329
+ widths[i] = new Array<number>(n).fill(width);
3330
+ });
3331
+ }
3332
+
3333
+ const columns: LazyColumnPlan[] = [];
3334
+ let cursor = 0;
3335
+ items.forEach((item, i) => {
3336
+ const itemWidths = widths[i] as number[];
3337
+ itemWidths.forEach((width, k) => {
3338
+ columns.push({ x: cursor, width, alignment: item.alignment });
3339
+ cursor += width;
3340
+ const last = i === items.length - 1 && k === itemWidths.length - 1;
3341
+ if (!last) cursor += item.spacing;
3342
+ });
3343
+ });
3344
+ const used = cursor;
3345
+ const gridWidth = finite(p.width) ? p.width : used;
3346
+ const alignment = str(node.props['alignment']) ?? 'center';
3347
+ const offset = safeOffset(gridWidth, used, alignmentAnchors(alignment)[0]);
3348
+ for (const column of columns) column.x += offset;
3349
+
3350
+ const rows: LazyGridPlan['rows'] = [];
3351
+ let y = 0;
3352
+ const perRow = Math.max(1, columns.length);
3353
+ for (let start = 0; start < cells.length; start += perRow) {
3354
+ const rowCells: LazyGridPlan['rows'][number]['cells'] = [];
3355
+ let height = 0;
3356
+ for (let k = 0; k < perRow && start + k < cells.length; k++) {
3357
+ const cell = cells[start + k] as LayoutNode;
3358
+ const column = columns[k] as LazyColumnPlan;
3359
+ const size = this.measure(cell, cctx, { width: column.width, height: null });
3360
+ height = Math.max(height, size.height);
3361
+ rowCells.push({ node: cell, column: k, size });
3362
+ }
3363
+ rows.push({ y, height, cells: rowCells });
3364
+ y += height + rowSpacing;
3365
+ }
3366
+ const gridHeight = rows.length > 0 ? y - rowSpacing : 0;
3367
+ return { size: { width: gridWidth, height: gridHeight }, columns, rows };
3368
+ });
3369
+ }
3370
+
3371
+ private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3372
+ const plan = this.lazyGridPlan(node, ctx, p);
3373
+ const cctx = this.childContext(node, ctx);
3374
+ const gridAlignment = str(node.props['alignment']) ?? 'center';
3375
+ for (const row of plan.rows) {
3376
+ for (const cell of row.cells) {
3377
+ const column = plan.columns[cell.column] as LazyColumnPlan;
3378
+ const box: Size = { width: column.width, height: row.height };
3379
+ const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
3380
+ this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
3381
+ }
3382
+ }
3383
+ }
3384
+
3385
+ // -------------------------------------------------------------------------
3386
+ // Gauge (Phase 6)
3387
+ // -------------------------------------------------------------------------
3388
+
3389
+ /**
3390
+ * A linear gauge: width = proposal (100 when none), a 4px bar below an
3391
+ * optional label row (label leading, current value label trailing, both in
3392
+ * the `caption` text style) with a 4px gap. Circular styles render linear.
3393
+ */
3394
+ private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
3395
+ return this.plan(node, p, () => {
3396
+ const width = finite(p.width) ? p.width : GAUGE_DEFAULT_WIDTH;
3397
+ const cctx = this.childContext(node, ctx);
3398
+ const labelCtx: Context = { ...cctx, font: resolveFont({ textStyle: 'caption' }, ctx.font, this.env) };
3399
+ let index = 0;
3400
+ const label = node.props['hasLabel'] === true ? (node.children[index++] ?? null) : null;
3401
+ const current = node.props['hasCurrentValueLabel'] === true ? (node.children[index++] ?? null) : null;
3402
+ const proposal: Proposal = { width, height: null };
3403
+ const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
3404
+ const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3405
+ const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
3406
+ const height = GAUGE_BAR_HEIGHT + (labelRow > 0 ? labelRow + GAUGE_LABEL_GAP : 0);
3407
+ return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx };
3408
+ });
3409
+ }
3410
+
3411
+ private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
3412
+ const plan = this.gaugePlan(node, ctx, p);
3413
+ const proposal: Proposal = { width: size.width, height: null };
3414
+ if (plan.label) {
3415
+ this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
3416
+ }
3417
+ if (plan.current) {
3418
+ this.place(
3419
+ plan.current,
3420
+ plan.labelCtx,
3421
+ proposal,
3422
+ x + size.width - plan.currentSize.width,
3423
+ y + (plan.labelRow - plan.currentSize.height) / 2,
3424
+ );
3425
+ }
3426
+ const value = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
3427
+ const barY = y + (plan.labelRow > 0 ? plan.labelRow + GAUGE_LABEL_GAP : 0);
3428
+ this.setChrome(node, {
3429
+ track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
3430
+ fill: { x, y: barY, width: size.width * value, height: GAUGE_BAR_HEIGHT },
3431
+ });
3432
+ }
3433
+
3434
+ // -------------------------------------------------------------------------
3435
+ // Chart (Phase 7)
3436
+ // -------------------------------------------------------------------------
3437
+
3438
+ /**
3439
+ * A chart is the proposal on both axes (300 × 200 for nil axes). Its box is
3440
+ * split into the legend, the axis bands (as thick as their largest label,
3441
+ * measured in the `caption` font or, for `xLabel` / `yLabel` children, at
3442
+ * the view's ideal size, plus 6) and the plot; every `chartitem` child is
3443
+ * measured at its ideal size and framed against the plot (src/layout/chart.ts).
3444
+ */
3445
+ private chartPlan(node: LayoutNode, ctx: Context, p: Proposal): ChartPlan {
3446
+ return this.plan(node, p, () => {
3447
+ const size = chartSize(p.width, p.height);
3448
+ // An infinite proposal reports infinite (the chart is flexible); the
3449
+ // geometry is computed for the default size and recomputed when placed.
3450
+ const box = chartSize(finite(size.width) ? size.width : null, finite(size.height) ? size.height : null);
3451
+ const childCtx = this.childContext(node, ctx);
3452
+ const font = resolveFont({ textStyle: 'caption' }, ctx.font, this.env);
3453
+ const measureWidth = (text: string): number => this.measurer.measureWidth(text, font);
3454
+
3455
+ // Children at their ideal size, by role.
3456
+ const items: ChartItemPlan[] = node.children.map((child) => {
3457
+ const role = str(child.props['role']) ?? 'annotation';
3458
+ const childSize = this.measure(child, childCtx, NIL_PROPOSAL);
3459
+ return { node: child, role, size: childSize, frame: { x: 0, y: 0, ...childSize } };
3460
+ });
3461
+ const xViews = items.filter((item) => item.role === 'xLabel');
3462
+ const yViews = items.filter((item) => item.role === 'yLabel');
3463
+
3464
+ const axisInput = (axis: unknown, fallback: string, views: ChartItemPlan[], key: 'x0' | 'y0'): ChartAxisInput | null => {
3465
+ const a = obj(axis);
3466
+ if (!a) return null;
3467
+ const labels: { at: number; width: number }[] = [];
3468
+ const rawLabels = Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
3469
+ for (const raw of rawLabels) {
3470
+ const label = obj(raw);
3471
+ if (!label) continue;
3472
+ const text = str(label['text']) ?? '';
3473
+ labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text) });
3474
+ }
3475
+ return {
3476
+ position: (str(a['position']) ?? fallback) as ChartAxisInput['position'],
3477
+ labels,
3478
+ labelHeight: font.lineHeight,
3479
+ views: views.map((view) => ({ at: num(view.node.props[key]) ?? 0, size: view.size })),
3480
+ };
3481
+ };
3482
+ const xAxis = axisInput(node.props['xAxis'], 'bottom', xViews, 'x0');
3483
+ const yAxis = axisInput(node.props['yAxis'], 'trailing', yViews, 'y0');
3484
+
3485
+ let legend: ChartLegendInput | null = null;
3486
+ const rawLegend = obj(node.props['legend']);
3487
+ if (rawLegend) {
3488
+ const rawItems = Array.isArray(rawLegend['items']) ? (rawLegend['items'] as unknown[]) : [];
3489
+ const itemWidths = rawItems.map((raw) => {
3490
+ const item = obj(raw);
3491
+ const glyph = legendGlyphSize(item ? str(item['symbol']) : null);
3492
+ // Whole pixels: the item's rect is snapped down, which must not clip the label.
3493
+ return Math.ceil(glyph.width + CHART_LEGEND_GAP + measureWidth(item ? (str(item['label']) ?? '') : ''));
3494
+ });
3495
+ legend = {
3496
+ position: (str(rawLegend['position']) ?? 'top') as ChartLegendInput['position'],
3497
+ itemWidths,
3498
+ itemHeight: Math.max(font.lineHeight, CHART_LEGEND_GLYPH),
3499
+ };
3500
+ }
3501
+
3502
+ const geometry = layoutChart({ width: box.width, height: box.height, xAxis, yAxis, legend });
3503
+
3504
+ // Frames (relative to the chart origin) for every child.
3505
+ xViews.forEach((item, i) => {
3506
+ item.frame = geometry.xViews[i] ?? item.frame;
3507
+ });
3508
+ yViews.forEach((item, i) => {
3509
+ item.frame = geometry.yViews[i] ?? item.frame;
3510
+ });
3511
+ for (const item of items) {
3512
+ if (item.role === 'xLabel' || item.role === 'yLabel') continue;
3513
+ const props = item.node.props;
3514
+ const R = unitRect(
3515
+ geometry.plot,
3516
+ num(props['x0']) ?? 0,
3517
+ num(props['x1']) ?? 0,
3518
+ num(props['y0']) ?? 0,
3519
+ num(props['y1']) ?? 0,
3520
+ );
3521
+ const position = (str(props['position']) ?? 'overlay') as ChartItemPosition;
3522
+ item.frame = annotationFrame(R, item.size, position, str(props['alignment']), num(props['spacing']) ?? 0);
3523
+ }
3524
+
3525
+ const labelTexts = (axis: unknown): string[] => {
3526
+ const a = obj(axis);
3527
+ const raw = a && Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
3528
+ return raw.filter((l) => obj(l) !== null).map((l) => str((l as Record<string, unknown>)['text']) ?? '');
3529
+ };
3530
+ return {
3531
+ size,
3532
+ geometry,
3533
+ font,
3534
+ items,
3535
+ xLabelTexts: labelTexts(node.props['xAxis']),
3536
+ yLabelTexts: labelTexts(node.props['yAxis']),
3537
+ childCtx,
3538
+ };
3539
+ });
3540
+ }
3541
+
3542
+ private placeChart(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3543
+ const plan = this.chartPlan(node, ctx, p);
3544
+ const shift = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
3545
+ for (const item of plan.items) {
3546
+ this.place(item.node, plan.childCtx, NIL_PROPOSAL, x + item.frame.x, y + item.frame.y);
3547
+ }
3548
+ const g = plan.geometry;
3549
+ const chrome: Record<string, Rect> = { plot: shift(g.plot) };
3550
+ if (g.legend) chrome['legend'] = shift(g.legend);
3551
+ if (g.xBand) chrome['xband'] = shift(g.xBand);
3552
+ if (g.yBand) chrome['yband'] = shift(g.yBand);
3553
+ const texts: Record<string, TextLayout> = {};
3554
+ const xAlign: TextAlignment = 'center';
3555
+ const yAlign: TextAlignment = g.yBand && g.yBand.x < g.plot.x ? 'trailing' : 'leading';
3556
+ g.xLabels.forEach((rect, i) => {
3557
+ chrome[`xlabel${i}`] = shift(rect);
3558
+ texts[`xlabel${i}`] = { font: plan.font, lines: [plan.xLabelTexts[i] ?? ''], alignment: xAlign, truncated: false };
3559
+ });
3560
+ g.yLabels.forEach((rect, i) => {
3561
+ chrome[`ylabel${i}`] = shift(rect);
3562
+ texts[`ylabel${i}`] = { font: plan.font, lines: [plan.yLabelTexts[i] ?? ''], alignment: yAlign, truncated: false };
3563
+ });
3564
+ g.legendItems.forEach((rect, i) => {
3565
+ chrome[`legend${i}`] = shift(rect);
3566
+ });
3567
+ this.setChrome(node, chrome);
3568
+ if (node.id >= 0) {
3569
+ this.chromeText.set(node.id, texts);
3570
+ this.fonts.set(node.id, plan.font);
3571
+ }
3572
+ }
3573
+ }
3574
+
3575
+ export function layoutTree(
3576
+ roots: readonly LayoutNode[],
3577
+ env: LayoutEnvironment,
3578
+ measurer: TextMeasurer,
3579
+ ): LayoutResult {
3580
+ return new LayoutPass(env, measurer).run(roots);
3581
+ }