@swiftbrowser/web 0.5.6 → 0.6.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.
@@ -158,8 +158,6 @@ const NAV_BAR_HEIGHT = 44;
158
158
  const NAV_BAR_INLINE_BOTTOM = 10;
159
159
  // iOS 26: the large title's row is 62 (52 before).
160
160
  const LARGE_TITLE_HEIGHT = 62;
161
- /** Chevron glyph plus its gap, in px. */
162
- const SHEET_TOP_GAP = 10;
163
161
  const SHEET_GRABBER_ROW = 20;
164
162
  const SHEET_GRABBER: Size = { width: 36, height: 5 };
165
163
  const SHEET_GRABBER_TOP = 8;
@@ -233,8 +231,15 @@ const PROGRESS_VALUE_GAP = 6;
233
231
  const GAUGE_DEFAULT_WIDTH = 100;
234
232
  /** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
235
233
  const GAUGE_RING_SIZE = 58;
236
- /** The square inside the ring its label is proposed, as a fraction of the side. */
237
- const GAUGE_RING_INNER = 0.62;
234
+ /**
235
+ * iOS 26 (Examples/Fidelity's `spacing` screen): what a circular gauge
236
+ * proposes the view in its middle, in a 24pt semibold font (Backyard Birds'
237
+ * water drop is a 24pt symbol there); an `accessoryCircular` gauge's label
238
+ * sits in the gap at the bottom of its arc, in the box below.
239
+ */
240
+ const GAUGE_RING_CENTER: Size = { width: 40.67, height: 29 };
241
+ const GAUGE_RING_FONT = { size: 24, weight: 'semibold' };
242
+ const GAUGE_ARC_LABEL: Size = { width: 36, height: 17.67 };
238
243
  const GRID_ITEM_DEFAULT_MIN = 10;
239
244
  // Phase 6 (docs/phase6/chrome.md).
240
245
  const PICKER_INLINE_ROW_HEIGHT = 44;
@@ -295,6 +300,14 @@ const SEARCH_BOTTOM_HEIGHT = 48;
295
300
  const SEARCH_BOTTOM_MARGIN = 28;
296
301
  const SEARCH_BOTTOM_MARGIN_INSET = 8;
297
302
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
303
+ // iOS 26 (measured in the Simulator, Backyard Birds): in a tab view the
304
+ // search field sits under the title instead, a 44-tall capsule 16 from the
305
+ // sides, 1 below the title row (y 166 under a large title), the content 15
306
+ // below it: a 60 row.
307
+ const SEARCH_TOP_HEIGHT = 44;
308
+ const SEARCH_TOP_ABOVE = 1;
309
+ const SEARCH_TOP_BELOW = 15;
310
+ const SEARCH_TOP_ROW = SEARCH_TOP_ABOVE + SEARCH_TOP_HEIGHT + SEARCH_TOP_BELOW;
298
311
  const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
299
312
  // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
300
313
  const NAVSPLIT_SIDEBAR_WIDTH = 320;
@@ -459,6 +472,22 @@ function layoutPriorityOf(node: LayoutNode): number {
459
472
  return 0;
460
473
  }
461
474
 
475
+ /**
476
+ * A flexible frame's length on one axis, as SwiftUI resolves it: a minimum
477
+ * raises the child's length (up to the proposal) to at least `min`, and a
478
+ * maximum takes the proposal (or the child's length, if longer) up to `max`.
479
+ * So `.frame(minWidth: 25)` around a narrow badge is 25 wide, not the width
480
+ * it was offered, while `.frame(maxWidth: .infinity)` fills the proposal. A
481
+ * nil proposal is the child's own length.
482
+ */
483
+ function flexibleFrameLength(proposed: number | null, child: number, min: number | null, max: number | null): number {
484
+ const offered = proposed ?? child;
485
+ let length = child;
486
+ if (min !== null) length = Math.max(min, Math.min(length, offered));
487
+ if (max !== null) length = Math.min(max, Math.max(length, offered));
488
+ return length;
489
+ }
490
+
462
491
  /** The element under a chain of `styled` wrappers. */
463
492
  function unwrapStyled(node: LayoutNode): LayoutNode {
464
493
  let current = node;
@@ -468,6 +497,31 @@ function unwrapStyled(node: LayoutNode): LayoutNode {
468
497
  return current;
469
498
  }
470
499
 
500
+ /** Root kinds laid out over the whole screen: their bars reach into the safe areas. */
501
+ const SCREEN_ROOT_KINDS = new Set(['navstack', 'tabview', 'navsplit', 'inspector', 'alert']);
502
+
503
+ /**
504
+ * A root that fills the screen: one of `SCREEN_ROOT_KINDS`, also when an
505
+ * app's root modifiers wrap it in modifiers that leave its size alone or in
506
+ * a one-child `ZStack` (Backyard Birds' shop modifier wraps its `TabView`
507
+ * so); on iOS its bars reach the screen's edges all the same.
508
+ */
509
+ function fillsScreen(node: LayoutNode): boolean {
510
+ let current = node;
511
+ for (;;) {
512
+ if (SCREEN_ROOT_KINDS.has(current.kind)) return true;
513
+ if (current.children.length !== 1) return false;
514
+ if (current.kind === 'styled') {
515
+ const style = styleOf(current);
516
+ const sized = ['frame', 'containerRelativeFrame', 'padding', 'aspectRatio', 'fixedSize'].some((key) => style[key] !== undefined && style[key] !== null);
517
+ if (sized) return false;
518
+ } else if (current.kind !== 'zstack') {
519
+ return false;
520
+ }
521
+ current = current.children[0] as LayoutNode;
522
+ }
523
+ }
524
+
471
525
  /** A `{x, y}` pair of finite numbers. */
472
526
  function readPoint(value: unknown): { x: number; y: number } | null {
473
527
  const o = obj(value);
@@ -512,13 +566,13 @@ function readFlexSize(value: unknown): Size | null {
512
566
  }
513
567
 
514
568
  /** A custom layout's `{min, ideal, max}` sizes from its props. */
515
- function readLayoutSizes(value: unknown): { min: Size; ideal: Size; max: Size } | null {
569
+ function readLayoutSizes(value: unknown): { min: Size; ideal: Size; max: Size; atWidth: Size | null } | null {
516
570
  const o = obj(value);
517
571
  if (!o) return null;
518
572
  const min = readFlexSize(o['min']);
519
573
  const ideal = readFlexSize(o['ideal']);
520
574
  const max = readFlexSize(o['max']);
521
- return min && ideal && max ? { min, ideal, max } : null;
575
+ return min && ideal && max ? { min, ideal, max, atWidth: readFlexSize(o['atWidth']) } : null;
522
576
  }
523
577
 
524
578
  /** A `{width, height}` pair of finite, non-negative numbers. */
@@ -690,6 +744,28 @@ function rowHintsOf(node: LayoutNode): RowHints {
690
744
  return hints;
691
745
  }
692
746
 
747
+ /**
748
+ * `.listRowBackground(_:)`: the ShapeStyle a list row's cell is painted with
749
+ * instead of the card's (`Color.clear` lets the list's background show), read
750
+ * through the row's `styled` chain and wrappers as `rowHintsOf` reads the
751
+ * other hints; undefined when the row has none.
752
+ */
753
+ export function listRowBackgroundOf(node: LayoutNode): unknown {
754
+ let current: LayoutNode | undefined = node;
755
+ while (current) {
756
+ if (current.kind === 'styled') {
757
+ const background = styleOf(current)['listRowBackground'];
758
+ if (background !== undefined && background !== null) return background;
759
+ current = current.children.length === 1 ? current.children[0] : undefined;
760
+ } else if ((current.kind === 'navlink' && current.children.length === 1) || (ROW_WRAPPERS.has(current.kind) && current.children.length >= 1)) {
761
+ current = current.children[0];
762
+ } else {
763
+ return undefined;
764
+ }
765
+ }
766
+ return undefined;
767
+ }
768
+
693
769
  /** Phase 8 wrappers whose first child is the row content they stand for. */
694
770
  const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture', 'draggable', 'dropdestination']);
695
771
 
@@ -896,6 +972,8 @@ interface StackPlan {
896
972
  size: Size;
897
973
  childSizes: Size[];
898
974
  childProposals: Proposal[];
975
+ /** The space after each child but the last (see `stackGaps`). */
976
+ gaps: number[];
899
977
  }
900
978
 
901
979
  interface StyledPlan {
@@ -1064,7 +1142,7 @@ interface GaugePlan {
1064
1142
  /** A ProgressView's label context (the gauge's own font, not the caption). */
1065
1143
  labelOwnCtx?: Context;
1066
1144
  /** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
1067
- ring?: { center: LayoutNode | null; centerSize: Size };
1145
+ ring?: { center: LayoutNode | null; centerSize: Size; ctx: Context; arcLabel: LayoutNode | null };
1068
1146
  }
1069
1147
 
1070
1148
  /** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
@@ -1122,7 +1200,8 @@ export class LayoutPass {
1122
1200
  * `nested`: a scroll view deeper in the screen's content that touches the
1123
1201
  * bar (`scrollEdgeGrowth`), rather than the screen's own content.
1124
1202
  */
1125
- private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
1203
+ /** A scroll view under its screen's bar: the bar, the large title that scrolls with it and a search row under the title (`search`). */
1204
+ private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; search?: number; nested?: boolean }>();
1126
1205
 
1127
1206
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
1128
1207
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -1167,13 +1246,7 @@ export class LayoutPass {
1167
1246
  const { screen, safeArea } = this.env;
1168
1247
  const ctx = this.rootContext;
1169
1248
  for (const root of roots) {
1170
- if (
1171
- root.kind === 'navstack' ||
1172
- root.kind === 'tabview' ||
1173
- root.kind === 'navsplit' ||
1174
- root.kind === 'inspector' ||
1175
- root.kind === 'alert'
1176
- ) {
1249
+ if (fillsScreen(root)) {
1177
1250
  // These ignore the safe areas: the tab bar and navigation bars
1178
1251
  // extend into them and the content is inset instead (a `navsplit`
1179
1252
  // hands the horizontal insets to its columns, see `placeNavSplit`;
@@ -1787,14 +1860,16 @@ export class LayoutPass {
1787
1860
  const detents = node.props['detents'];
1788
1861
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1789
1862
  const { screen, safeArea } = this.env;
1790
- if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
1863
+ // iOS 26: a `large` sheet's top is the safe area's (measured in the
1864
+ // iOS 26 simulator); earlier versions left 10 more above it.
1865
+ const large = screen.height - safeArea.top;
1866
+ if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, large);
1791
1867
  // `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
1792
- const large = screen.height - safeArea.top - SHEET_TOP_GAP;
1793
1868
  if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
1794
1869
  if (first?.startsWith('fraction:')) return Math.min(large, screen.height * (Number(first.slice(9)) || 1));
1795
1870
  // Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
1796
1871
  if (first === 'full') return screen.height;
1797
- return screen.height - safeArea.top - SHEET_TOP_GAP;
1872
+ return large;
1798
1873
  }
1799
1874
 
1800
1875
  /** Phase 8: a sheet whose first detent is `full` has no grabber row. */
@@ -1961,11 +2036,69 @@ export class LayoutPass {
1961
2036
 
1962
2037
  private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
1963
2038
  return this.plan(node, p, () => {
1964
- const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
1965
- return this.layoutStack(node.children, this.childContext(node, ctx), p, axis, spacing);
2039
+ const cctx = this.childContext(node, ctx);
2040
+ return this.layoutStack(node.children, cctx, p, axis, this.stackGaps(node, cctx, axis));
1966
2041
  });
1967
2042
  }
1968
2043
 
2044
+ /**
2045
+ * The space between a stack's children: its `spacing`, else the system's.
2046
+ * A horizontal stack's default is 8; in a vertical one iOS 26 spaces text
2047
+ * by its font (measured in the simulator, Examples/Fidelity's `spacing`
2048
+ * screen; `textEdge` finds text at an edge). Between two texts the gap is
2049
+ * the lower font's leading less its line height, plus a pixel (about 2 for
2050
+ * the text styles); a view above text sits 0.409em − 1.24 above its line
2051
+ * (caption 3.67, body 5.71, largeTitle 12.67); text above a view is
2052
+ * 0.6em + 1.13 above it (caption 8.33, body 11.33); other views are 8 apart.
2053
+ */
2054
+ private stackGaps(node: LayoutNode, cctx: Context, axis: Axis): number[] {
2055
+ const n = node.children.length;
2056
+ if (n < 2) return [];
2057
+ const spacing = num(node.props['spacing']);
2058
+ if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2059
+ const gaps: number[] = [];
2060
+ for (let i = 0; i < n - 1; i++) {
2061
+ const above = this.textEdge(node.children[i] as LayoutNode, cctx, 'bottom');
2062
+ const below = this.textEdge(node.children[i + 1] as LayoutNode, cctx, 'top');
2063
+ let gap = DEFAULT_SPACING;
2064
+ if (above && below) gap = below.leading - below.lineHeight + 1 / 3;
2065
+ else if (below) gap = 0.409 * below.size - 1.24;
2066
+ else if (above) gap = 0.6 * above.size + 1.13;
2067
+ gaps.push(Math.max(0, Math.round(gap * 3) / 3));
2068
+ }
2069
+ return gaps;
2070
+ }
2071
+
2072
+ /**
2073
+ * The font of the text at a view's top or bottom edge, for the default
2074
+ * spacing of a vertical stack: a `text`, through modifiers that leave its
2075
+ * box alone (not a frame or padding), decorations (their content) and
2076
+ * vertical stacks (their first or last child). Null for any other view.
2077
+ */
2078
+ private textEdge(node: LayoutNode, ctx: Context, edge: 'top' | 'bottom', depth = 0): ResolvedFont | null {
2079
+ if (depth > 32) return null;
2080
+ switch (node.kind) {
2081
+ case 'text':
2082
+ return ctx.font;
2083
+ case 'styled': {
2084
+ const style = styleOf(node);
2085
+ const boxed = ['frame', 'padding', 'containerRelativeFrame', 'aspectRatio', 'position', 'offset'].some((key) => style[key] !== undefined && style[key] !== null);
2086
+ const child = node.children[0];
2087
+ return !boxed && node.children.length === 1 && child ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2088
+ }
2089
+ case 'decorated': {
2090
+ const content = node.children[0];
2091
+ return content ? this.textEdge(content, this.childContext(node, ctx), edge, depth + 1) : null;
2092
+ }
2093
+ case 'vstack': {
2094
+ const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
2095
+ return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2096
+ }
2097
+ default:
2098
+ return null;
2099
+ }
2100
+ }
2101
+
1969
2102
  /**
1970
2103
  * SwiftUI's stack algorithm. With an axis proposal, every child's minimum
1971
2104
  * and maximum along the axis (proposals 0 and Infinity, cross fixed) give
@@ -2007,13 +2140,13 @@ export class LayoutPass {
2007
2140
  cctx: Context,
2008
2141
  p: Proposal,
2009
2142
  axis: Axis,
2010
- spacing: number,
2143
+ gaps: number[],
2011
2144
  ): StackPlan {
2012
2145
  const n = children.length;
2013
- if (n === 0) return { size: ZERO, childSizes: [], childProposals: [] };
2146
+ if (n === 0) return { size: ZERO, childSizes: [], childProposals: [], gaps };
2014
2147
  const along = axis === 'horizontal' ? p.width : p.height;
2015
2148
  const cross = axis === 'horizontal' ? p.height : p.width;
2016
- const totalSpacing = spacing * (n - 1);
2149
+ const totalSpacing = gaps.reduce((sum, gap) => sum + gap, 0);
2017
2150
  const childSizes: Size[] = new Array<Size>(n);
2018
2151
  const childProposals: Proposal[] = new Array<Proposal>(n);
2019
2152
 
@@ -2110,7 +2243,7 @@ export class LayoutPass {
2110
2243
  }
2111
2244
  const size: Size =
2112
2245
  axis === 'horizontal' ? { width: total, height: crossSize } : { width: crossSize, height: total };
2113
- return { size, childSizes, childProposals };
2246
+ return { size, childSizes, childProposals, gaps };
2114
2247
  }
2115
2248
 
2116
2249
  private placeStack(
@@ -2119,7 +2252,6 @@ export class LayoutPass {
2119
2252
  plan: StackPlan,
2120
2253
  axis: Axis,
2121
2254
  alignment: string,
2122
- spacing: number,
2123
2255
  x: number,
2124
2256
  y: number,
2125
2257
  size: Size,
@@ -2150,7 +2282,7 @@ export class LayoutPass {
2150
2282
  : line - (guides[i] ?? crossOf(childSize, axis) * crossAnchor);
2151
2283
  if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
2152
2284
  else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
2153
- cursor += axisOf(childSize, axis) + spacing;
2285
+ cursor += axisOf(childSize, axis) + (plan.gaps[i] ?? 0);
2154
2286
  }
2155
2287
  }
2156
2288
 
@@ -2261,10 +2393,10 @@ export class LayoutPass {
2261
2393
  if (frame) {
2262
2394
  if (frame.width !== null) width = frame.width;
2263
2395
  else if (frame.minWidth !== null || frame.maxWidth !== null)
2264
- width = clamp(p.width === null ? padded.width : p.width, frame.minWidth, frame.maxWidth);
2396
+ width = flexibleFrameLength(p.width, padded.width, frame.minWidth, frame.maxWidth);
2265
2397
  if (frame.height !== null) height = frame.height;
2266
2398
  else if (frame.minHeight !== null || frame.maxHeight !== null)
2267
- height = clamp(p.height === null ? padded.height : p.height, frame.minHeight, frame.maxHeight);
2399
+ height = flexibleFrameLength(p.height, padded.height, frame.minHeight, frame.maxHeight);
2268
2400
  // A flexible frame proposes its own resolved size to the child (so
2269
2401
  // `Rectangle().frame(minHeight: 100)` fills 100, as in SwiftUI). Only
2270
2402
  // when a nil-proposed axis was clamped up to a minimum does the child
@@ -2656,9 +2788,8 @@ export class LayoutPass {
2656
2788
  }
2657
2789
  const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
2658
2790
  const plan = this.stackPlan(node, ctx, p, axis);
2659
- const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
2660
2791
  const alignment = str(node.props['alignment']) ?? 'center';
2661
- this.placeStack(node.children, cctx, plan, axis, alignment, spacing, x, y, size);
2792
+ this.placeStack(node.children, cctx, plan, axis, alignment, x, y, size);
2662
2793
  return;
2663
2794
  }
2664
2795
  case 'zstack': {
@@ -2799,12 +2930,25 @@ export class LayoutPass {
2799
2930
  const inset = axes.vertical ? this.scrollInsets.get(node) : undefined;
2800
2931
  const content: Rect = {
2801
2932
  x: x + dx,
2802
- y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
2933
+ y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) + (inset.search ?? 0) : 0),
2803
2934
  width: plan.contentSize.width,
2804
2935
  height: plan.contentSize.height,
2805
2936
  };
2806
2937
  const covered = this.coveredBottom(ctx, x, y, size);
2807
2938
  const scrollChrome: Record<string, Rect> = { content };
2939
+ // `.scrollClipDisabled()`: a horizontal scroll view draws its content
2940
+ // past its sides, as far as the screen's (an ancestor that clips, a
2941
+ // sheet's card, still does). Its frame reaches them; the content stays
2942
+ // where it is and `viewport` keeps the rect the scroll view laid out in,
2943
+ // which the page scrolls and snaps against.
2944
+ if (node.props['clipDisabled'] === true && axes.horizontal && !axes.vertical) {
2945
+ const left = Math.max(0, x);
2946
+ const right = Math.max(0, this.env.screen.width - (x + size.width));
2947
+ if (left > 0 || right > 0) {
2948
+ scrollChrome['viewport'] = { x, y, width: size.width, height: size.height };
2949
+ this.setFrame(node, x - left, y, { width: size.width + left + right, height: size.height });
2950
+ }
2951
+ }
2808
2952
  if (covered) scrollChrome['bottomInset'] = covered;
2809
2953
  if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
2810
2954
  if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
@@ -2892,7 +3036,10 @@ export class LayoutPass {
2892
3036
  const proposal: Proposal = { width: size.width, height: size.height };
2893
3037
  const decorationSize = this.measure(decoration, cctx, proposal);
2894
3038
  const alignment = str(node.props['alignment']) ?? 'center';
2895
- const { dx, dy } = alignChild(size, decorationSize, alignment, decoration);
3039
+ // A decoration in an `if` aligns by its edges, its guides unused (as on iOS 26).
3040
+ const { dx, dy } = node.props['conditional'] === true
3041
+ ? alignIn(size, decorationSize, alignment)
3042
+ : alignChild(size, decorationSize, alignment, decoration);
2896
3043
  this.place(decoration, cctx, proposal, x + dx, y + dy);
2897
3044
  }
2898
3045
  return;
@@ -3009,7 +3156,7 @@ export class LayoutPass {
3009
3156
  if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
3010
3157
  if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
3011
3158
  if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
3012
- const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
3159
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
3013
3160
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
3014
3161
  y += inset;
3015
3162
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
@@ -3117,8 +3264,12 @@ export class LayoutPass {
3117
3264
  const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
3118
3265
  ? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
3119
3266
  : 0;
3120
- const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3121
- const contentTop = barHeight + largeHeight;
3267
+ // In a tab view (a tab bar covers the bottom) the field goes under the
3268
+ // title, as iOS 26 places it; elsewhere it floats at the bottom.
3269
+ const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
3270
+ const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
3271
+ const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3272
+ const contentTop = barHeight + largeHeight + searchRow;
3122
3273
  const content: Rect = {
3123
3274
  x,
3124
3275
  y: y + contentTop,
@@ -3135,10 +3286,13 @@ export class LayoutPass {
3135
3286
  if (parts.search) {
3136
3287
  chrome['searchField'] = {
3137
3288
  x: x + SEARCH_FIELD_MARGIN_H,
3138
- y: y + size.height - searchBottom,
3289
+ y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchBottom,
3139
3290
  width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
3140
- height: SEARCH_BOTTOM_HEIGHT,
3291
+ height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
3141
3292
  };
3293
+ // The page draws a field under the title as the bar's (a gray capsule)
3294
+ // and moves it with the content when that scrolls under the bar.
3295
+ if (searchOnTop) chrome['searchTop'] = chrome['searchField'];
3142
3296
  }
3143
3297
  if (bottomBarHeight > 0) {
3144
3298
  chrome['bottomBar'] = { x, y: y + size.height - bottomBarHeight, width: size.width, height: bottomBarHeight };
@@ -3400,6 +3554,7 @@ export class LayoutPass {
3400
3554
  this.scrollInsets.set(scroller, {
3401
3555
  bar: barHeight,
3402
3556
  largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
3557
+ search: searchRow,
3403
3558
  });
3404
3559
  this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
3405
3560
  return;
@@ -4189,6 +4344,10 @@ export class LayoutPass {
4189
4344
  // answered size made an enclosing custom layout see this one as rigid
4190
4345
  // at a stale size, and the two then traded answers forever.
4191
4346
  const sizes = readLayoutSizes(node.props['sizes']);
4347
+ // At the answered width with no height (a grid or stack measuring the
4348
+ // row it places in): Swift's answer for exactly that.
4349
+ const answered = readProposal(node.props['proposal']);
4350
+ if (sizes?.atWidth && p.height === null && p.width !== null && answered?.width != null && Math.abs(p.width - answered.width) < 0.01) return sizes.atWidth;
4192
4351
  const axis = (proposed: number | null, answered: number, dim: 'width' | 'height'): number => {
4193
4352
  if (sizes) {
4194
4353
  if (proposed === null) return sizes.ideal[dim];
@@ -4424,7 +4583,8 @@ export class LayoutPass {
4424
4583
  * and the gaps between items) equally, flexible ones clamped to [min, max];
4425
4584
  * an adaptive item then fits as many columns as its share allows,
4426
4585
  * n = max(1, floor((W' + s) / (min + s))), each (W' − (n−1)·s) / n wide,
4427
- * clamped. With no width the flexible columns take the cells' largest ideal
4586
+ * at most its maximum (its minimum only counts the columns: one column of
4587
+ * a minimum wider than the share is the share, as in SwiftUI). With no width the flexible columns take the cells' largest ideal
4428
4588
  * width and an adaptive item is one column. Cells fill rows left to right.
4429
4589
  */
4430
4590
  /**
@@ -4480,7 +4640,7 @@ export class LayoutPass {
4480
4640
  const s = item.spacing;
4481
4641
  const unit = item.minimum + s;
4482
4642
  const n = unit > 0 ? Math.max(1, Math.floor((adaptiveShare + s) / unit + EPSILON)) : 1;
4483
- const width = clamp((adaptiveShare - (n - 1) * s) / n, item.minimum, item.maximum);
4643
+ const width = clamp((adaptiveShare - (n - 1) * s) / n, null, item.maximum);
4484
4644
  widths[i] = new Array<number>(n).fill(width);
4485
4645
  });
4486
4646
  }
@@ -4533,9 +4693,14 @@ export class LayoutPass {
4533
4693
  for (const cell of row.cells) {
4534
4694
  const column = plan.columns[cell.column] as LazyColumnPlan;
4535
4695
  const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
4536
- const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
4537
- if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
4538
- else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
4696
+ // Measured without a main-axis size, a cell is placed proposing its
4697
+ // row's (as on iOS: Backyard Birds' cards, a `ZStack` of artwork and
4698
+ // a `VStack` with a `Spacer`, stretch that stack to the artwork's
4699
+ // height, so the header sits at the card's top).
4700
+ const size = this.measure(cell.node, cctx, box);
4701
+ const { dx, dy } = alignIn(box, size, column.alignment ?? gridAlignment);
4702
+ if (horizontal) this.place(cell.node, cctx, box, x + row.y + dx, y + column.x + dy);
4703
+ else this.place(cell.node, cctx, box, x + column.x + dx, y + row.y + dy);
4539
4704
  }
4540
4705
  }
4541
4706
  }
@@ -4549,7 +4714,9 @@ export class LayoutPass {
4549
4714
  * optional label row (label leading, current value label trailing, both in
4550
4715
  * the `caption` text style) with a 4px gap. A circular gauge is a 58-point
4551
4716
  * square, whatever the proposal, with the current value label (else the
4552
- * label) centered in the ring, proposed the square inside it.
4717
+ * label) centered in the ring, proposed `GAUGE_RING_CENTER` in a 24pt
4718
+ * semibold font; an `accessoryCircular` gauge with both labels puts the
4719
+ * label in the gap at the bottom of its arc.
4553
4720
  */
4554
4721
  private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
4555
4722
  return this.plan(node, p, () => {
@@ -4562,9 +4729,15 @@ export class LayoutPass {
4562
4729
  const style = str(node.props['style']);
4563
4730
  if (style === 'accessoryCircular' || style === 'accessoryCircularCapacity') {
4564
4731
  const center = current ?? label;
4565
- const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
4566
- const centerSize = center ? this.measure(center, labelCtx, { width: inner, height: inner }) : ZERO;
4567
- return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
4732
+ const ringCtx: Context = { ...cctx, font: resolveFont(GAUGE_RING_FONT, ctx.font, this.env) };
4733
+ const centerSize = center ? this.measure(center, ringCtx, GAUGE_RING_CENTER) : ZERO;
4734
+ // With both labels an `accessoryCircular` gauge shows the label too, under the value.
4735
+ const arcLabel = style === 'accessoryCircular' && current && label ? label : null;
4736
+ const arcLabelSize = arcLabel ? this.measure(arcLabel, labelCtx, GAUGE_ARC_LABEL) : ZERO;
4737
+ return {
4738
+ size: square(GAUGE_RING_SIZE), label, current, labelSize: arcLabelSize, currentSize: ZERO, labelRow: 0, labelCtx,
4739
+ ring: { center, centerSize, ctx: ringCtx, arcLabel },
4740
+ };
4568
4741
  }
4569
4742
  const proposal: Proposal = { width, height: null };
4570
4743
  if (style === 'progress') {
@@ -4587,14 +4760,18 @@ export class LayoutPass {
4587
4760
  private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
4588
4761
  const plan = this.gaugePlan(node, ctx, p);
4589
4762
  if (plan.ring) {
4590
- const { center, centerSize } = plan.ring;
4591
- const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
4763
+ const { center, centerSize, ctx: ringCtx, arcLabel } = plan.ring;
4592
4764
  if (center) {
4593
4765
  const { dx, dy } = alignIn(size, centerSize, 'center');
4594
- this.place(center, plan.labelCtx, { width: inner, height: inner }, x + dx, y + dy);
4766
+ this.place(center, ringCtx, GAUGE_RING_CENTER, x + dx, y + dy);
4767
+ }
4768
+ if (arcLabel) {
4769
+ // Centered in the arc's gap, on the gauge's bottom edge.
4770
+ const labelSize = plan.labelSize;
4771
+ this.place(arcLabel, plan.labelCtx, GAUGE_ARC_LABEL, x + (size.width - labelSize.width) / 2, y + size.height - labelSize.height);
4595
4772
  }
4596
- // The other label is not shown (SwiftUI shows one in the middle).
4597
- for (const other of [plan.label, plan.current]) if (other && other !== center) this.hide(other, node);
4773
+ // A capacity gauge shows one label, in the middle.
4774
+ for (const other of [plan.label, plan.current]) if (other && other !== center && other !== arcLabel) this.hide(other, node);
4598
4775
  this.setChrome(node, { ring: { x, y, width: size.width, height: size.height } });
4599
4776
  return;
4600
4777
  }
@@ -65,7 +65,7 @@ export type {
65
65
  RichTextLayout,
66
66
  TextMeasurer,
67
67
  } from './types';
68
- export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
68
+ export { layoutTree, LayoutPass, listRowBackgroundOf, type Context as LayoutContext } from './engine';
69
69
  export {
70
70
  defaultFont,
71
71
  resolveFont,