@swiftbrowser/web 0.1.0 → 0.3.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 +141 -63
- package/app/index.html +21 -10
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +12 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +68 -3
- package/src/landing.ts +2 -2
- package/src/largeTitle.ts +79 -0
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +681 -174
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +66 -6
- package/src/main.ts +127 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +4 -3
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +782 -13
- package/src/renderer.ts +603 -44
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +521 -100
- package/src/styles.ts +61 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +658 -28
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/layout/engine.ts
CHANGED
|
@@ -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,
|
|
@@ -78,11 +79,22 @@ export interface Context {
|
|
|
78
79
|
* from here.
|
|
79
80
|
*/
|
|
80
81
|
safeTop: number;
|
|
82
|
+
/**
|
|
83
|
+
* The inset a sheet gives the bar of the navigation stack that fills it
|
|
84
|
+
* (its grabber row, or iOS 26's room above a sheet's bar), as the status
|
|
85
|
+
* bar does at the root. Only the stack's own screens see it.
|
|
86
|
+
*/
|
|
87
|
+
barInset?: number;
|
|
81
88
|
/**
|
|
82
89
|
* Phase 12: the size of the nearest container (`containerRelativeFrame`):
|
|
83
90
|
* the screen at the root, a scroll view's viewport inside one.
|
|
84
91
|
*/
|
|
85
92
|
container: Size;
|
|
93
|
+
/**
|
|
94
|
+
* The lower bound of the enclosing `.lineLimit(2...4)` range (`lineLimitMin`):
|
|
95
|
+
* a vertical text field is at least this many lines tall. Absent for 1.
|
|
96
|
+
*/
|
|
97
|
+
minLines?: number;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
// Constants from the spec.
|
|
@@ -91,35 +103,71 @@ const DEFAULT_SPACER_MIN = 8;
|
|
|
91
103
|
/** Phase 5: the square an `image` whose catalog name is unknown measures. */
|
|
92
104
|
const UNKNOWN_IMAGE_SIDE = 44;
|
|
93
105
|
const SHAPE_DEFAULT = 10;
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
// iOS 26 metrics (measured in the iOS 26.2 simulator, Examples/Fidelity): a
|
|
107
|
+
// bordered button is 34 tall (50 at `.controlSize(.large)`), a switch 63 × 28.
|
|
108
|
+
const BORDERED_PAD_V = 6;
|
|
109
|
+
const BORDERED_PAD_H = 12;
|
|
96
110
|
const BORDERED_MIN_HEIGHT = 34;
|
|
97
|
-
|
|
98
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A bordered button's padding, least height and (smaller sizes) label text
|
|
113
|
+
* style per `controlSize` (props); `regular` is the constants above.
|
|
114
|
+
*/
|
|
115
|
+
const BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
116
|
+
mini: { padV: 3, padH: 8, minHeight: 24, textStyle: 'footnote' },
|
|
117
|
+
small: { padV: 4, padH: 10, minHeight: 28, textStyle: 'subheadline' },
|
|
118
|
+
large: { padV: 14, padH: 20, minHeight: 50 },
|
|
119
|
+
extraLarge: { padV: 18, padH: 24, minHeight: 58 },
|
|
120
|
+
};
|
|
121
|
+
const SWITCH_WIDTH = 63;
|
|
122
|
+
const SWITCH_HEIGHT = 28;
|
|
99
123
|
const TOGGLE_GAP = 8;
|
|
100
124
|
const TEXTFIELD_DEFAULT_WIDTH = 200;
|
|
101
125
|
const TEXTFIELD_HEIGHT_ROUNDED = 34;
|
|
102
126
|
const TEXTFIELD_HEIGHT_PLAIN = 22;
|
|
127
|
+
/** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
|
|
128
|
+
const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
|
|
103
129
|
const NAV_BAR_HEIGHT = 44;
|
|
104
|
-
|
|
130
|
+
// iOS 26: the large title's row is 62 (52 before).
|
|
131
|
+
const LARGE_TITLE_HEIGHT = 62;
|
|
105
132
|
/** Chevron glyph plus its gap, in px. */
|
|
106
|
-
const BACK_CHEVRON_WIDTH = 24;
|
|
107
133
|
const SHEET_TOP_GAP = 10;
|
|
108
134
|
const SHEET_GRABBER_ROW = 20;
|
|
109
135
|
const SHEET_GRABBER: Size = { width: 36, height: 5 };
|
|
110
136
|
const SHEET_GRABBER_TOP = 8;
|
|
137
|
+
// iOS 26: a sheet at a partial detent (medium, a height or a fraction)
|
|
138
|
+
// floats 8 from the screen's sides and 15 above its bottom; the medium card
|
|
139
|
+
// is 433 tall. Its grabber shows only when it has more than one detent; its
|
|
140
|
+
// content then starts 14 below the card's top.
|
|
141
|
+
const SHEET_FLOAT_INSET_H = 8;
|
|
142
|
+
const SHEET_FLOAT_INSET_BOTTOM = 15;
|
|
143
|
+
const SHEET_MEDIUM_HEIGHT = 433;
|
|
144
|
+
// iOS 26: without a grabber a sheet's content (and a navigation bar's row)
|
|
145
|
+
// starts 16 below its top.
|
|
146
|
+
const SHEET_CONTENT_TOP = 16;
|
|
111
147
|
const LIST_INSET = 16;
|
|
112
148
|
const LIST_CELL_PADDING = 16;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
// iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity): rows are
|
|
150
|
+
// at least 52 tall with 13 above and below their content; a section's header
|
|
151
|
+
// is a sentence-case headline-sized line that starts 9 into the 35 gap
|
|
152
|
+
// between cards and ends 10 above its card.
|
|
153
|
+
const LIST_ROW_VPAD = 13;
|
|
154
|
+
const LIST_ROW_MIN_HEIGHT = 52;
|
|
155
|
+
const LIST_TOP = 18;
|
|
116
156
|
const LIST_BOTTOM = 20;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
157
|
+
const LIST_HEADER_LIFT = 9;
|
|
158
|
+
const LIST_HEADER_GAP = 10;
|
|
159
|
+
const LIST_FOOTER_GAP = 6;
|
|
119
160
|
const LIST_SECTION_GAP = 35;
|
|
120
161
|
const LIST_CHEVRON_WIDTH = 20;
|
|
121
162
|
// Phase 4.
|
|
122
|
-
|
|
163
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
|
|
164
|
+
// and 22 above the bottom edge (8 when the tab view ends above the screen
|
|
165
|
+
// bottom), its items in equal slots inside a 4 inset.
|
|
166
|
+
const TAB_BAR_HEIGHT = 62;
|
|
167
|
+
const TAB_BAR_MARGIN_H = 21;
|
|
168
|
+
const TAB_BAR_MARGIN_BOTTOM = 22;
|
|
169
|
+
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
170
|
+
const TAB_BAR_ITEM_INSET = 4;
|
|
123
171
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
124
172
|
const PICKER_SEGMENT_PAD = 20;
|
|
125
173
|
const PICKER_SEGMENT_FONT_SIZE = 13;
|
|
@@ -131,7 +179,14 @@ const PICKER_MENU_GAP = 12;
|
|
|
131
179
|
// Phase 6.
|
|
132
180
|
const GAUGE_BAR_HEIGHT = 4;
|
|
133
181
|
const GAUGE_LABEL_GAP = 4;
|
|
182
|
+
/** A ProgressView's label to its bar, and its bar to its current value label. */
|
|
183
|
+
const PROGRESS_LABEL_GAP = 8;
|
|
184
|
+
const PROGRESS_VALUE_GAP = 6;
|
|
134
185
|
const GAUGE_DEFAULT_WIDTH = 100;
|
|
186
|
+
/** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
|
|
187
|
+
const GAUGE_RING_SIZE = 58;
|
|
188
|
+
/** The square inside the ring its label is proposed, as a fraction of the side. */
|
|
189
|
+
const GAUGE_RING_INNER = 0.62;
|
|
135
190
|
const GRID_ITEM_DEFAULT_MIN = 10;
|
|
136
191
|
// Phase 6 (docs/phase6/chrome.md).
|
|
137
192
|
const PICKER_INLINE_ROW_HEIGHT = 44;
|
|
@@ -141,12 +196,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
|
|
|
141
196
|
const BADGE_GAP = 8;
|
|
142
197
|
const NAV_ITEM_SPACING = 16;
|
|
143
198
|
const NAV_ITEM_MARGIN = 16;
|
|
199
|
+
// iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
|
|
200
|
+
// and the bar's leading and trailing items sit in glass capsules 44 tall,
|
|
201
|
+
// each item in a slot at least 44 wide (an icon: a circle; text: 12 either
|
|
202
|
+
// side), the groups 8 apart.
|
|
203
|
+
const NAV_GLASS_SIZE = 44;
|
|
204
|
+
const NAV_GLASS_PAD_H = 12;
|
|
205
|
+
const NAV_GROUP_SPACING = 8;
|
|
144
206
|
const BOTTOM_BAR_HEIGHT = 49;
|
|
145
207
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
146
208
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
209
|
+
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
210
|
+
// floating 16 from the sides and 28 above the screen bottom (8 above a view
|
|
211
|
+
// that ends higher); the content stops above it.
|
|
212
|
+
const SEARCH_BOTTOM_HEIGHT = 48;
|
|
213
|
+
const SEARCH_BOTTOM_MARGIN = 28;
|
|
214
|
+
const SEARCH_BOTTOM_MARGIN_INSET = 8;
|
|
150
215
|
const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
151
216
|
const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
|
|
152
217
|
// Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
|
|
@@ -160,10 +225,12 @@ const EPSILON = 1e-6;
|
|
|
160
225
|
const STEPPER_WIDTH = 94;
|
|
161
226
|
const STEPPER_HEIGHT = 32;
|
|
162
227
|
const STEPPER_GAP = 8;
|
|
163
|
-
/** A `slider` is
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
228
|
+
/** A `slider` is 35 high (its label row, when present, sits above). */
|
|
229
|
+
// iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
|
|
230
|
+
const SLIDER_HEIGHT = 35;
|
|
231
|
+
const SLIDER_TRACK_HEIGHT = 6;
|
|
232
|
+
const SLIDER_THUMB = 38;
|
|
233
|
+
const SLIDER_THUMB_HEIGHT = 24;
|
|
167
234
|
const SLIDER_DEFAULT_WIDTH = 200;
|
|
168
235
|
/** Gap between a min / max value label and the track. */
|
|
169
236
|
const SLIDER_LABEL_GAP = 8;
|
|
@@ -174,14 +241,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
|
|
|
174
241
|
const DATEPICKER_GAP = 8;
|
|
175
242
|
/** A `texteditor` fills the proposal; 100 high when proposed nil. */
|
|
176
243
|
const TEXTEDITOR_DEFAULT_HEIGHT = 100;
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
244
|
+
/**
|
|
245
|
+
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
246
|
+
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
247
|
+
* in from the card's sides, side by side 8 apart for two, stacked 8 apart
|
|
248
|
+
* otherwise.
|
|
249
|
+
*/
|
|
250
|
+
const ALERT_WIDTH = 320;
|
|
251
|
+
const ALERT_PAD_TOP = 21;
|
|
252
|
+
const ALERT_PAD_H = 30;
|
|
253
|
+
const ALERT_BUTTON_INSET = 16;
|
|
254
|
+
const ALERT_BUTTONS_GAP = 20;
|
|
255
|
+
const ALERT_PAD_BOTTOM = 16;
|
|
256
|
+
const ALERT_BUTTON_SPACING = 8;
|
|
182
257
|
/** Gap between the alert title and its message. */
|
|
183
|
-
const ALERT_TITLE_GAP =
|
|
184
|
-
const ALERT_BUTTON_HEIGHT =
|
|
258
|
+
const ALERT_TITLE_GAP = 6;
|
|
259
|
+
const ALERT_BUTTON_HEIGHT = 46;
|
|
260
|
+
/** Gap between an action sheet's title and its message. */
|
|
261
|
+
const DIALOG_TITLE_GAP = 4;
|
|
262
|
+
/** An action sheet's button labels are inset this much from its rows. */
|
|
263
|
+
const DIALOG_LABEL_INSET = 16;
|
|
185
264
|
/** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
|
|
186
265
|
const DIALOG_MARGIN = 8;
|
|
187
266
|
const DIALOG_BUTTON_HEIGHT = 57;
|
|
@@ -457,6 +536,8 @@ interface RowHints {
|
|
|
457
536
|
badge: string | null;
|
|
458
537
|
/** `.listRowInsets(_:)`, or null for the defaults. */
|
|
459
538
|
insets: EdgeInsets | null;
|
|
539
|
+
/** `.listRowSeparator(.hidden, edges:)`: the edges whose separator the row hides. */
|
|
540
|
+
separatorHidden: { top: boolean; bottom: boolean } | null;
|
|
460
541
|
}
|
|
461
542
|
|
|
462
543
|
/**
|
|
@@ -464,7 +545,7 @@ interface RowHints {
|
|
|
464
545
|
* outermost wins, like `layoutPriority`): `badge` and `listRowInsets`.
|
|
465
546
|
*/
|
|
466
547
|
function rowHintsOf(node: LayoutNode): RowHints {
|
|
467
|
-
const hints: RowHints = { badge: null, insets: null };
|
|
548
|
+
const hints: RowHints = { badge: null, insets: null, separatorHidden: null };
|
|
468
549
|
let current: LayoutNode | undefined = node;
|
|
469
550
|
// `NavigationLink(value:) { OrderRow().badge(n) }`: the hints sit on the
|
|
470
551
|
// link's label, which is the row's content. Phase 8 wrappers (`listrow`,
|
|
@@ -477,6 +558,11 @@ function rowHintsOf(node: LayoutNode): RowHints {
|
|
|
477
558
|
if (badge !== null && badge.length > 0) hints.badge = badge;
|
|
478
559
|
}
|
|
479
560
|
if (hints.insets === null) hints.insets = readInsets(style['listRowInsets']);
|
|
561
|
+
const separator = style['listRowSeparator'];
|
|
562
|
+
if (hints.separatorHidden === null && typeof separator === 'object' && separator !== null) {
|
|
563
|
+
const edges = separator as Record<string, unknown>;
|
|
564
|
+
hints.separatorHidden = { top: edges['top'] === true, bottom: edges['bottom'] === true };
|
|
565
|
+
}
|
|
480
566
|
current = current.children.length === 1 ? current.children[0] : undefined;
|
|
481
567
|
} else if (current.kind === 'navlink' && current.children.length === 1) {
|
|
482
568
|
current = current.children[0];
|
|
@@ -581,6 +667,54 @@ function alignIn(container: Size, child: Size, alignment: string): { dx: number;
|
|
|
581
667
|
};
|
|
582
668
|
}
|
|
583
669
|
|
|
670
|
+
/** The guide names a pair of anchors aligns by: `leading`/`center`/`trailing` and `top`/`center`/`bottom`. */
|
|
671
|
+
function guideNames([ax, ay]: [number, number]): [string, string] {
|
|
672
|
+
return [ax === 0 ? 'leading' : ax === 1 ? 'trailing' : 'center', ay === 0 ? 'top' : ay === 1 ? 'bottom' : 'center'];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The guide `name` on `axis` a view moved with `.alignmentGuide`, as
|
|
677
|
+
* `[a, b, c]` (the guide is `a + b·width + c·height`): set on the view or on
|
|
678
|
+
* a `styled` wrapper inside it (the modifiers between change nothing here).
|
|
679
|
+
*/
|
|
680
|
+
export function explicitGuide(node: LayoutNode, axis: 'horizontal' | 'vertical', name: string): [number, number, number] | null {
|
|
681
|
+
let current: LayoutNode | undefined = node;
|
|
682
|
+
for (let depth = 0; current && current.kind === 'styled' && depth < 32; depth++) {
|
|
683
|
+
const guide = (current.props['style'] as { alignmentGuide?: { axis?: string; name?: string; value?: unknown } } | undefined)?.alignmentGuide;
|
|
684
|
+
if (guide && guide.axis === axis && guide.name === name && Array.isArray(guide.value) && guide.value.length === 3) {
|
|
685
|
+
const [a, b, c] = guide.value.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) as [number, number, number];
|
|
686
|
+
return [a, b, c];
|
|
687
|
+
}
|
|
688
|
+
current = current.children.length === 1 ? current.children[0] : undefined;
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function guideValue([a, b, c]: [number, number, number], size: Size): number {
|
|
694
|
+
return a + b * size.width + c * size.height;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* `alignIn` for a child that may have moved its guides (`.alignmentGuide`):
|
|
699
|
+
* on an axis where it did, its guide meets the container's guide for the
|
|
700
|
+
* same alignment (0, the middle or the end); elsewhere it aligns as before.
|
|
701
|
+
*/
|
|
702
|
+
function alignChild(container: Size, child: Size, alignment: string, node: LayoutNode): { dx: number; dy: number } {
|
|
703
|
+
const anchors = alignmentAnchors(alignment);
|
|
704
|
+
const [horizontal, vertical] = guideNames(anchors);
|
|
705
|
+
const offsets = alignIn(container, child, alignment);
|
|
706
|
+
const gx = explicitGuide(node, 'horizontal', horizontal);
|
|
707
|
+
const gy = explicitGuide(node, 'vertical', vertical);
|
|
708
|
+
if (gx && Number.isFinite(container.width)) offsets.dx = container.width * anchors[0] - guideValue(gx, child);
|
|
709
|
+
if (gy && Number.isFinite(container.height)) offsets.dy = container.height * anchors[1] - guideValue(gy, child);
|
|
710
|
+
return offsets;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** A `Spacer` (a lazy stack's too): a stack sizes it after its other children. */
|
|
714
|
+
function isSpacer(node: LayoutNode): boolean {
|
|
715
|
+
return node.kind === 'spacer' || node.kind === 'lazyspacer';
|
|
716
|
+
}
|
|
717
|
+
|
|
584
718
|
function safeOffset(container: number, child: number, anchor: number): number {
|
|
585
719
|
if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
|
|
586
720
|
return (container - child) * anchor;
|
|
@@ -682,6 +816,17 @@ interface ListRowPlan {
|
|
|
682
816
|
badge: { rect: Rect; layout: TextLayout } | null;
|
|
683
817
|
/** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
|
|
684
818
|
edit: { listRow: LayoutNode; control: Rect } | null;
|
|
819
|
+
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
820
|
+
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
|
|
824
|
+
interface ListSupplementPlan {
|
|
825
|
+
node: LayoutNode;
|
|
826
|
+
ctx: Context;
|
|
827
|
+
proposal: Proposal;
|
|
828
|
+
/** Relative to the list origin at scroll 0. */
|
|
829
|
+
rect: Rect;
|
|
685
830
|
}
|
|
686
831
|
|
|
687
832
|
interface ListGroupPlan {
|
|
@@ -689,6 +834,8 @@ interface ListGroupPlan {
|
|
|
689
834
|
frame: Rect;
|
|
690
835
|
header: { rect: Rect; layout: TextLayout } | null;
|
|
691
836
|
footer: { rect: Rect; layout: TextLayout } | null;
|
|
837
|
+
headerView: ListSupplementPlan | null;
|
|
838
|
+
footerView: ListSupplementPlan | null;
|
|
692
839
|
card: Rect;
|
|
693
840
|
rows: ListRowPlan[];
|
|
694
841
|
}
|
|
@@ -769,6 +916,10 @@ interface GaugePlan {
|
|
|
769
916
|
/** Height of the label row (0 without labels). */
|
|
770
917
|
labelRow: number;
|
|
771
918
|
labelCtx: Context;
|
|
919
|
+
/** A ProgressView's label context (the gauge's own font, not the caption). */
|
|
920
|
+
labelOwnCtx?: Context;
|
|
921
|
+
/** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
|
|
922
|
+
ring?: { center: LayoutNode | null; centerSize: Size };
|
|
772
923
|
}
|
|
773
924
|
|
|
774
925
|
/** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
|
|
@@ -819,6 +970,12 @@ export class LayoutPass {
|
|
|
819
970
|
readonly layouts = new Map<number, LayoutRecord>();
|
|
820
971
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
821
972
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
973
|
+
/**
|
|
974
|
+
* A large-title screen's root list or vertical scroll view starts under the
|
|
975
|
+
* bar and insets its content by the title row, which scrolls with it
|
|
976
|
+
* (iOS): the inset, by scroll node, set by `placeNavScreen`.
|
|
977
|
+
*/
|
|
978
|
+
private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
|
|
822
979
|
|
|
823
980
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
824
981
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -870,9 +1027,12 @@ export class LayoutPass {
|
|
|
870
1027
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
871
1028
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
872
1029
|
} else if (root.kind === 'sheet') {
|
|
873
|
-
const
|
|
1030
|
+
const floats = this.sheetFloats(root);
|
|
1031
|
+
const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
|
|
1032
|
+
const insetBottom = floats ? SHEET_FLOAT_INSET_BOTTOM : 0;
|
|
1033
|
+
const proposal: Proposal = { width: screen.width - 2 * insetH, height: null };
|
|
874
1034
|
const size = this.measure(root, ctx, proposal);
|
|
875
|
-
this.place(root, ctx, proposal,
|
|
1035
|
+
this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
|
|
876
1036
|
} else {
|
|
877
1037
|
const safe: Size = {
|
|
878
1038
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
@@ -1030,7 +1190,11 @@ export class LayoutPass {
|
|
|
1030
1190
|
if (font !== ctx.font) next = { ...next, font };
|
|
1031
1191
|
if ('lineLimit' in style) {
|
|
1032
1192
|
const limit = num(style['lineLimit']);
|
|
1193
|
+
const min = num(style['lineLimitMin']);
|
|
1033
1194
|
next = { ...next, lineLimit: limit !== null && limit >= 1 ? Math.floor(limit) : null };
|
|
1195
|
+
// A new limit replaces the whole range, lower bound included.
|
|
1196
|
+
if (min !== null && min > 1) next.minLines = Math.floor(min);
|
|
1197
|
+
else delete next.minLines;
|
|
1034
1198
|
}
|
|
1035
1199
|
const alignment = str(style['multilineTextAlignment']);
|
|
1036
1200
|
if (alignment === 'leading' || alignment === 'center' || alignment === 'trailing') {
|
|
@@ -1120,6 +1284,9 @@ export class LayoutPass {
|
|
|
1120
1284
|
return this.measureImage(node, ctx, p);
|
|
1121
1285
|
case 'spacer':
|
|
1122
1286
|
return this.measureSpacer(node, ctx, p);
|
|
1287
|
+
case 'lazyspacer':
|
|
1288
|
+
// Lazy rows not built yet: no space (the page builds them as it nears).
|
|
1289
|
+
return ZERO;
|
|
1123
1290
|
case 'divider':
|
|
1124
1291
|
if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
|
|
1125
1292
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
@@ -1136,6 +1303,7 @@ export class LayoutPass {
|
|
|
1136
1303
|
case 'toggle':
|
|
1137
1304
|
return this.measureToggle(node, ctx, p);
|
|
1138
1305
|
case 'textfield': {
|
|
1306
|
+
if (node.props['axis'] === 'vertical') return this.measureMultilineField(node, ctx, p);
|
|
1139
1307
|
const style = str(node.props['style']);
|
|
1140
1308
|
return {
|
|
1141
1309
|
width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
|
|
@@ -1144,7 +1312,11 @@ export class LayoutPass {
|
|
|
1144
1312
|
}
|
|
1145
1313
|
case 'color':
|
|
1146
1314
|
case 'shape':
|
|
1315
|
+
case 'canvas':
|
|
1147
1316
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1317
|
+
case 'video':
|
|
1318
|
+
// Fills the proposal; an open axis follows the video's aspect ratio.
|
|
1319
|
+
return videoSize(node.props, p);
|
|
1148
1320
|
case 'scrollview':
|
|
1149
1321
|
return this.scrollPlan(node, ctx, p).size;
|
|
1150
1322
|
case 'list':
|
|
@@ -1230,8 +1402,26 @@ export class LayoutPass {
|
|
|
1230
1402
|
}
|
|
1231
1403
|
}
|
|
1232
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* A vertical `textfield`: the proposed width (200 when unconstrained) and
|
|
1407
|
+
* as many lines as its text (or, empty, its placeholder) wraps to in the
|
|
1408
|
+
* context font, between the range's `minLines` and `lineLimit`.
|
|
1409
|
+
*/
|
|
1410
|
+
private measureMultilineField(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1411
|
+
const inset = str(node.props['style']) === 'roundedBorder' ? TEXTFIELD_ROUNDED_INSET : { horizontal: 0, vertical: 0 };
|
|
1412
|
+
const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
|
|
1413
|
+
const text = str(node.props['text']) || str(node.props['placeholder']) || '';
|
|
1414
|
+
const wrapped = wrapText(text, ctx.font, Math.max(1, width - 2 * inset.horizontal), null, this.measurer);
|
|
1415
|
+
const min = Math.max(1, ctx.minLines ?? 1);
|
|
1416
|
+
const max = Math.max(min, ctx.lineLimit ?? Infinity);
|
|
1417
|
+
const lines = Math.min(Math.max(wrapped.lines.length, min), max);
|
|
1418
|
+
return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1233
1421
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
1234
1422
|
return this.plan(node, p, () => {
|
|
1423
|
+
const runs = node.props['runs'];
|
|
1424
|
+
if (Array.isArray(runs)) return this.richTextPlan(runs, ctx, p);
|
|
1235
1425
|
const text = str(node.props['text']) ?? '';
|
|
1236
1426
|
const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1237
1427
|
return {
|
|
@@ -1246,6 +1436,47 @@ export class LayoutPass {
|
|
|
1246
1436
|
});
|
|
1247
1437
|
}
|
|
1248
1438
|
|
|
1439
|
+
/**
|
|
1440
|
+
* Rich text: each run's font (the text's font, changed by the run's), its
|
|
1441
|
+
* kerning, and an inline image's size; wrapped as one paragraph flow.
|
|
1442
|
+
*/
|
|
1443
|
+
private richTextPlan(runs: unknown[], ctx: Context, p: Proposal): TextPlan {
|
|
1444
|
+
const inputs: TextRunInput[] = runs.map((value) => {
|
|
1445
|
+
const run = obj(value) ?? {};
|
|
1446
|
+
let font = resolveFont(run['font'], ctx.font, this.env);
|
|
1447
|
+
if (run['italic'] === true) font = { ...font, css: `italic ${font.css}` };
|
|
1448
|
+
const kerning = num(run['kerning']) ?? 0;
|
|
1449
|
+
const image = obj(run['image']);
|
|
1450
|
+
if (image) return { image: this.inlineImageSize(image, font), font, kerning };
|
|
1451
|
+
return { text: str(run['text']) ?? '', font, kerning };
|
|
1452
|
+
});
|
|
1453
|
+
const wrapped = wrapRuns(inputs, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1454
|
+
const lines = wrapped.lines.map((line) => line.map((fragment) => fragment.text ?? '\uFFFC').join(''));
|
|
1455
|
+
return {
|
|
1456
|
+
size: { width: wrapped.width, height: wrapped.heights.reduce((total, height) => total + height, 0) },
|
|
1457
|
+
layout: {
|
|
1458
|
+
font: ctx.font,
|
|
1459
|
+
lines,
|
|
1460
|
+
alignment: ctx.textAlignment,
|
|
1461
|
+
truncated: wrapped.truncated,
|
|
1462
|
+
rich: { lines: wrapped.lines, heights: wrapped.heights, fonts: inputs.map((input) => input.font) },
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
|
|
1468
|
+
private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
|
|
1469
|
+
if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
|
|
1470
|
+
return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
|
|
1471
|
+
}
|
|
1472
|
+
const name = str(props['name']);
|
|
1473
|
+
if (name === null) return square(font.lineHeight);
|
|
1474
|
+
const asset = this.env.assets?.images[name];
|
|
1475
|
+
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1476
|
+
if (asset.kind === 'symbol') return square(font.lineHeight);
|
|
1477
|
+
return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1249
1480
|
private measureSpacer(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1250
1481
|
const min = num(node.props['minLength']);
|
|
1251
1482
|
if (ctx.stackAxis === null) {
|
|
@@ -1280,19 +1511,31 @@ export class LayoutPass {
|
|
|
1280
1511
|
|
|
1281
1512
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1282
1513
|
const label = this.contentOf(node);
|
|
1283
|
-
|
|
1284
|
-
|
|
1514
|
+
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1515
|
+
const cctx = this.borderedLabelContext(node, ctx);
|
|
1516
|
+
const { padV, padH, minHeight } = this.borderedMetrics(node);
|
|
1285
1517
|
const inner: Proposal = {
|
|
1286
|
-
width: shrink(p.width, 2 *
|
|
1287
|
-
height: shrink(p.height, 2 *
|
|
1518
|
+
width: shrink(p.width, 2 * padH),
|
|
1519
|
+
height: shrink(p.height, 2 * padV),
|
|
1288
1520
|
};
|
|
1289
1521
|
const size = label ? this.measure(label, cctx, inner) : ZERO;
|
|
1290
1522
|
return {
|
|
1291
|
-
width: size.width + 2 *
|
|
1292
|
-
height: Math.max(
|
|
1523
|
+
width: size.width + 2 * padH,
|
|
1524
|
+
height: Math.max(minHeight, size.height + 2 * padV),
|
|
1293
1525
|
};
|
|
1294
1526
|
}
|
|
1295
1527
|
|
|
1528
|
+
private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
|
|
1529
|
+
return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** A bordered button's label context: a small or mini one sets a smaller text style. */
|
|
1533
|
+
private borderedLabelContext(node: LayoutNode, ctx: Context): Context {
|
|
1534
|
+
const cctx = this.childContext(node, ctx);
|
|
1535
|
+
const textStyle = this.borderedMetrics(node).textStyle;
|
|
1536
|
+
return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1296
1539
|
private toggleLabelProposal(p: Proposal): Proposal {
|
|
1297
1540
|
return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
|
|
1298
1541
|
}
|
|
@@ -1325,7 +1568,11 @@ export class LayoutPass {
|
|
|
1325
1568
|
const detents = node.props['detents'];
|
|
1326
1569
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1327
1570
|
const { screen, safeArea } = this.env;
|
|
1328
|
-
if (first === 'medium') return screen.height
|
|
1571
|
+
if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
|
|
1572
|
+
// `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
|
|
1573
|
+
const large = screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
1574
|
+
if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
|
|
1575
|
+
if (first?.startsWith('fraction:')) return Math.min(large, screen.height * (Number(first.slice(9)) || 1));
|
|
1329
1576
|
// Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
|
|
1330
1577
|
if (first === 'full') return screen.height;
|
|
1331
1578
|
return screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
@@ -1337,6 +1584,19 @@ export class LayoutPass {
|
|
|
1337
1584
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
1338
1585
|
}
|
|
1339
1586
|
|
|
1587
|
+
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
1588
|
+
private sheetFloats(node: LayoutNode): boolean {
|
|
1589
|
+
const detents = node.props['detents'];
|
|
1590
|
+
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1591
|
+
return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** iOS 26 shows the grabber only for a sheet with more than one detent. */
|
|
1595
|
+
private sheetHasGrabber(node: LayoutNode): boolean {
|
|
1596
|
+
const detents = node.props['detents'];
|
|
1597
|
+
return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1340
1600
|
// -------------------------------------------------------------------------
|
|
1341
1601
|
// Picker (Phase 4)
|
|
1342
1602
|
// -------------------------------------------------------------------------
|
|
@@ -1463,11 +1723,15 @@ export class LayoutPass {
|
|
|
1463
1723
|
* least flexible first. A child is offered `remaining / childrenLeft`, where
|
|
1464
1724
|
* `remaining` excludes the minimum sizes of every lower-priority child and
|
|
1465
1725
|
* `childrenLeft` counts only the unsized children of its own priority group
|
|
1466
|
-
* with *finite* flexibility: infinitely flexible children (
|
|
1726
|
+
* with *finite* flexibility: infinitely flexible children (colors,
|
|
1467
1727
|
* `maxWidth: infinity` frames, text fields) only reserve their minimum and
|
|
1468
1728
|
* share whatever is left once everything else is sized, which is why a
|
|
1469
|
-
* `Spacer` never makes its sibling text wrap.
|
|
1470
|
-
*
|
|
1729
|
+
* `Spacer` never makes its sibling text wrap. Spacers come after those: they
|
|
1730
|
+
* reserve their minimum while the other infinitely flexible children share,
|
|
1731
|
+
* and take what is left (iOS gives `HStack { VStack { … }.frame(maxWidth:
|
|
1732
|
+
* .infinity); Spacer() }`'s stack all but the spacer's 8 points, where an
|
|
1733
|
+
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1734
|
+
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1471
1735
|
*/
|
|
1472
1736
|
private layoutStack(
|
|
1473
1737
|
children: readonly LayoutNode[],
|
|
@@ -1494,6 +1758,7 @@ export class LayoutPass {
|
|
|
1494
1758
|
const mins = new Array<number>(n);
|
|
1495
1759
|
const flex = new Array<number>(n);
|
|
1496
1760
|
const priorities = new Array<number>(n);
|
|
1761
|
+
const spacers = children.map(isSpacer);
|
|
1497
1762
|
const minProposal = makeProposal(axis, 0, cross);
|
|
1498
1763
|
const maxProposal = makeProposal(axis, Infinity, cross);
|
|
1499
1764
|
for (let i = 0; i < n; i++) {
|
|
@@ -1511,6 +1776,7 @@ export class LayoutPass {
|
|
|
1511
1776
|
const fa = flex[a] as number;
|
|
1512
1777
|
const fb = flex[b] as number;
|
|
1513
1778
|
if (fa !== fb) return fa < fb ? -1 : 1;
|
|
1779
|
+
if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
|
|
1514
1780
|
return a - b;
|
|
1515
1781
|
});
|
|
1516
1782
|
|
|
@@ -1527,10 +1793,15 @@ export class LayoutPass {
|
|
|
1527
1793
|
let finiteLeft = 0;
|
|
1528
1794
|
let infiniteLeft = 0;
|
|
1529
1795
|
let infiniteMin = 0;
|
|
1796
|
+
let spacersLeft = 0;
|
|
1797
|
+
let spacerMin = 0;
|
|
1530
1798
|
for (let k = i; k < end; k++) {
|
|
1531
1799
|
const index = order[k] as number;
|
|
1532
1800
|
if (Number.isFinite(flex[index])) finiteLeft++;
|
|
1533
|
-
else {
|
|
1801
|
+
else if (spacers[index]) {
|
|
1802
|
+
spacersLeft++;
|
|
1803
|
+
spacerMin += mins[index] as number;
|
|
1804
|
+
} else {
|
|
1534
1805
|
infiniteLeft++;
|
|
1535
1806
|
infiniteMin += mins[index] as number;
|
|
1536
1807
|
}
|
|
@@ -1538,16 +1809,21 @@ export class LayoutPass {
|
|
|
1538
1809
|
for (let k = i; k < end; k++) {
|
|
1539
1810
|
const index = order[k] as number;
|
|
1540
1811
|
const isInfinite = !Number.isFinite(flex[index]);
|
|
1812
|
+
const isSpacerChild = isInfinite && spacers[index] === true;
|
|
1541
1813
|
let offer: number;
|
|
1542
1814
|
if (!Number.isFinite(remaining)) offer = Infinity;
|
|
1543
|
-
else if (
|
|
1544
|
-
else offer = Math.max(0, remaining - lowerMin -
|
|
1815
|
+
else if (isSpacerChild) offer = Math.max(0, remaining - lowerMin) / spacersLeft;
|
|
1816
|
+
else if (isInfinite) offer = Math.max(0, remaining - lowerMin - spacerMin) / infiniteLeft;
|
|
1817
|
+
else offer = Math.max(0, remaining - lowerMin - infiniteMin - spacerMin) / finiteLeft;
|
|
1545
1818
|
const proposal = makeProposal(axis, offer, cross);
|
|
1546
1819
|
const size = this.measure(children[index] as LayoutNode, cctx, proposal);
|
|
1547
1820
|
childProposals[index] = proposal;
|
|
1548
1821
|
childSizes[index] = size;
|
|
1549
1822
|
if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
|
|
1550
|
-
if (
|
|
1823
|
+
if (isSpacerChild) {
|
|
1824
|
+
spacersLeft--;
|
|
1825
|
+
spacerMin -= mins[index] as number;
|
|
1826
|
+
} else if (isInfinite) {
|
|
1551
1827
|
infiniteLeft--;
|
|
1552
1828
|
infiniteMin -= mins[index] as number;
|
|
1553
1829
|
} else finiteLeft--;
|
|
@@ -1582,12 +1858,27 @@ export class LayoutPass {
|
|
|
1582
1858
|
const anchors = alignmentAnchors(alignment);
|
|
1583
1859
|
const crossAnchor = axis === 'horizontal' ? anchors[1] : anchors[0];
|
|
1584
1860
|
const crossSize = crossOf(size, axis);
|
|
1861
|
+
// `.alignmentGuide`: the children's guides for the stack's alignment
|
|
1862
|
+
// line up. A child's guide is its explicit one, else the default (none,
|
|
1863
|
+
// half or all of its cross size); the line is the stack's own guide, or
|
|
1864
|
+
// further along when a guide reaches past it (the children move with it).
|
|
1865
|
+
const guideAxis = axis === 'horizontal' ? 'vertical' : 'horizontal';
|
|
1866
|
+
const guideName = guideNames(anchors)[axis === 'horizontal' ? 1 : 0];
|
|
1867
|
+
const guides = children.map((child, i) => {
|
|
1868
|
+
const explicit = explicitGuide(child, guideAxis, guideName);
|
|
1869
|
+
return explicit ? guideValue(explicit, plan.childSizes[i] as Size) : null;
|
|
1870
|
+
});
|
|
1871
|
+
const line = guides.some((g) => g !== null) && Number.isFinite(crossSize)
|
|
1872
|
+
? Math.max(crossSize * crossAnchor, ...guides.filter((g): g is number => g !== null))
|
|
1873
|
+
: null;
|
|
1585
1874
|
let cursor = 0;
|
|
1586
1875
|
for (let i = 0; i < children.length; i++) {
|
|
1587
1876
|
const child = children[i] as LayoutNode;
|
|
1588
1877
|
const childSize = plan.childSizes[i] as Size;
|
|
1589
1878
|
const proposal = plan.childProposals[i] as Proposal;
|
|
1590
|
-
const crossOffset =
|
|
1879
|
+
const crossOffset = line === null
|
|
1880
|
+
? safeOffset(crossSize, crossOf(childSize, axis), crossAnchor)
|
|
1881
|
+
: line - (guides[i] ?? crossOf(childSize, axis) * crossAnchor);
|
|
1591
1882
|
if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
|
|
1592
1883
|
else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
|
|
1593
1884
|
cursor += axisOf(childSize, axis) + spacing;
|
|
@@ -1606,6 +1897,13 @@ export class LayoutPass {
|
|
|
1606
1897
|
* fixed 44×44 so the placeholder is visible.
|
|
1607
1898
|
*/
|
|
1608
1899
|
private measureImage(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1900
|
+
if (typeof node.props['url'] === 'string' || typeof node.props['bitmap'] === 'object') {
|
|
1901
|
+
// Phase 13: a remote image carries its own point size, as does a bitmap.
|
|
1902
|
+
const width = num(node.props['width']) ?? 0;
|
|
1903
|
+
const height = num(node.props['height']) ?? 0;
|
|
1904
|
+
if (node.props['resizable'] !== true) return { width, height };
|
|
1905
|
+
return { width: p.width ?? width, height: p.height ?? height };
|
|
1906
|
+
}
|
|
1609
1907
|
const name = str(node.props['name']);
|
|
1610
1908
|
if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
|
|
1611
1909
|
const asset = this.env.assets?.images[name];
|
|
@@ -1815,6 +2113,8 @@ export class LayoutPass {
|
|
|
1815
2113
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
1816
2114
|
// the space between sections.
|
|
1817
2115
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
2116
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
|
|
2117
|
+
const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
|
|
1818
2118
|
const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
|
|
1819
2119
|
const plain = listStyle === 'plain';
|
|
1820
2120
|
const listTop = plain ? 0 : LIST_TOP;
|
|
@@ -1824,7 +2124,13 @@ export class LayoutPass {
|
|
|
1824
2124
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
1825
2125
|
const contentX = inset + LIST_CELL_PADDING;
|
|
1826
2126
|
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
|
|
2127
|
+
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
1827
2128
|
const headerFont = makeFont(
|
|
2129
|
+
'system',
|
|
2130
|
+
textStyleSize('headline', this.env.dynamicTypeSize),
|
|
2131
|
+
600,
|
|
2132
|
+
);
|
|
2133
|
+
const footerFont = makeFont(
|
|
1828
2134
|
'system',
|
|
1829
2135
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
1830
2136
|
400,
|
|
@@ -1834,49 +2140,78 @@ export class LayoutPass {
|
|
|
1834
2140
|
// its content shifts right by as much.
|
|
1835
2141
|
const editing = node.props['editing'] === true;
|
|
1836
2142
|
|
|
1837
|
-
// Group consecutive plain rows into an implicit section.
|
|
1838
|
-
|
|
2143
|
+
// Group consecutive plain rows into an implicit section. A section's
|
|
2144
|
+
// view header and footer are not rows.
|
|
2145
|
+
type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
2146
|
+
const groupsSource: GroupSource[] = [];
|
|
1839
2147
|
for (const child of node.children) {
|
|
1840
2148
|
if (child.kind === 'section') {
|
|
1841
|
-
|
|
2149
|
+
const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
|
|
2150
|
+
const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
|
|
2151
|
+
const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
|
|
2152
|
+
groupsSource.push({ section: child, rows, headerView, footerView });
|
|
1842
2153
|
} else {
|
|
1843
2154
|
const last = groupsSource[groupsSource.length - 1];
|
|
1844
2155
|
if (last && last.section === null) last.rows.push(child);
|
|
1845
|
-
else groupsSource.push({ section: null, rows: [child] });
|
|
2156
|
+
else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
|
|
1846
2157
|
}
|
|
1847
2158
|
}
|
|
2159
|
+
// View headers and footers: the content width, the header's and the
|
|
2160
|
+
// footer's font.
|
|
2161
|
+
const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
|
|
2162
|
+
const footerCtx: Context = { ...headerCtx, font: footerFont };
|
|
2163
|
+
const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
|
|
2164
|
+
if (!view) return null;
|
|
2165
|
+
const proposal: Proposal = { width: contentWidth, height: null };
|
|
2166
|
+
const size = this.measure(view, ctx, proposal);
|
|
2167
|
+
return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
|
|
2168
|
+
};
|
|
1848
2169
|
|
|
1849
2170
|
const groups: ListGroupPlan[] = [];
|
|
1850
2171
|
let y = listTop;
|
|
1851
|
-
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
1852
|
-
const wrapped = wrapText(text
|
|
2172
|
+
const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
|
|
2173
|
+
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
1853
2174
|
return {
|
|
1854
|
-
layout: { font
|
|
1855
|
-
size: { width: wrapped.width, height: wrapped.lines.length *
|
|
2175
|
+
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2176
|
+
size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
|
|
1856
2177
|
};
|
|
1857
2178
|
};
|
|
1858
2179
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
1859
|
-
const source = groupsSource[g] as
|
|
2180
|
+
const source = groupsSource[g] as GroupSource;
|
|
1860
2181
|
if (g > 0) y += sectionGap;
|
|
1861
2182
|
const top = y;
|
|
1862
2183
|
let header: ListGroupPlan['header'] = null;
|
|
1863
2184
|
let footer: ListGroupPlan['footer'] = null;
|
|
2185
|
+
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2186
|
+
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2187
|
+
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
2188
|
+
const headerView = supplement(source.headerView, y - headerLift, headerCtx);
|
|
2189
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
|
|
1864
2190
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
1865
2191
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
1866
2192
|
if (headerText !== null && headerText.length > 0) {
|
|
1867
|
-
const { layout, size } = makeText(headerText);
|
|
1868
|
-
|
|
1869
|
-
y
|
|
2193
|
+
const { layout, size } = makeText(headerText, headerFont);
|
|
2194
|
+
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
2195
|
+
header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
2196
|
+
y = headerY + size.height + LIST_HEADER_GAP;
|
|
1870
2197
|
}
|
|
1871
2198
|
const cardTop = y;
|
|
1872
2199
|
const rows: ListRowPlan[] = [];
|
|
1873
2200
|
for (const rowNode of source.rows) {
|
|
1874
|
-
|
|
1875
|
-
|
|
2201
|
+
if (rowNode.kind === 'lazyspacer') {
|
|
2202
|
+
// Lazy rows not built yet: a row of no height, without a cell.
|
|
2203
|
+
const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
|
|
2204
|
+
rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
// A menu or inline picker already reports its row height (44 for
|
|
2208
|
+
// a menu, 44 per option inline); other rows (a segmented picker
|
|
2209
|
+
// too) get the 13px vertical padding. Phase 6
|
|
1876
2210
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
1877
2211
|
// width (plus a gap) at the trailing edge.
|
|
1878
2212
|
const hints = rowHintsOf(rowNode);
|
|
1879
|
-
const
|
|
2213
|
+
const unwrappedRow = unwrapStyled(rowNode);
|
|
2214
|
+
const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
|
|
1880
2215
|
const insets: EdgeInsets = hints.insets ?? {
|
|
1881
2216
|
top: defaultVPad,
|
|
1882
2217
|
leading: LIST_CELL_PADDING,
|
|
@@ -1891,7 +2226,7 @@ export class LayoutPass {
|
|
|
1891
2226
|
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
|
|
1892
2227
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
1893
2228
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
1894
|
-
const rowHeight = Math.max(
|
|
2229
|
+
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
1895
2230
|
const row: Rect = { x: cardX, y, width: cardWidth, height: rowHeight };
|
|
1896
2231
|
rows.push({
|
|
1897
2232
|
node: rowNode,
|
|
@@ -1912,21 +2247,27 @@ export class LayoutPass {
|
|
|
1912
2247
|
}
|
|
1913
2248
|
: null,
|
|
1914
2249
|
edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
|
|
2250
|
+
separatorHidden: hints.separatorHidden,
|
|
1915
2251
|
});
|
|
1916
2252
|
y += rowHeight;
|
|
1917
2253
|
}
|
|
1918
2254
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
1919
2255
|
if (footerText !== null && footerText.length > 0) {
|
|
1920
2256
|
y += LIST_FOOTER_GAP;
|
|
1921
|
-
const { layout, size } = makeText(footerText);
|
|
2257
|
+
const { layout, size } = makeText(footerText, footerFont);
|
|
1922
2258
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
1923
2259
|
y += size.height;
|
|
1924
2260
|
}
|
|
2261
|
+
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
2262
|
+
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
2263
|
+
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
1925
2264
|
groups.push({
|
|
1926
2265
|
section: source.section,
|
|
1927
2266
|
frame: { x: cardX, y: top, width: cardWidth, height: y - top },
|
|
1928
2267
|
header,
|
|
1929
2268
|
footer,
|
|
2269
|
+
headerView,
|
|
2270
|
+
footerView,
|
|
1930
2271
|
card,
|
|
1931
2272
|
rows,
|
|
1932
2273
|
});
|
|
@@ -1944,15 +2285,21 @@ export class LayoutPass {
|
|
|
1944
2285
|
// Place
|
|
1945
2286
|
// -------------------------------------------------------------------------
|
|
1946
2287
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2288
|
+
/**
|
|
2289
|
+
* Phase 9: placed before, same proposal, same origin, nothing in the
|
|
2290
|
+
* subtree invalidated since: the last run's output still holds.
|
|
2291
|
+
*/
|
|
2292
|
+
private placedAlready(node: LayoutNode, p: Proposal, x: number, y: number): boolean {
|
|
1951
2293
|
const last = this.placed.get(node);
|
|
1952
|
-
|
|
2294
|
+
return last !== undefined && last.key === proposalKey(p) && last.x === x && last.y === y;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2298
|
+
if (this.placedAlready(node, p, x, y)) {
|
|
1953
2299
|
this.stats.skipped += 1;
|
|
1954
2300
|
return;
|
|
1955
2301
|
}
|
|
2302
|
+
const key = proposalKey(p);
|
|
1956
2303
|
this.placed.set(node, { key, x, y });
|
|
1957
2304
|
this.stats.placed += 1;
|
|
1958
2305
|
if (node.id >= 0) {
|
|
@@ -1979,11 +2326,17 @@ export class LayoutPass {
|
|
|
1979
2326
|
case 'image':
|
|
1980
2327
|
if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
|
|
1981
2328
|
return;
|
|
2329
|
+
case 'textfield':
|
|
2330
|
+
// A vertical field draws its text in the context font (one-line fields in the body font).
|
|
2331
|
+
if (node.props['axis'] === 'vertical' && node.id >= 0) this.fonts.set(node.id, ctx.font);
|
|
2332
|
+
return;
|
|
1982
2333
|
case 'spacer':
|
|
2334
|
+
case 'lazyspacer':
|
|
1983
2335
|
case 'divider':
|
|
1984
|
-
case 'textfield':
|
|
1985
2336
|
case 'color':
|
|
1986
2337
|
case 'shape':
|
|
2338
|
+
case 'canvas':
|
|
2339
|
+
case 'video':
|
|
1987
2340
|
return;
|
|
1988
2341
|
case 'vstack':
|
|
1989
2342
|
case 'hstack': {
|
|
@@ -1998,7 +2351,7 @@ export class LayoutPass {
|
|
|
1998
2351
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
1999
2352
|
for (const child of node.children) {
|
|
2000
2353
|
const childSize = this.measure(child, cctx, p);
|
|
2001
|
-
const { dx, dy } =
|
|
2354
|
+
const { dx, dy } = alignChild(size, childSize, alignment, child);
|
|
2002
2355
|
this.place(child, cctx, p, x + dx, y + dy);
|
|
2003
2356
|
}
|
|
2004
2357
|
return;
|
|
@@ -2038,7 +2391,7 @@ export class LayoutPass {
|
|
|
2038
2391
|
);
|
|
2039
2392
|
return;
|
|
2040
2393
|
}
|
|
2041
|
-
const { dx, dy } =
|
|
2394
|
+
const { dx, dy } = alignChild(box, plan.childSize, plan.alignment, child);
|
|
2042
2395
|
this.place(child, cctx, plan.childProposal, x + box.x + dx, y + box.y + dy);
|
|
2043
2396
|
}
|
|
2044
2397
|
return;
|
|
@@ -2050,13 +2403,15 @@ export class LayoutPass {
|
|
|
2050
2403
|
this.place(label, cctx, p, x, y);
|
|
2051
2404
|
return;
|
|
2052
2405
|
}
|
|
2406
|
+
const metrics = this.borderedMetrics(node);
|
|
2407
|
+
const labelCtx = this.borderedLabelContext(node, ctx);
|
|
2053
2408
|
const inner: Proposal = {
|
|
2054
|
-
width: shrink(p.width, 2 *
|
|
2055
|
-
height: shrink(p.height, 2 *
|
|
2409
|
+
width: shrink(p.width, 2 * metrics.padH),
|
|
2410
|
+
height: shrink(p.height, 2 * metrics.padV),
|
|
2056
2411
|
};
|
|
2057
|
-
const labelSize = this.measure(label,
|
|
2412
|
+
const labelSize = this.measure(label, labelCtx, inner);
|
|
2058
2413
|
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
2059
|
-
this.place(label,
|
|
2414
|
+
this.place(label, labelCtx, inner, x + dx, y + dy);
|
|
2060
2415
|
// Keep the list row chrome (`row`, `badge`) a list set before placing.
|
|
2061
2416
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
2062
2417
|
this.setChrome(node, {
|
|
@@ -2124,14 +2479,19 @@ export class LayoutPass {
|
|
|
2124
2479
|
// centered on the other axes when smaller than the viewport.
|
|
2125
2480
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2126
2481
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2482
|
+
// Under a large title that scrolls with it, the content starts below it.
|
|
2483
|
+
const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
|
|
2127
2484
|
const content: Rect = {
|
|
2128
2485
|
x: x + dx,
|
|
2129
|
-
y: y + dy,
|
|
2486
|
+
y: y + dy + (titleRect?.height ?? 0),
|
|
2130
2487
|
width: plan.contentSize.width,
|
|
2131
2488
|
height: plan.contentSize.height,
|
|
2132
2489
|
};
|
|
2133
2490
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2134
|
-
|
|
2491
|
+
const scrollChrome: Record<string, Rect> = { content };
|
|
2492
|
+
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2493
|
+
if (titleRect) scrollChrome['largeTitle'] = titleRect;
|
|
2494
|
+
this.setChrome(node, scrollChrome);
|
|
2135
2495
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2136
2496
|
return;
|
|
2137
2497
|
}
|
|
@@ -2208,7 +2568,7 @@ export class LayoutPass {
|
|
|
2208
2568
|
const proposal: Proposal = { width: size.width, height: size.height };
|
|
2209
2569
|
const decorationSize = this.measure(decoration, cctx, proposal);
|
|
2210
2570
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
2211
|
-
const { dx, dy } =
|
|
2571
|
+
const { dx, dy } = alignChild(size, decorationSize, alignment, decoration);
|
|
2212
2572
|
this.place(decoration, cctx, proposal, x + dx, y + dy);
|
|
2213
2573
|
}
|
|
2214
2574
|
return;
|
|
@@ -2271,9 +2631,16 @@ export class LayoutPass {
|
|
|
2271
2631
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2272
2632
|
const plan = this.listPlan(node, ctx, p);
|
|
2273
2633
|
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2274
|
-
if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
|
|
2275
2634
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2276
|
-
|
|
2635
|
+
// Under a large title that scrolls with it, the rows start below it.
|
|
2636
|
+
const titleRect = this.titleInsets.get(node);
|
|
2637
|
+
const listChrome: Record<string, Rect> = {};
|
|
2638
|
+
if (covered) listChrome['bottomInset'] = covered;
|
|
2639
|
+
if (titleRect) listChrome['largeTitle'] = titleRect;
|
|
2640
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2641
|
+
const inset = titleRect?.height ?? 0;
|
|
2642
|
+
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2643
|
+
y += inset;
|
|
2277
2644
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
2278
2645
|
for (const group of plan.groups) {
|
|
2279
2646
|
if (group.section) {
|
|
@@ -2289,6 +2656,9 @@ export class LayoutPass {
|
|
|
2289
2656
|
}
|
|
2290
2657
|
this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
|
|
2291
2658
|
this.setChrome(group.section, chrome);
|
|
2659
|
+
for (const view of [group.headerView, group.footerView]) {
|
|
2660
|
+
if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
|
|
2661
|
+
}
|
|
2292
2662
|
if (group.section.id >= 0) {
|
|
2293
2663
|
this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
|
|
2294
2664
|
if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
|
|
@@ -2297,6 +2667,10 @@ export class LayoutPass {
|
|
|
2297
2667
|
for (const row of group.rows) {
|
|
2298
2668
|
// Set the row chrome before placing so a navlink can merge its own keys.
|
|
2299
2669
|
const chrome: Record<string, Rect> = { row: offset(row.row) };
|
|
2670
|
+
// `.listRowSeparator(.hidden)`: flags the renderer turns into data
|
|
2671
|
+
// attributes on the row element (its separator line is CSS).
|
|
2672
|
+
if (row.separatorHidden?.top) chrome['separatorTopHidden'] = offset(row.row);
|
|
2673
|
+
if (row.separatorHidden?.bottom) chrome['separatorBottomHidden'] = offset(row.row);
|
|
2300
2674
|
if (row.badge) {
|
|
2301
2675
|
chrome['badge'] = offset(row.badge.rect);
|
|
2302
2676
|
if (row.node.id >= 0) this.chromeText.set(row.node.id, { badge: row.badge.layout });
|
|
@@ -2307,42 +2681,72 @@ export class LayoutPass {
|
|
|
2307
2681
|
if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
|
|
2308
2682
|
else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
|
|
2309
2683
|
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2684
|
+
const rowX = x + row.content.x;
|
|
2685
|
+
const rowY = y + row.content.y;
|
|
2686
|
+
// A row that is skipped keeps the chrome it set itself when it was
|
|
2687
|
+
// placed (a link's chevron and label); only the list's keys are new.
|
|
2688
|
+
const own = this.placedAlready(row.node, row.proposal, rowX, rowY) && row.node.id >= 0
|
|
2689
|
+
? this.chrome.get(row.node.id)
|
|
2690
|
+
: undefined;
|
|
2691
|
+
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
2692
|
+
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
2693
|
+
// iOS 26: the separator under a row starts where its first text
|
|
2694
|
+
// starts (after a Label's icon), else at the row's content.
|
|
2695
|
+
const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
|
|
2696
|
+
if (placed) {
|
|
2697
|
+
const textX = this.firstTextX(row.node);
|
|
2698
|
+
const leading = (textX ?? rowX) - (x + row.row.x);
|
|
2699
|
+
placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
|
|
2700
|
+
}
|
|
2312
2701
|
}
|
|
2313
2702
|
}
|
|
2314
2703
|
}
|
|
2315
2704
|
|
|
2705
|
+
/** The x of the first text (in tree order) placed under `node`, if any. */
|
|
2706
|
+
private firstTextX(node: LayoutNode): number | null {
|
|
2707
|
+
if (node.kind === 'text' && node.id >= 0) {
|
|
2708
|
+
const frame = this.frames.get(node.id);
|
|
2709
|
+
if (frame && frame.width > 0) return frame.x;
|
|
2710
|
+
}
|
|
2711
|
+
for (const child of node.children) {
|
|
2712
|
+
const x = this.firstTextX(child);
|
|
2713
|
+
if (x !== null) return x;
|
|
2714
|
+
}
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2316
2718
|
/**
|
|
2317
|
-
* The navigation bar is the status bar plus a 44 row (plus
|
|
2318
|
-
* title
|
|
2319
|
-
* bar plus the bottom safe area; the
|
|
2320
|
-
*
|
|
2321
|
-
*
|
|
2719
|
+
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
2720
|
+
* title); a Phase 6 `searchfield` is a capsule floating above the bottom
|
|
2721
|
+
* (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
|
|
2722
|
+
* content is proposed the rest. Toolbar
|
|
2723
|
+
* items are measured at their ideal size and laid out in iOS 26 glass
|
|
2724
|
+
* capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
|
|
2322
2725
|
* trailing edge, `principal` centered in place of the title) or spread
|
|
2323
2726
|
* evenly across the bottom bar. The inline title (`chrome.title`) is
|
|
2324
2727
|
* centered and truncated to the room the bar items leave.
|
|
2325
2728
|
*/
|
|
2326
2729
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2327
2730
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
2328
|
-
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
2731
|
+
const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
|
|
2329
2732
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2330
2733
|
const depth = num(node.props['depth']) ?? 0;
|
|
2331
2734
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2332
2735
|
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
2333
2736
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2334
2737
|
const parts = this.navScreenParts(node);
|
|
2335
|
-
const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
|
|
2336
2738
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
2337
2739
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
2338
2740
|
const bottomBarHeight =
|
|
2339
2741
|
bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
|
|
2340
|
-
const
|
|
2742
|
+
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
2743
|
+
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
2744
|
+
const contentTop = barHeight + largeHeight;
|
|
2341
2745
|
const content: Rect = {
|
|
2342
2746
|
x,
|
|
2343
2747
|
y: y + contentTop,
|
|
2344
2748
|
width: size.width,
|
|
2345
|
-
height: Math.max(0, size.height - contentTop - bottomBarHeight),
|
|
2749
|
+
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
2346
2750
|
};
|
|
2347
2751
|
const chrome: Record<string, Rect> = {
|
|
2348
2752
|
bar: { x, y, width: size.width, height: barHeight },
|
|
@@ -2352,9 +2756,9 @@ export class LayoutPass {
|
|
|
2352
2756
|
if (parts.search) {
|
|
2353
2757
|
chrome['searchField'] = {
|
|
2354
2758
|
x: x + SEARCH_FIELD_MARGIN_H,
|
|
2355
|
-
y: y +
|
|
2759
|
+
y: y + size.height - searchBottom,
|
|
2356
2760
|
width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
|
|
2357
|
-
height:
|
|
2761
|
+
height: SEARCH_BOTTOM_HEIGHT,
|
|
2358
2762
|
};
|
|
2359
2763
|
}
|
|
2360
2764
|
if (bottomBarHeight > 0) {
|
|
@@ -2371,60 +2775,56 @@ export class LayoutPass {
|
|
|
2371
2775
|
toolbar: LayoutNode;
|
|
2372
2776
|
placement: string;
|
|
2373
2777
|
sizes: Size[];
|
|
2778
|
+
/** Leading and trailing groups: each item's glass slot width. */
|
|
2779
|
+
slots: number[] | null;
|
|
2374
2780
|
total: number;
|
|
2375
2781
|
}
|
|
2376
2782
|
const groups: Group[] = parts.toolbars.map((toolbar) => {
|
|
2377
2783
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
2378
2784
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
2785
|
+
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
2786
|
+
if (placement === 'leading' || placement === 'trailing') {
|
|
2787
|
+
const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
|
|
2788
|
+
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
2789
|
+
}
|
|
2379
2790
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
2380
|
-
return { toolbar, placement
|
|
2791
|
+
return { toolbar, placement, sizes, slots: null, total };
|
|
2381
2792
|
});
|
|
2382
2793
|
const groupWidth = (placement: string): number =>
|
|
2383
|
-
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total +
|
|
2794
|
+
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
|
|
2384
2795
|
const leadingWidth = groupWidth('leading');
|
|
2385
2796
|
const trailingWidth = groupWidth('trailing');
|
|
2386
2797
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
2387
2798
|
|
|
2388
|
-
// Back button,
|
|
2389
|
-
//
|
|
2390
|
-
|
|
2799
|
+
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2800
|
+
// title no longer shows).
|
|
2801
|
+
const backLabel = '';
|
|
2391
2802
|
if (depth > 0) {
|
|
2392
|
-
|
|
2393
|
-
const available =
|
|
2394
|
-
(large ? size.width - BACK_CHEVRON_WIDTH - 16 - 44 : (size.width - inlineTitleWidth) / 2 - 8 - 12) - leadingWidth;
|
|
2395
|
-
const candidates = [this.previousTitles.get(node) ?? '', 'Back', ''];
|
|
2396
|
-
let width = BACK_CHEVRON_WIDTH + 8;
|
|
2397
|
-
for (const candidate of candidates) {
|
|
2398
|
-
const labelWidth = candidate ? this.measurer.measureWidth(candidate, backFont) : 0;
|
|
2399
|
-
const needed = 8 + BACK_CHEVRON_WIDTH + labelWidth + 8;
|
|
2400
|
-
if (candidate === '' || needed <= available) {
|
|
2401
|
-
backLabel = candidate;
|
|
2402
|
-
width = needed;
|
|
2403
|
-
break;
|
|
2404
|
-
}
|
|
2405
|
-
}
|
|
2406
|
-
chrome['back'] = { x: x + 8, y: rowY, width, height: NAV_BAR_HEIGHT };
|
|
2803
|
+
chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2407
2804
|
}
|
|
2408
2805
|
|
|
2409
2806
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2410
2807
|
const back = chrome['back'];
|
|
2411
|
-
let leadingCursor = back ? back.x + back.width +
|
|
2808
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
|
|
2412
2809
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2413
2810
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2414
2811
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
2415
2812
|
let cursor = startX;
|
|
2416
2813
|
group.toolbar.children.forEach((item, i) => {
|
|
2417
2814
|
const itemSize = group.sizes[i] as Size;
|
|
2418
|
-
|
|
2419
|
-
|
|
2815
|
+
// In a glass slot the item is centered in it.
|
|
2816
|
+
const slot = group.slots?.[i];
|
|
2817
|
+
const itemX = slot !== undefined ? cursor + (slot - itemSize.width) / 2 : cursor;
|
|
2818
|
+
this.place(item, itemCtx, { width: null, height: null }, itemX, top + (rowHeight - itemSize.height) / 2);
|
|
2819
|
+
cursor += (slot ?? itemSize.width) + spacing;
|
|
2420
2820
|
});
|
|
2421
2821
|
};
|
|
2422
2822
|
for (const group of groups) {
|
|
2423
2823
|
switch (group.placement) {
|
|
2424
2824
|
case 'leading': {
|
|
2425
2825
|
this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
2426
|
-
placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT,
|
|
2427
|
-
leadingCursor += group.total +
|
|
2826
|
+
placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
|
|
2827
|
+
leadingCursor += group.total + NAV_GROUP_SPACING;
|
|
2428
2828
|
break;
|
|
2429
2829
|
}
|
|
2430
2830
|
case 'principal': {
|
|
@@ -2449,25 +2849,38 @@ export class LayoutPass {
|
|
|
2449
2849
|
default: {
|
|
2450
2850
|
const startX = trailingEdge - group.total;
|
|
2451
2851
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
2452
|
-
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT,
|
|
2453
|
-
trailingEdge = startX -
|
|
2852
|
+
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
|
|
2853
|
+
trailingEdge = startX - NAV_GROUP_SPACING;
|
|
2454
2854
|
break;
|
|
2455
2855
|
}
|
|
2456
2856
|
}
|
|
2457
2857
|
}
|
|
2458
2858
|
if (parts.search) {
|
|
2859
|
+
// The field is the whole capsule (its own measure is a 36pt field).
|
|
2459
2860
|
const field = chrome['searchField'] as Rect;
|
|
2460
2861
|
this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
|
|
2862
|
+
this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
|
|
2461
2863
|
}
|
|
2462
2864
|
|
|
2463
2865
|
// The inline title: centered, no wider than 56% of the bar, and clear of
|
|
2464
|
-
// whatever the back button and the bar items occupy on either side.
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2866
|
+
// whatever the back button and the bar items occupy on either side. Its
|
|
2867
|
+
// width is rounded up to whole pixels: snapping floors both edges, which
|
|
2868
|
+
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
2869
|
+
// the title truncates with an ellipsis ("Animati…") when its box is any
|
|
2870
|
+
// narrower than its text.
|
|
2871
|
+
const scroller = parts.content ? unwrapStyled(parts.content) : null;
|
|
2872
|
+
const titleScrolls =
|
|
2873
|
+
large &&
|
|
2874
|
+
scroller !== null &&
|
|
2875
|
+
(scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
2876
|
+
// A large title that scrolls away collapses into the inline title, which
|
|
2877
|
+
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2878
|
+
if ((!large || titleScrolls) && !principal) {
|
|
2879
|
+
const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
|
|
2880
|
+
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2468
2881
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2469
2882
|
const available = Math.max(0, size.width - 2 * side);
|
|
2470
|
-
const titleWidth = Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56, available);
|
|
2883
|
+
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
|
|
2471
2884
|
chrome['title'] = { x: x + (size.width - titleWidth) / 2, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
|
|
2472
2885
|
}
|
|
2473
2886
|
this.setChrome(node, chrome);
|
|
@@ -2498,8 +2911,17 @@ export class LayoutPass {
|
|
|
2498
2911
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2499
2912
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2500
2913
|
// large title / search field), which is transparent over it.
|
|
2501
|
-
const contentCtx: Context = { ...cctx, safeTop: content.y };
|
|
2502
|
-
if (
|
|
2914
|
+
const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
|
|
2915
|
+
if (scroller && titleScrolls) {
|
|
2916
|
+
// The scroll view starts under the bar; its content starts under the
|
|
2917
|
+
// large title, which moves with it.
|
|
2918
|
+
const top = content.y - largeHeight;
|
|
2919
|
+
this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
|
|
2920
|
+
this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
if (scroller) this.titleInsets.delete(scroller);
|
|
2924
|
+
if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
|
|
2503
2925
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2504
2926
|
return;
|
|
2505
2927
|
}
|
|
@@ -2511,25 +2933,38 @@ export class LayoutPass {
|
|
|
2511
2933
|
}
|
|
2512
2934
|
|
|
2513
2935
|
/**
|
|
2514
|
-
* The tab bar
|
|
2515
|
-
*
|
|
2516
|
-
* the
|
|
2517
|
-
*
|
|
2936
|
+
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
2937
|
+
* content (TAB_BAR_* above), with one equal-width item per tab; what it
|
|
2938
|
+
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
2939
|
+
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
2940
|
+
* content out, the others are reported in `hidden`.
|
|
2518
2941
|
*/
|
|
2519
2942
|
private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2520
|
-
const { screen
|
|
2521
|
-
const
|
|
2522
|
-
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT +
|
|
2523
|
-
const
|
|
2943
|
+
const { screen } = this.env;
|
|
2944
|
+
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
2945
|
+
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
2946
|
+
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
2947
|
+
const bar: Rect = {
|
|
2948
|
+
x: x + TAB_BAR_MARGIN_H,
|
|
2949
|
+
y: y + size.height - barHeight,
|
|
2950
|
+
width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
|
|
2951
|
+
height: capsuleHeight,
|
|
2952
|
+
};
|
|
2524
2953
|
const chrome: Record<string, Rect> = {
|
|
2525
2954
|
bar,
|
|
2526
2955
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
2527
2956
|
};
|
|
2528
2957
|
const tabs = node.children;
|
|
2529
2958
|
const n = tabs.length;
|
|
2959
|
+
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
2530
2960
|
for (let i = 0; i < n; i++) {
|
|
2531
|
-
const itemWidth =
|
|
2532
|
-
chrome[`item${i}`] = {
|
|
2961
|
+
const itemWidth = inner / n;
|
|
2962
|
+
chrome[`item${i}`] = {
|
|
2963
|
+
x: bar.x + TAB_BAR_ITEM_INSET + i * itemWidth,
|
|
2964
|
+
y: bar.y + TAB_BAR_ITEM_INSET,
|
|
2965
|
+
width: itemWidth,
|
|
2966
|
+
height: Math.max(0, capsuleHeight - 2 * TAB_BAR_ITEM_INSET),
|
|
2967
|
+
};
|
|
2533
2968
|
}
|
|
2534
2969
|
this.setChrome(node, chrome);
|
|
2535
2970
|
const selected = readSelected(node, n);
|
|
@@ -2775,9 +3210,9 @@ export class LayoutPass {
|
|
|
2775
3210
|
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
|
|
2776
3211
|
thumb: {
|
|
2777
3212
|
x: thumbCenter - SLIDER_THUMB / 2,
|
|
2778
|
-
y: rowY + (SLIDER_HEIGHT -
|
|
3213
|
+
y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
|
|
2779
3214
|
width: SLIDER_THUMB,
|
|
2780
|
-
height:
|
|
3215
|
+
height: SLIDER_THUMB_HEIGHT,
|
|
2781
3216
|
},
|
|
2782
3217
|
});
|
|
2783
3218
|
}
|
|
@@ -2847,9 +3282,10 @@ export class LayoutPass {
|
|
|
2847
3282
|
}
|
|
2848
3283
|
|
|
2849
3284
|
/**
|
|
2850
|
-
* `alert` style: a
|
|
2851
|
-
* (headline)
|
|
2852
|
-
*
|
|
3285
|
+
* `alert` style (iOS 26): a card centered in the safe area with the title
|
|
3286
|
+
* (headline) and the message (subheadline), leading-aligned, and capsule
|
|
3287
|
+
* buttons: two side by side (a `cancel` one first), otherwise stacked; no
|
|
3288
|
+
* buttons → an `ok` rect the renderer draws.
|
|
2853
3289
|
* `dialog` style: an action sheet of 57 rows in a card 8 from the screen
|
|
2854
3290
|
* edges, above a separate card for the `cancel` button, 8 above the bottom
|
|
2855
3291
|
* safe area; the title and message are a footnote caption on top. Button
|
|
@@ -2865,40 +3301,45 @@ export class LayoutPass {
|
|
|
2865
3301
|
const texts: Record<string, TextLayout> = {};
|
|
2866
3302
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
2867
3303
|
|
|
2868
|
-
const
|
|
3304
|
+
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
2869
3305
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
2870
3306
|
return {
|
|
2871
|
-
layout: { font, lines: wrapped.lines, alignment
|
|
3307
|
+
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
2872
3308
|
height: wrapped.lines.length * font.lineHeight,
|
|
2873
3309
|
};
|
|
2874
3310
|
};
|
|
3311
|
+
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
2875
3312
|
|
|
2876
3313
|
if (style === 'alert') {
|
|
2877
3314
|
const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
|
|
2878
3315
|
const textWidth = cardWidth - 2 * ALERT_PAD_H;
|
|
2879
3316
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
2880
|
-
const messageFont = resolveFont({ textStyle: '
|
|
3317
|
+
const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
|
|
2881
3318
|
// Heights first (the card is centered), then the rects.
|
|
2882
3319
|
let h = ALERT_PAD_TOP;
|
|
2883
3320
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
2884
3321
|
let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
2885
3322
|
if (title.length > 0) {
|
|
2886
|
-
const
|
|
2887
|
-
titleBlock = { ...
|
|
2888
|
-
h +=
|
|
3323
|
+
const b = block(title, titleFont, textWidth, 'leading');
|
|
3324
|
+
titleBlock = { ...b, top: h };
|
|
3325
|
+
h += b.height;
|
|
2889
3326
|
}
|
|
2890
3327
|
if (message.length > 0) {
|
|
2891
3328
|
if (titleBlock) h += ALERT_TITLE_GAP;
|
|
2892
|
-
const
|
|
2893
|
-
messageBlock = { ...
|
|
2894
|
-
h +=
|
|
3329
|
+
const b = block(message, messageFont, textWidth, 'leading');
|
|
3330
|
+
messageBlock = { ...b, top: h };
|
|
3331
|
+
h += b.height;
|
|
2895
3332
|
}
|
|
2896
|
-
h +=
|
|
3333
|
+
h += ALERT_BUTTONS_GAP;
|
|
2897
3334
|
const headerHeight = h;
|
|
2898
3335
|
const buttons = node.children;
|
|
2899
3336
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
2900
|
-
h += rows * ALERT_BUTTON_HEIGHT;
|
|
2901
|
-
|
|
3337
|
+
h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
|
|
3338
|
+
// Centered in the safe area, as iOS does.
|
|
3339
|
+
const { safeArea: inset } = this.env;
|
|
3340
|
+
const top = y + (y <= 0 ? inset.top : 0);
|
|
3341
|
+
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
3342
|
+
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
2902
3343
|
chrome['card'] = card;
|
|
2903
3344
|
if (titleBlock) {
|
|
2904
3345
|
chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
@@ -2909,18 +3350,28 @@ export class LayoutPass {
|
|
|
2909
3350
|
texts['message'] = messageBlock.layout;
|
|
2910
3351
|
}
|
|
2911
3352
|
const buttonsTop = card.y + headerHeight;
|
|
3353
|
+
const buttonsX = card.x + ALERT_BUTTON_INSET;
|
|
3354
|
+
const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
|
|
2912
3355
|
if (buttons.length === 0) {
|
|
2913
|
-
chrome['ok'] = { x:
|
|
3356
|
+
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
|
|
2914
3357
|
} else if (buttons.length === 2) {
|
|
2915
|
-
|
|
2916
|
-
buttons
|
|
2917
|
-
|
|
3358
|
+
// A `cancel` button goes first (leading), as iOS orders them.
|
|
3359
|
+
const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
|
|
3360
|
+
const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
|
|
3361
|
+
ordered.forEach((button, slot) => {
|
|
3362
|
+
const i = buttons.indexOf(button as LayoutNode);
|
|
3363
|
+
const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
|
|
2918
3364
|
chrome[`button${i}`] = rect;
|
|
2919
|
-
this.placeAlertButton(button, cctx, rect);
|
|
3365
|
+
this.placeAlertButton(button as LayoutNode, cctx, rect);
|
|
2920
3366
|
});
|
|
2921
3367
|
} else {
|
|
2922
3368
|
buttons.forEach((button, i) => {
|
|
2923
|
-
const rect: Rect = {
|
|
3369
|
+
const rect: Rect = {
|
|
3370
|
+
x: buttonsX,
|
|
3371
|
+
y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
|
|
3372
|
+
width: buttonsWidth,
|
|
3373
|
+
height: ALERT_BUTTON_HEIGHT,
|
|
3374
|
+
};
|
|
2924
3375
|
chrome[`button${i}`] = rect;
|
|
2925
3376
|
this.placeAlertButton(button, cctx, rect);
|
|
2926
3377
|
});
|
|
@@ -2943,7 +3394,7 @@ export class LayoutPass {
|
|
|
2943
3394
|
h += block.height;
|
|
2944
3395
|
}
|
|
2945
3396
|
if (message.length > 0) {
|
|
2946
|
-
if (titleBlock) h +=
|
|
3397
|
+
if (titleBlock) h += DIALOG_TITLE_GAP;
|
|
2947
3398
|
const block = centered(message, messageFont, textWidth);
|
|
2948
3399
|
messageBlock = { ...block, top: h };
|
|
2949
3400
|
h += block.height;
|
|
@@ -2961,7 +3412,7 @@ export class LayoutPass {
|
|
|
2961
3412
|
const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
2962
3413
|
chrome['cancelCard'] = cancelCard;
|
|
2963
3414
|
chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
|
|
2964
|
-
this.placeAlertButton(cancel, buttonCtx, cancelCard);
|
|
3415
|
+
this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
|
|
2965
3416
|
cardBottom = cancelCard.y - DIALOG_CARD_GAP;
|
|
2966
3417
|
}
|
|
2967
3418
|
const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
|
|
@@ -2981,7 +3432,7 @@ export class LayoutPass {
|
|
|
2981
3432
|
others.forEach((button, i) => {
|
|
2982
3433
|
const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
2983
3434
|
chrome[`button${node.children.indexOf(button)}`] = rect;
|
|
2984
|
-
this.placeAlertButton(button, buttonCtx, rect);
|
|
3435
|
+
this.placeAlertButton(button, buttonCtx, rect, true);
|
|
2985
3436
|
});
|
|
2986
3437
|
}
|
|
2987
3438
|
this.setChrome(node, chrome);
|
|
@@ -2989,10 +3440,11 @@ export class LayoutPass {
|
|
|
2989
3440
|
}
|
|
2990
3441
|
|
|
2991
3442
|
/** An alert button fills its row; its label is centered in it (any other child is centered as is). */
|
|
2992
|
-
private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect): void {
|
|
2993
|
-
const proposal: Proposal = { width: Math.max(0, rect.width - 2 *
|
|
2994
|
-
//
|
|
2995
|
-
|
|
3443
|
+
private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect, boldCancel = false): void {
|
|
3444
|
+
const proposal: Proposal = { width: Math.max(0, rect.width - 2 * DIALOG_LABEL_INSET), height: rect.height };
|
|
3445
|
+
// An action sheet's `cancel` button is bold: measure (and so draw) its
|
|
3446
|
+
// label semibold. iOS 26 alerts draw it regular.
|
|
3447
|
+
const cancel = boldCancel && str(unwrapStyled(child).props['role']) === 'cancel';
|
|
2996
3448
|
const inherited = this.childContext(child, ctx);
|
|
2997
3449
|
const cctx: Context = {
|
|
2998
3450
|
...inherited,
|
|
@@ -3017,9 +3469,16 @@ export class LayoutPass {
|
|
|
3017
3469
|
}
|
|
3018
3470
|
|
|
3019
3471
|
private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3020
|
-
//
|
|
3472
|
+
// A grabber gets its row; without one the content starts a little below
|
|
3473
|
+
// the sheet's top (iOS 26), except in a `full` one (Phase 8,
|
|
3474
|
+
// `.fullScreenCover`). A navigation stack fills the sheet from its top
|
|
3475
|
+
// instead, its bar taking that room (`barInset`), so its background
|
|
3476
|
+
// reaches the sheet's edge; a list or scroll view fills it below the
|
|
3477
|
+
// grabber row.
|
|
3478
|
+
const grabber = this.sheetHasGrabber(node);
|
|
3479
|
+
const floats = this.sheetFloats(node);
|
|
3021
3480
|
const full = this.sheetIsFull(node);
|
|
3022
|
-
const grabberRow = full ? 0 :
|
|
3481
|
+
const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
|
|
3023
3482
|
const content: Rect = {
|
|
3024
3483
|
x,
|
|
3025
3484
|
y: y + grabberRow,
|
|
@@ -3027,7 +3486,8 @@ export class LayoutPass {
|
|
|
3027
3486
|
height: Math.max(0, size.height - grabberRow),
|
|
3028
3487
|
};
|
|
3029
3488
|
const chrome: Record<string, Rect> = { content };
|
|
3030
|
-
if (
|
|
3489
|
+
if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
|
|
3490
|
+
if (grabber) {
|
|
3031
3491
|
chrome['grabber'] = {
|
|
3032
3492
|
x: x + (size.width - SHEET_GRABBER.width) / 2,
|
|
3033
3493
|
y: y + SHEET_GRABBER_TOP,
|
|
@@ -3040,8 +3500,14 @@ export class LayoutPass {
|
|
|
3040
3500
|
if (!child) return;
|
|
3041
3501
|
const proposal: Proposal = { width: content.width, height: content.height };
|
|
3042
3502
|
const kind = unwrapStyled(child).kind;
|
|
3043
|
-
if (kind === '
|
|
3044
|
-
this.place(child, cctx,
|
|
3503
|
+
if (kind === 'navstack') {
|
|
3504
|
+
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
if (kind === 'list' || kind === 'scrollview') {
|
|
3508
|
+
// A scrolling view fills the sheet below the grabber row, if any.
|
|
3509
|
+
const top = grabber ? SHEET_GRABBER_ROW : 0;
|
|
3510
|
+
this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
|
|
3045
3511
|
return;
|
|
3046
3512
|
}
|
|
3047
3513
|
const childSize = this.measure(child, cctx, proposal);
|
|
@@ -3389,7 +3855,9 @@ export class LayoutPass {
|
|
|
3389
3855
|
/**
|
|
3390
3856
|
* A linear gauge: width = proposal (100 when none), a 4px bar below an
|
|
3391
3857
|
* optional label row (label leading, current value label trailing, both in
|
|
3392
|
-
* the `caption` text style) with a 4px gap.
|
|
3858
|
+
* the `caption` text style) with a 4px gap. A circular gauge is a 58-point
|
|
3859
|
+
* square, whatever the proposal, with the current value label (else the
|
|
3860
|
+
* label) centered in the ring, proposed the square inside it.
|
|
3393
3861
|
*/
|
|
3394
3862
|
private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
|
|
3395
3863
|
return this.plan(node, p, () => {
|
|
@@ -3399,7 +3867,23 @@ export class LayoutPass {
|
|
|
3399
3867
|
let index = 0;
|
|
3400
3868
|
const label = node.props['hasLabel'] === true ? (node.children[index++] ?? null) : null;
|
|
3401
3869
|
const current = node.props['hasCurrentValueLabel'] === true ? (node.children[index++] ?? null) : null;
|
|
3870
|
+
const style = str(node.props['style']);
|
|
3871
|
+
if (style === 'accessoryCircular' || style === 'accessoryCircularCapacity') {
|
|
3872
|
+
const center = current ?? label;
|
|
3873
|
+
const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
|
|
3874
|
+
const centerSize = center ? this.measure(center, labelCtx, { width: inner, height: inner }) : ZERO;
|
|
3875
|
+
return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
|
|
3876
|
+
}
|
|
3402
3877
|
const proposal: Proposal = { width, height: null };
|
|
3878
|
+
if (style === 'progress') {
|
|
3879
|
+
// A ProgressView: the label in the context font above the bar, the
|
|
3880
|
+
// current value label (caption) under it.
|
|
3881
|
+
const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
|
|
3882
|
+
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3883
|
+
const labelRow = label ? labelSize.height + PROGRESS_LABEL_GAP : 0;
|
|
3884
|
+
const height = labelRow + GAUGE_BAR_HEIGHT + (current ? PROGRESS_VALUE_GAP + currentSize.height : 0);
|
|
3885
|
+
return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx, labelOwnCtx: cctx };
|
|
3886
|
+
}
|
|
3403
3887
|
const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
|
|
3404
3888
|
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3405
3889
|
const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
|
|
@@ -3410,7 +3894,30 @@ export class LayoutPass {
|
|
|
3410
3894
|
|
|
3411
3895
|
private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
3412
3896
|
const plan = this.gaugePlan(node, ctx, p);
|
|
3897
|
+
if (plan.ring) {
|
|
3898
|
+
const { center, centerSize } = plan.ring;
|
|
3899
|
+
const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
|
|
3900
|
+
if (center) {
|
|
3901
|
+
const { dx, dy } = alignIn(size, centerSize, 'center');
|
|
3902
|
+
this.place(center, plan.labelCtx, { width: inner, height: inner }, x + dx, y + dy);
|
|
3903
|
+
}
|
|
3904
|
+
// The other label is not shown (SwiftUI shows one in the middle).
|
|
3905
|
+
for (const other of [plan.label, plan.current]) if (other && other !== center) this.hide(other, node);
|
|
3906
|
+
this.setChrome(node, { ring: { x, y, width: size.width, height: size.height } });
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3413
3909
|
const proposal: Proposal = { width: size.width, height: null };
|
|
3910
|
+
if (str(node.props['style']) === 'progress') {
|
|
3911
|
+
if (plan.label) this.place(plan.label, plan.labelOwnCtx ?? plan.labelCtx, proposal, x, y);
|
|
3912
|
+
const barY = y + plan.labelRow;
|
|
3913
|
+
if (plan.current) this.place(plan.current, plan.labelCtx, proposal, x, barY + GAUGE_BAR_HEIGHT + PROGRESS_VALUE_GAP);
|
|
3914
|
+
const fraction = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
|
|
3915
|
+
this.setChrome(node, {
|
|
3916
|
+
track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
|
|
3917
|
+
fill: { x, y: barY, width: size.width * fraction, height: GAUGE_BAR_HEIGHT },
|
|
3918
|
+
});
|
|
3919
|
+
return;
|
|
3920
|
+
}
|
|
3414
3921
|
if (plan.label) {
|
|
3415
3922
|
this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
|
|
3416
3923
|
}
|
|
@@ -3464,13 +3971,13 @@ export class LayoutPass {
|
|
|
3464
3971
|
const axisInput = (axis: unknown, fallback: string, views: ChartItemPlan[], key: 'x0' | 'y0'): ChartAxisInput | null => {
|
|
3465
3972
|
const a = obj(axis);
|
|
3466
3973
|
if (!a) return null;
|
|
3467
|
-
const labels:
|
|
3974
|
+
const labels: ChartAxisInput['labels'] = [];
|
|
3468
3975
|
const rawLabels = Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
|
|
3469
3976
|
for (const raw of rawLabels) {
|
|
3470
3977
|
const label = obj(raw);
|
|
3471
3978
|
if (!label) continue;
|
|
3472
3979
|
const text = str(label['text']) ?? '';
|
|
3473
|
-
labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text) });
|
|
3980
|
+
labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text), leading: label['anchor'] === 'leading' });
|
|
3474
3981
|
}
|
|
3475
3982
|
return {
|
|
3476
3983
|
position: (str(a['position']) ?? fallback) as ChartAxisInput['position'],
|