@swiftbrowser/web 0.1.0 → 0.2.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 (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +11 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. package/src/zoneinfo.ts +269 -0
@@ -14,6 +14,8 @@ export const CHART_DEFAULT_WIDTH = 300;
14
14
  export const CHART_DEFAULT_HEIGHT = 200;
15
15
  /** Gap between an axis label and the plot edge (the ticks sit inside it). */
16
16
  export const CHART_AXIS_PAD = 6;
17
+ /** How far a leading-anchored x label (a date's) starts after its tick. */
18
+ export const CHART_LEADING_LABEL_OFFSET = 2;
17
19
  export const CHART_TICK_LENGTH = 4;
18
20
  /** Legend glyph: a `symbolSize`-px symbol, or an 18 × 3 line when the item has no symbol. */
19
21
  export const CHART_LEGEND_GLYPH = 10;
@@ -32,8 +34,11 @@ export type ChartItemPosition = 'top' | 'bottom' | 'leading' | 'trailing' | 'ove
32
34
 
33
35
  export interface ChartAxisInput {
34
36
  position: ChartAxisPosition;
35
- /** Text labels: unit position and measured width (height is `labelHeight`). */
36
- labels: { at: number; width: number }[];
37
+ /**
38
+ * Text labels: unit position and measured width (height is `labelHeight`);
39
+ * `leading`: an x label starts at its tick (a date's) instead of centering on it.
40
+ */
41
+ labels: { at: number; width: number; leading?: boolean }[];
37
42
  /** Line height of the axis font. */
38
43
  labelHeight: number;
39
44
  /** View labels (`xLabel` / `yLabel` children) at their ideal size. */
@@ -186,7 +191,8 @@ export function layoutChart(input: ChartLayoutInput): ChartGeometry {
186
191
  const top = (h: number) => (xTop ? plot.y - CHART_AXIS_PAD - h : plot.y + plot.height + CHART_AXIS_PAD);
187
192
  for (const label of xAxis.labels) {
188
193
  const h = xAxis.labelHeight;
189
- const x = clampTo(mapX(plot, label.at) - label.width / 2, label.width, 0, width);
194
+ const start = label.leading ? mapX(plot, label.at) + CHART_LEADING_LABEL_OFFSET : mapX(plot, label.at) - label.width / 2;
195
+ const x = clampTo(start, label.width, 0, width);
190
196
  xLabels.push({ x, y: top(h), width: label.width, height: h });
191
197
  }
192
198
  for (const view of xAxis.views) {
@@ -20,8 +20,9 @@
20
20
  */
21
21
 
22
22
  import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
23
- import { wrapText } from './text';
23
+ import { wrapRuns, wrapText, type TextRunInput } from './text';
24
24
  import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
25
+ import { videoSize } from './video';
25
26
  import {
26
27
  CHART_LEGEND_GAP,
27
28
  CHART_LEGEND_GLYPH,
@@ -83,6 +84,11 @@ export interface Context {
83
84
  * the screen at the root, a scroll view's viewport inside one.
84
85
  */
85
86
  container: Size;
87
+ /**
88
+ * The lower bound of the enclosing `.lineLimit(2...4)` range (`lineLimitMin`):
89
+ * a vertical text field is at least this many lines tall. Absent for 1.
90
+ */
91
+ minLines?: number;
86
92
  }
87
93
 
88
94
  // Constants from the spec.
@@ -100,6 +106,8 @@ const TOGGLE_GAP = 8;
100
106
  const TEXTFIELD_DEFAULT_WIDTH = 200;
101
107
  const TEXTFIELD_HEIGHT_ROUNDED = 34;
102
108
  const TEXTFIELD_HEIGHT_PLAIN = 22;
109
+ /** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
110
+ const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
103
111
  const NAV_BAR_HEIGHT = 44;
104
112
  const LARGE_TITLE_HEIGHT = 52;
105
113
  /** Chevron glyph plus its gap, in px. */
@@ -132,6 +140,10 @@ const PICKER_MENU_GAP = 12;
132
140
  const GAUGE_BAR_HEIGHT = 4;
133
141
  const GAUGE_LABEL_GAP = 4;
134
142
  const GAUGE_DEFAULT_WIDTH = 100;
143
+ /** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
144
+ const GAUGE_RING_SIZE = 58;
145
+ /** The square inside the ring its label is proposed, as a fraction of the side. */
146
+ const GAUGE_RING_INNER = 0.62;
135
147
  const GRID_ITEM_DEFAULT_MIN = 10;
136
148
  // Phase 6 (docs/phase6/chrome.md).
137
149
  const PICKER_INLINE_ROW_HEIGHT = 44;
@@ -457,6 +469,8 @@ interface RowHints {
457
469
  badge: string | null;
458
470
  /** `.listRowInsets(_:)`, or null for the defaults. */
459
471
  insets: EdgeInsets | null;
472
+ /** `.listRowSeparator(.hidden, edges:)`: the edges whose separator the row hides. */
473
+ separatorHidden: { top: boolean; bottom: boolean } | null;
460
474
  }
461
475
 
462
476
  /**
@@ -464,7 +478,7 @@ interface RowHints {
464
478
  * outermost wins, like `layoutPriority`): `badge` and `listRowInsets`.
465
479
  */
466
480
  function rowHintsOf(node: LayoutNode): RowHints {
467
- const hints: RowHints = { badge: null, insets: null };
481
+ const hints: RowHints = { badge: null, insets: null, separatorHidden: null };
468
482
  let current: LayoutNode | undefined = node;
469
483
  // `NavigationLink(value:) { OrderRow().badge(n) }`: the hints sit on the
470
484
  // link's label, which is the row's content. Phase 8 wrappers (`listrow`,
@@ -477,6 +491,11 @@ function rowHintsOf(node: LayoutNode): RowHints {
477
491
  if (badge !== null && badge.length > 0) hints.badge = badge;
478
492
  }
479
493
  if (hints.insets === null) hints.insets = readInsets(style['listRowInsets']);
494
+ const separator = style['listRowSeparator'];
495
+ if (hints.separatorHidden === null && typeof separator === 'object' && separator !== null) {
496
+ const edges = separator as Record<string, unknown>;
497
+ hints.separatorHidden = { top: edges['top'] === true, bottom: edges['bottom'] === true };
498
+ }
480
499
  current = current.children.length === 1 ? current.children[0] : undefined;
481
500
  } else if (current.kind === 'navlink' && current.children.length === 1) {
482
501
  current = current.children[0];
@@ -581,6 +600,49 @@ function alignIn(container: Size, child: Size, alignment: string): { dx: number;
581
600
  };
582
601
  }
583
602
 
603
+ /** The guide names a pair of anchors aligns by: `leading`/`center`/`trailing` and `top`/`center`/`bottom`. */
604
+ function guideNames([ax, ay]: [number, number]): [string, string] {
605
+ return [ax === 0 ? 'leading' : ax === 1 ? 'trailing' : 'center', ay === 0 ? 'top' : ay === 1 ? 'bottom' : 'center'];
606
+ }
607
+
608
+ /**
609
+ * The guide `name` on `axis` a view moved with `.alignmentGuide`, as
610
+ * `[a, b, c]` (the guide is `a + b·width + c·height`): set on the view or on
611
+ * a `styled` wrapper inside it (the modifiers between change nothing here).
612
+ */
613
+ export function explicitGuide(node: LayoutNode, axis: 'horizontal' | 'vertical', name: string): [number, number, number] | null {
614
+ let current: LayoutNode | undefined = node;
615
+ for (let depth = 0; current && current.kind === 'styled' && depth < 32; depth++) {
616
+ const guide = (current.props['style'] as { alignmentGuide?: { axis?: string; name?: string; value?: unknown } } | undefined)?.alignmentGuide;
617
+ if (guide && guide.axis === axis && guide.name === name && Array.isArray(guide.value) && guide.value.length === 3) {
618
+ const [a, b, c] = guide.value.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) as [number, number, number];
619
+ return [a, b, c];
620
+ }
621
+ current = current.children.length === 1 ? current.children[0] : undefined;
622
+ }
623
+ return null;
624
+ }
625
+
626
+ function guideValue([a, b, c]: [number, number, number], size: Size): number {
627
+ return a + b * size.width + c * size.height;
628
+ }
629
+
630
+ /**
631
+ * `alignIn` for a child that may have moved its guides (`.alignmentGuide`):
632
+ * on an axis where it did, its guide meets the container's guide for the
633
+ * same alignment (0, the middle or the end); elsewhere it aligns as before.
634
+ */
635
+ function alignChild(container: Size, child: Size, alignment: string, node: LayoutNode): { dx: number; dy: number } {
636
+ const anchors = alignmentAnchors(alignment);
637
+ const [horizontal, vertical] = guideNames(anchors);
638
+ const offsets = alignIn(container, child, alignment);
639
+ const gx = explicitGuide(node, 'horizontal', horizontal);
640
+ const gy = explicitGuide(node, 'vertical', vertical);
641
+ if (gx && Number.isFinite(container.width)) offsets.dx = container.width * anchors[0] - guideValue(gx, child);
642
+ if (gy && Number.isFinite(container.height)) offsets.dy = container.height * anchors[1] - guideValue(gy, child);
643
+ return offsets;
644
+ }
645
+
584
646
  function safeOffset(container: number, child: number, anchor: number): number {
585
647
  if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
586
648
  return (container - child) * anchor;
@@ -682,6 +744,17 @@ interface ListRowPlan {
682
744
  badge: { rect: Rect; layout: TextLayout } | null;
683
745
  /** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
684
746
  edit: { listRow: LayoutNode; control: Rect } | null;
747
+ /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
748
+ separatorHidden?: { top: boolean; bottom: boolean } | null;
749
+ }
750
+
751
+ /** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
752
+ interface ListSupplementPlan {
753
+ node: LayoutNode;
754
+ ctx: Context;
755
+ proposal: Proposal;
756
+ /** Relative to the list origin at scroll 0. */
757
+ rect: Rect;
685
758
  }
686
759
 
687
760
  interface ListGroupPlan {
@@ -689,6 +762,8 @@ interface ListGroupPlan {
689
762
  frame: Rect;
690
763
  header: { rect: Rect; layout: TextLayout } | null;
691
764
  footer: { rect: Rect; layout: TextLayout } | null;
765
+ headerView: ListSupplementPlan | null;
766
+ footerView: ListSupplementPlan | null;
692
767
  card: Rect;
693
768
  rows: ListRowPlan[];
694
769
  }
@@ -769,6 +844,8 @@ interface GaugePlan {
769
844
  /** Height of the label row (0 without labels). */
770
845
  labelRow: number;
771
846
  labelCtx: Context;
847
+ /** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
848
+ ring?: { center: LayoutNode | null; centerSize: Size };
772
849
  }
773
850
 
774
851
  /** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
@@ -1030,7 +1107,11 @@ export class LayoutPass {
1030
1107
  if (font !== ctx.font) next = { ...next, font };
1031
1108
  if ('lineLimit' in style) {
1032
1109
  const limit = num(style['lineLimit']);
1110
+ const min = num(style['lineLimitMin']);
1033
1111
  next = { ...next, lineLimit: limit !== null && limit >= 1 ? Math.floor(limit) : null };
1112
+ // A new limit replaces the whole range, lower bound included.
1113
+ if (min !== null && min > 1) next.minLines = Math.floor(min);
1114
+ else delete next.minLines;
1034
1115
  }
1035
1116
  const alignment = str(style['multilineTextAlignment']);
1036
1117
  if (alignment === 'leading' || alignment === 'center' || alignment === 'trailing') {
@@ -1120,6 +1201,9 @@ export class LayoutPass {
1120
1201
  return this.measureImage(node, ctx, p);
1121
1202
  case 'spacer':
1122
1203
  return this.measureSpacer(node, ctx, p);
1204
+ case 'lazyspacer':
1205
+ // Lazy rows not built yet: no space (the page builds them as it nears).
1206
+ return ZERO;
1123
1207
  case 'divider':
1124
1208
  if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
1125
1209
  return { width: finite(p.width) ? p.width : 0, height: 1 };
@@ -1136,6 +1220,7 @@ export class LayoutPass {
1136
1220
  case 'toggle':
1137
1221
  return this.measureToggle(node, ctx, p);
1138
1222
  case 'textfield': {
1223
+ if (node.props['axis'] === 'vertical') return this.measureMultilineField(node, ctx, p);
1139
1224
  const style = str(node.props['style']);
1140
1225
  return {
1141
1226
  width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
@@ -1144,7 +1229,11 @@ export class LayoutPass {
1144
1229
  }
1145
1230
  case 'color':
1146
1231
  case 'shape':
1232
+ case 'canvas':
1147
1233
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1234
+ case 'video':
1235
+ // Fills the proposal; an open axis follows the video's aspect ratio.
1236
+ return videoSize(node.props, p);
1148
1237
  case 'scrollview':
1149
1238
  return this.scrollPlan(node, ctx, p).size;
1150
1239
  case 'list':
@@ -1230,8 +1319,26 @@ export class LayoutPass {
1230
1319
  }
1231
1320
  }
1232
1321
 
1322
+ /**
1323
+ * A vertical `textfield`: the proposed width (200 when unconstrained) and
1324
+ * as many lines as its text (or, empty, its placeholder) wraps to in the
1325
+ * context font, between the range's `minLines` and `lineLimit`.
1326
+ */
1327
+ private measureMultilineField(node: LayoutNode, ctx: Context, p: Proposal): Size {
1328
+ const inset = str(node.props['style']) === 'roundedBorder' ? TEXTFIELD_ROUNDED_INSET : { horizontal: 0, vertical: 0 };
1329
+ const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
1330
+ const text = str(node.props['text']) || str(node.props['placeholder']) || '';
1331
+ const wrapped = wrapText(text, ctx.font, Math.max(1, width - 2 * inset.horizontal), null, this.measurer);
1332
+ const min = Math.max(1, ctx.minLines ?? 1);
1333
+ const max = Math.max(min, ctx.lineLimit ?? Infinity);
1334
+ const lines = Math.min(Math.max(wrapped.lines.length, min), max);
1335
+ return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
1336
+ }
1337
+
1233
1338
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
1234
1339
  return this.plan(node, p, () => {
1340
+ const runs = node.props['runs'];
1341
+ if (Array.isArray(runs)) return this.richTextPlan(runs, ctx, p);
1235
1342
  const text = str(node.props['text']) ?? '';
1236
1343
  const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1237
1344
  return {
@@ -1246,6 +1353,47 @@ export class LayoutPass {
1246
1353
  });
1247
1354
  }
1248
1355
 
1356
+ /**
1357
+ * Rich text: each run's font (the text's font, changed by the run's), its
1358
+ * kerning, and an inline image's size; wrapped as one paragraph flow.
1359
+ */
1360
+ private richTextPlan(runs: unknown[], ctx: Context, p: Proposal): TextPlan {
1361
+ const inputs: TextRunInput[] = runs.map((value) => {
1362
+ const run = obj(value) ?? {};
1363
+ let font = resolveFont(run['font'], ctx.font, this.env);
1364
+ if (run['italic'] === true) font = { ...font, css: `italic ${font.css}` };
1365
+ const kerning = num(run['kerning']) ?? 0;
1366
+ const image = obj(run['image']);
1367
+ if (image) return { image: this.inlineImageSize(image, font), font, kerning };
1368
+ return { text: str(run['text']) ?? '', font, kerning };
1369
+ });
1370
+ const wrapped = wrapRuns(inputs, ctx.font, p.width, ctx.lineLimit, this.measurer);
1371
+ const lines = wrapped.lines.map((line) => line.map((fragment) => fragment.text ?? '\uFFFC').join(''));
1372
+ return {
1373
+ size: { width: wrapped.width, height: wrapped.heights.reduce((total, height) => total + height, 0) },
1374
+ layout: {
1375
+ font: ctx.font,
1376
+ lines,
1377
+ alignment: ctx.textAlignment,
1378
+ truncated: wrapped.truncated,
1379
+ rich: { lines: wrapped.lines, heights: wrapped.heights, fonts: inputs.map((input) => input.font) },
1380
+ },
1381
+ };
1382
+ }
1383
+
1384
+ /** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
1385
+ private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
1386
+ if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
1387
+ return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
1388
+ }
1389
+ const name = str(props['name']);
1390
+ if (name === null) return square(font.lineHeight);
1391
+ const asset = this.env.assets?.images[name];
1392
+ if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1393
+ if (asset.kind === 'symbol') return square(font.lineHeight);
1394
+ return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
1395
+ }
1396
+
1249
1397
  private measureSpacer(node: LayoutNode, ctx: Context, p: Proposal): Size {
1250
1398
  const min = num(node.props['minLength']);
1251
1399
  if (ctx.stackAxis === null) {
@@ -1326,6 +1474,10 @@ export class LayoutPass {
1326
1474
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1327
1475
  const { screen, safeArea } = this.env;
1328
1476
  if (first === 'medium') return screen.height / 2;
1477
+ // `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
1478
+ const large = screen.height - safeArea.top - SHEET_TOP_GAP;
1479
+ if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
1480
+ if (first?.startsWith('fraction:')) return Math.min(large, screen.height * (Number(first.slice(9)) || 1));
1329
1481
  // Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
1330
1482
  if (first === 'full') return screen.height;
1331
1483
  return screen.height - safeArea.top - SHEET_TOP_GAP;
@@ -1582,12 +1734,27 @@ export class LayoutPass {
1582
1734
  const anchors = alignmentAnchors(alignment);
1583
1735
  const crossAnchor = axis === 'horizontal' ? anchors[1] : anchors[0];
1584
1736
  const crossSize = crossOf(size, axis);
1737
+ // `.alignmentGuide`: the children's guides for the stack's alignment
1738
+ // line up. A child's guide is its explicit one, else the default (none,
1739
+ // half or all of its cross size); the line is the stack's own guide, or
1740
+ // further along when a guide reaches past it (the children move with it).
1741
+ const guideAxis = axis === 'horizontal' ? 'vertical' : 'horizontal';
1742
+ const guideName = guideNames(anchors)[axis === 'horizontal' ? 1 : 0];
1743
+ const guides = children.map((child, i) => {
1744
+ const explicit = explicitGuide(child, guideAxis, guideName);
1745
+ return explicit ? guideValue(explicit, plan.childSizes[i] as Size) : null;
1746
+ });
1747
+ const line = guides.some((g) => g !== null) && Number.isFinite(crossSize)
1748
+ ? Math.max(crossSize * crossAnchor, ...guides.filter((g): g is number => g !== null))
1749
+ : null;
1585
1750
  let cursor = 0;
1586
1751
  for (let i = 0; i < children.length; i++) {
1587
1752
  const child = children[i] as LayoutNode;
1588
1753
  const childSize = plan.childSizes[i] as Size;
1589
1754
  const proposal = plan.childProposals[i] as Proposal;
1590
- const crossOffset = safeOffset(crossSize, crossOf(childSize, axis), crossAnchor);
1755
+ const crossOffset = line === null
1756
+ ? safeOffset(crossSize, crossOf(childSize, axis), crossAnchor)
1757
+ : line - (guides[i] ?? crossOf(childSize, axis) * crossAnchor);
1591
1758
  if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
1592
1759
  else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
1593
1760
  cursor += axisOf(childSize, axis) + spacing;
@@ -1606,6 +1773,13 @@ export class LayoutPass {
1606
1773
  * fixed 44×44 so the placeholder is visible.
1607
1774
  */
1608
1775
  private measureImage(node: LayoutNode, ctx: Context, p: Proposal): Size {
1776
+ if (typeof node.props['url'] === 'string' || typeof node.props['bitmap'] === 'object') {
1777
+ // Phase 13: a remote image carries its own point size, as does a bitmap.
1778
+ const width = num(node.props['width']) ?? 0;
1779
+ const height = num(node.props['height']) ?? 0;
1780
+ if (node.props['resizable'] !== true) return { width, height };
1781
+ return { width: p.width ?? width, height: p.height ?? height };
1782
+ }
1609
1783
  const name = str(node.props['name']);
1610
1784
  if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
1611
1785
  const asset = this.env.assets?.images[name];
@@ -1815,6 +1989,8 @@ export class LayoutPass {
1815
1989
  // sections are full width. `plain` also drops the top/bottom gaps and
1816
1990
  // the space between sections.
1817
1991
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
1992
+ // `.environment(\.defaultMinListRowHeight, _)`; 44 by default.
1993
+ const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
1818
1994
  const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
1819
1995
  const plain = listStyle === 'plain';
1820
1996
  const listTop = plain ? 0 : LIST_TOP;
@@ -1834,17 +2010,30 @@ export class LayoutPass {
1834
2010
  // its content shifts right by as much.
1835
2011
  const editing = node.props['editing'] === true;
1836
2012
 
1837
- // Group consecutive plain rows into an implicit section.
1838
- const groupsSource: { section: LayoutNode | null; rows: LayoutNode[] }[] = [];
2013
+ // Group consecutive plain rows into an implicit section. A section's
2014
+ // view header and footer are not rows.
2015
+ type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
2016
+ const groupsSource: GroupSource[] = [];
1839
2017
  for (const child of node.children) {
1840
2018
  if (child.kind === 'section') {
1841
- groupsSource.push({ section: child, rows: child.children });
2019
+ const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2020
+ const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2021
+ const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2022
+ groupsSource.push({ section: child, rows, headerView, footerView });
1842
2023
  } else {
1843
2024
  const last = groupsSource[groupsSource.length - 1];
1844
2025
  if (last && last.section === null) last.rows.push(child);
1845
- else groupsSource.push({ section: null, rows: [child] });
2026
+ else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
1846
2027
  }
1847
2028
  }
2029
+ // View headers and footers: the content width, the footnote font.
2030
+ const supplementCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2031
+ const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
2032
+ if (!view) return null;
2033
+ const proposal: Proposal = { width: contentWidth, height: null };
2034
+ const size = this.measure(view, supplementCtx, proposal);
2035
+ return { node: view, ctx: supplementCtx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2036
+ };
1848
2037
 
1849
2038
  const groups: ListGroupPlan[] = [];
1850
2039
  let y = listTop;
@@ -1856,11 +2045,13 @@ export class LayoutPass {
1856
2045
  };
1857
2046
  };
1858
2047
  for (let g = 0; g < groupsSource.length; g++) {
1859
- const source = groupsSource[g] as { section: LayoutNode | null; rows: LayoutNode[] };
2048
+ const source = groupsSource[g] as GroupSource;
1860
2049
  if (g > 0) y += sectionGap;
1861
2050
  const top = y;
1862
2051
  let header: ListGroupPlan['header'] = null;
1863
2052
  let footer: ListGroupPlan['footer'] = null;
2053
+ const headerView = supplement(source.headerView, y);
2054
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP;
1864
2055
  const headerText = source.section ? str(source.section.props['header']) : null;
1865
2056
  const footerText = source.section ? str(source.section.props['footer']) : null;
1866
2057
  if (headerText !== null && headerText.length > 0) {
@@ -1871,6 +2062,12 @@ export class LayoutPass {
1871
2062
  const cardTop = y;
1872
2063
  const rows: ListRowPlan[] = [];
1873
2064
  for (const rowNode of source.rows) {
2065
+ if (rowNode.kind === 'lazyspacer') {
2066
+ // Lazy rows not built yet: a row of no height, without a cell.
2067
+ const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
2068
+ rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
2069
+ continue;
2070
+ }
1874
2071
  // A picker already reports its row height (44 for a menu, 44 per
1875
2072
  // option inline); other rows get the 11px vertical padding. Phase 6
1876
2073
  // `listRowInsets` replaces all four insets; a `badge` reserves its
@@ -1891,7 +2088,7 @@ export class LayoutPass {
1891
2088
  const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
1892
2089
  const proposal: Proposal = { width: rowContentWidth, height: null };
1893
2090
  const contentSize = this.measure(rowNode, rowCtx, proposal);
1894
- const rowHeight = Math.max(LIST_ROW_MIN_HEIGHT, contentSize.height + insets.top + insets.bottom);
2091
+ const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
1895
2092
  const row: Rect = { x: cardX, y, width: cardWidth, height: rowHeight };
1896
2093
  rows.push({
1897
2094
  node: rowNode,
@@ -1912,6 +2109,7 @@ export class LayoutPass {
1912
2109
  }
1913
2110
  : null,
1914
2111
  edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
2112
+ separatorHidden: hints.separatorHidden,
1915
2113
  });
1916
2114
  y += rowHeight;
1917
2115
  }
@@ -1922,11 +2120,16 @@ export class LayoutPass {
1922
2120
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
1923
2121
  y += size.height;
1924
2122
  }
2123
+ let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
2124
+ if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
2125
+ else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
1925
2126
  groups.push({
1926
2127
  section: source.section,
1927
2128
  frame: { x: cardX, y: top, width: cardWidth, height: y - top },
1928
2129
  header,
1929
2130
  footer,
2131
+ headerView,
2132
+ footerView,
1930
2133
  card,
1931
2134
  rows,
1932
2135
  });
@@ -1944,15 +2147,21 @@ export class LayoutPass {
1944
2147
  // Place
1945
2148
  // -------------------------------------------------------------------------
1946
2149
 
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);
2150
+ /**
2151
+ * Phase 9: placed before, same proposal, same origin, nothing in the
2152
+ * subtree invalidated since: the last run's output still holds.
2153
+ */
2154
+ private placedAlready(node: LayoutNode, p: Proposal, x: number, y: number): boolean {
1951
2155
  const last = this.placed.get(node);
1952
- if (last && last.key === key && last.x === x && last.y === y) {
2156
+ return last !== undefined && last.key === proposalKey(p) && last.x === x && last.y === y;
2157
+ }
2158
+
2159
+ place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2160
+ if (this.placedAlready(node, p, x, y)) {
1953
2161
  this.stats.skipped += 1;
1954
2162
  return;
1955
2163
  }
2164
+ const key = proposalKey(p);
1956
2165
  this.placed.set(node, { key, x, y });
1957
2166
  this.stats.placed += 1;
1958
2167
  if (node.id >= 0) {
@@ -1979,11 +2188,17 @@ export class LayoutPass {
1979
2188
  case 'image':
1980
2189
  if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
1981
2190
  return;
2191
+ case 'textfield':
2192
+ // A vertical field draws its text in the context font (one-line fields in the body font).
2193
+ if (node.props['axis'] === 'vertical' && node.id >= 0) this.fonts.set(node.id, ctx.font);
2194
+ return;
1982
2195
  case 'spacer':
2196
+ case 'lazyspacer':
1983
2197
  case 'divider':
1984
- case 'textfield':
1985
2198
  case 'color':
1986
2199
  case 'shape':
2200
+ case 'canvas':
2201
+ case 'video':
1987
2202
  return;
1988
2203
  case 'vstack':
1989
2204
  case 'hstack': {
@@ -1998,7 +2213,7 @@ export class LayoutPass {
1998
2213
  const alignment = str(node.props['alignment']) ?? 'center';
1999
2214
  for (const child of node.children) {
2000
2215
  const childSize = this.measure(child, cctx, p);
2001
- const { dx, dy } = alignIn(size, childSize, alignment);
2216
+ const { dx, dy } = alignChild(size, childSize, alignment, child);
2002
2217
  this.place(child, cctx, p, x + dx, y + dy);
2003
2218
  }
2004
2219
  return;
@@ -2038,7 +2253,7 @@ export class LayoutPass {
2038
2253
  );
2039
2254
  return;
2040
2255
  }
2041
- const { dx, dy } = alignIn(box, plan.childSize, plan.alignment);
2256
+ const { dx, dy } = alignChild(box, plan.childSize, plan.alignment, child);
2042
2257
  this.place(child, cctx, plan.childProposal, x + box.x + dx, y + box.y + dy);
2043
2258
  }
2044
2259
  return;
@@ -2208,7 +2423,7 @@ export class LayoutPass {
2208
2423
  const proposal: Proposal = { width: size.width, height: size.height };
2209
2424
  const decorationSize = this.measure(decoration, cctx, proposal);
2210
2425
  const alignment = str(node.props['alignment']) ?? 'center';
2211
- const { dx, dy } = alignIn(size, decorationSize, alignment);
2426
+ const { dx, dy } = alignChild(size, decorationSize, alignment, decoration);
2212
2427
  this.place(decoration, cctx, proposal, x + dx, y + dy);
2213
2428
  }
2214
2429
  return;
@@ -2289,6 +2504,9 @@ export class LayoutPass {
2289
2504
  }
2290
2505
  this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
2291
2506
  this.setChrome(group.section, chrome);
2507
+ for (const view of [group.headerView, group.footerView]) {
2508
+ if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
2509
+ }
2292
2510
  if (group.section.id >= 0) {
2293
2511
  this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
2294
2512
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
@@ -2297,6 +2515,10 @@ export class LayoutPass {
2297
2515
  for (const row of group.rows) {
2298
2516
  // Set the row chrome before placing so a navlink can merge its own keys.
2299
2517
  const chrome: Record<string, Rect> = { row: offset(row.row) };
2518
+ // `.listRowSeparator(.hidden)`: flags the renderer turns into data
2519
+ // attributes on the row element (its separator line is CSS).
2520
+ if (row.separatorHidden?.top) chrome['separatorTopHidden'] = offset(row.row);
2521
+ if (row.separatorHidden?.bottom) chrome['separatorBottomHidden'] = offset(row.row);
2300
2522
  if (row.badge) {
2301
2523
  chrome['badge'] = offset(row.badge.rect);
2302
2524
  if (row.node.id >= 0) this.chromeText.set(row.node.id, { badge: row.badge.layout });
@@ -2307,8 +2529,15 @@ export class LayoutPass {
2307
2529
  if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
2308
2530
  else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
2309
2531
  }
2310
- this.setChrome(row.node, chrome);
2311
- this.place(row.node, rowCtx, row.proposal, x + row.content.x, y + row.content.y);
2532
+ const rowX = x + row.content.x;
2533
+ const rowY = y + row.content.y;
2534
+ // A row that is skipped keeps the chrome it set itself when it was
2535
+ // placed (a link's chevron and label); only the list's keys are new.
2536
+ const own = this.placedAlready(row.node, row.proposal, rowX, rowY) && row.node.id >= 0
2537
+ ? this.chrome.get(row.node.id)
2538
+ : undefined;
2539
+ this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
2540
+ this.place(row.node, rowCtx, row.proposal, rowX, rowY);
2312
2541
  }
2313
2542
  }
2314
2543
  }
@@ -2461,13 +2690,17 @@ export class LayoutPass {
2461
2690
  }
2462
2691
 
2463
2692
  // 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.
2693
+ // whatever the back button and the bar items occupy on either side. Its
2694
+ // width is rounded up to whole pixels: snapping floors both edges, which
2695
+ // takes up to a pixel off a fractional width at a fractional origin, and
2696
+ // the title truncates with an ellipsis ("Animati…") when its box is any
2697
+ // narrower than its text.
2465
2698
  if (!large && !principal) {
2466
2699
  const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_ITEM_SPACING - x : 0);
2467
2700
  const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_ITEM_SPACING) : 0;
2468
2701
  const side = Math.max(leftUsed, rightUsed) + 8;
2469
2702
  const available = Math.max(0, size.width - 2 * side);
2470
- const titleWidth = Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56, available);
2703
+ const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
2471
2704
  chrome['title'] = { x: x + (size.width - titleWidth) / 2, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
2472
2705
  }
2473
2706
  this.setChrome(node, chrome);
@@ -3389,7 +3622,9 @@ export class LayoutPass {
3389
3622
  /**
3390
3623
  * A linear gauge: width = proposal (100 when none), a 4px bar below an
3391
3624
  * optional label row (label leading, current value label trailing, both in
3392
- * the `caption` text style) with a 4px gap. Circular styles render linear.
3625
+ * the `caption` text style) with a 4px gap. A circular gauge is a 58-point
3626
+ * square, whatever the proposal, with the current value label (else the
3627
+ * label) centered in the ring, proposed the square inside it.
3393
3628
  */
3394
3629
  private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
3395
3630
  return this.plan(node, p, () => {
@@ -3399,6 +3634,13 @@ export class LayoutPass {
3399
3634
  let index = 0;
3400
3635
  const label = node.props['hasLabel'] === true ? (node.children[index++] ?? null) : null;
3401
3636
  const current = node.props['hasCurrentValueLabel'] === true ? (node.children[index++] ?? null) : null;
3637
+ const style = str(node.props['style']);
3638
+ if (style === 'accessoryCircular' || style === 'accessoryCircularCapacity') {
3639
+ const center = current ?? label;
3640
+ const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
3641
+ const centerSize = center ? this.measure(center, labelCtx, { width: inner, height: inner }) : ZERO;
3642
+ return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
3643
+ }
3402
3644
  const proposal: Proposal = { width, height: null };
3403
3645
  const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
3404
3646
  const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
@@ -3410,6 +3652,18 @@ export class LayoutPass {
3410
3652
 
3411
3653
  private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
3412
3654
  const plan = this.gaugePlan(node, ctx, p);
3655
+ if (plan.ring) {
3656
+ const { center, centerSize } = plan.ring;
3657
+ const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
3658
+ if (center) {
3659
+ const { dx, dy } = alignIn(size, centerSize, 'center');
3660
+ this.place(center, plan.labelCtx, { width: inner, height: inner }, x + dx, y + dy);
3661
+ }
3662
+ // The other label is not shown (SwiftUI shows one in the middle).
3663
+ for (const other of [plan.label, plan.current]) if (other && other !== center) this.hide(other, node);
3664
+ this.setChrome(node, { ring: { x, y, width: size.width, height: size.height } });
3665
+ return;
3666
+ }
3413
3667
  const proposal: Proposal = { width: size.width, height: null };
3414
3668
  if (plan.label) {
3415
3669
  this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
@@ -3464,13 +3718,13 @@ export class LayoutPass {
3464
3718
  const axisInput = (axis: unknown, fallback: string, views: ChartItemPlan[], key: 'x0' | 'y0'): ChartAxisInput | null => {
3465
3719
  const a = obj(axis);
3466
3720
  if (!a) return null;
3467
- const labels: { at: number; width: number }[] = [];
3721
+ const labels: ChartAxisInput['labels'] = [];
3468
3722
  const rawLabels = Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
3469
3723
  for (const raw of rawLabels) {
3470
3724
  const label = obj(raw);
3471
3725
  if (!label) continue;
3472
3726
  const text = str(label['text']) ?? '';
3473
- labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text) });
3727
+ labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text), leading: label['anchor'] === 'leading' });
3474
3728
  }
3475
3729
  return {
3476
3730
  position: (str(a['position']) ?? fallback) as ChartAxisInput['position'],
@@ -61,6 +61,8 @@ export type {
61
61
  Size,
62
62
  TextAlignment,
63
63
  TextLayout,
64
+ TextFragment,
65
+ RichTextLayout,
64
66
  TextMeasurer,
65
67
  } from './types';
66
68
  export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
@@ -88,4 +90,4 @@ export {
88
90
  snapSliderValue,
89
91
  timeInputValue,
90
92
  } from './controls';
91
- export { wrapText, ELLIPSIS, type WrappedText } from './text';
93
+ export { wrapText, wrapRuns, ELLIPSIS, type WrappedText, type WrappedRuns, type TextRunInput } from './text';