@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.
- package/README.md +39 -7
- package/app/index.html +25 -13
- package/node/dist/node/config.d.ts +24 -3
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +41 -10
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/index.d.ts +9 -4
- package/node/dist/node/index.d.ts.map +1 -1
- package/node/dist/node/index.js +14 -5
- package/node/dist/node/index.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +12 -4
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +14 -9
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/site-layout.d.ts +41 -0
- package/node/dist/plugins/site-layout.d.ts.map +1 -0
- package/node/dist/plugins/site-layout.js +77 -0
- package/node/dist/plugins/site-layout.js.map +1 -0
- package/package.json +1 -1
- package/plugins/app-manifests.ts +28 -9
- package/plugins/site-layout.ts +96 -0
- package/src/animation.ts +86 -3
- package/src/deviceMode.ts +9 -2
- package/src/gaugeRing.ts +2 -2
- package/src/layout/engine.ts +228 -51
- package/src/layout/index.ts +1 -1
- package/src/layoutDom.ts +96 -12
- package/src/main.ts +6 -4
- package/src/press.ts +130 -0
- package/src/protocol.ts +26 -0
- package/src/renderer.ts +57 -14
- package/src/styles.css +184 -29
- package/src/styles.ts +3 -0
- package/src/symbols.ts +3 -1
- package/src/vite-env.d.ts +18 -0
package/src/layout/engine.ts
CHANGED
|
@@ -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
|
-
/**
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1965
|
-
return this.layoutStack(node.children, this.
|
|
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
|
-
|
|
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 =
|
|
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) +
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
3121
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
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
|
|
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
|
|
4566
|
-
const centerSize = center ? this.measure(center,
|
|
4567
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
}
|
package/src/layout/index.ts
CHANGED
|
@@ -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,
|