@swiftbrowser/web 0.3.1 → 0.5.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 +4 -1
- package/app/index.html +1 -0
- package/package.json +5 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +98 -54
- package/src/layout/engine.ts +582 -135
- package/src/layout/index.ts +8 -5
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +87 -16
- package/src/page.css +234 -0
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +219 -9
- package/src/renderer.ts +355 -15
- package/src/scrollEdge.ts +109 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +457 -251
- package/src/symbols.ts +3 -1
- package/src/typography.ts +5 -2
package/src/layout/engine.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* those.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
|
|
22
|
+
import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textHeight, textStyleSize } from './typography';
|
|
23
23
|
import { wrapRuns, wrapText, type TextRunInput } from './text';
|
|
24
24
|
import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
|
|
25
25
|
import { videoSize } from './video';
|
|
@@ -65,13 +65,33 @@ export interface Context {
|
|
|
65
65
|
/**
|
|
66
66
|
* Height of the bar covering the bottom of the screen above the subtree
|
|
67
67
|
* (a `tabview`'s bar plus the bottom safe area), 0 outside tabs. Scrolling
|
|
68
|
-
* views that reach the screen bottom inset their content by it.
|
|
68
|
+
* views that reach the screen bottom inset their content by it. Inside a
|
|
69
|
+
* `navscreen` with a bottom toolbar or search field, the room they take
|
|
70
|
+
* (up from `bottomEdge`).
|
|
69
71
|
*/
|
|
70
72
|
bottomBar: number;
|
|
73
|
+
/** Where the bar `bottomBar` measures ends: the screen's bottom unless set (a `navscreen`'s bottom). */
|
|
74
|
+
bottomEdge?: number | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* iOS 26: inside a `navscreen` whose content is not itself a list or scroll
|
|
77
|
+
* view, its bar (the band from the screen's top to `top`, `x` to `x + width`):
|
|
78
|
+
* a vertical list or scroll view whose top edge touches it across the
|
|
79
|
+
* screen runs up under it (`scrollEdgeGrowth`).
|
|
80
|
+
*/
|
|
81
|
+
barAbove?: { top: number; height: number; x: number; width: number } | undefined;
|
|
71
82
|
/** Phase 6 `imageScale`: symbol images measure this fraction of the line height (1 by default). */
|
|
72
83
|
imageScale: number;
|
|
73
84
|
/** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
|
|
74
85
|
splitSidebar: boolean;
|
|
86
|
+
/** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
|
|
87
|
+
sidebarList?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Inside a list row (the whole subtree): a `Label` lays out in iOS's icon
|
|
90
|
+
* column, its icon centred 28 from the card's edge and its title 40 past
|
|
91
|
+
* the content edge, so titles and separators line up across rows whatever
|
|
92
|
+
* the symbols' widths, and with the edit-mode controls.
|
|
93
|
+
*/
|
|
94
|
+
labelColumn?: boolean;
|
|
75
95
|
/**
|
|
76
96
|
* Phase 8: the y (screen coordinates) where content clears the bars above
|
|
77
97
|
* it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
|
|
@@ -85,6 +105,12 @@ export interface Context {
|
|
|
85
105
|
* bar does at the root. Only the stack's own screens see it.
|
|
86
106
|
*/
|
|
87
107
|
barInset?: number;
|
|
108
|
+
/**
|
|
109
|
+
* The room a split view's sidebar toggle takes at the leading edge of the
|
|
110
|
+
* bar row of the column it sits in (that column's stack's screens shift
|
|
111
|
+
* their back button and leading items past it).
|
|
112
|
+
*/
|
|
113
|
+
barLeading?: number;
|
|
88
114
|
/**
|
|
89
115
|
* Phase 12: the size of the nearest container (`containerRelativeFrame`):
|
|
90
116
|
* the screen at the root, a scroll view's viewport inside one.
|
|
@@ -127,6 +153,9 @@ const TEXTFIELD_HEIGHT_PLAIN = 22;
|
|
|
127
153
|
/** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
|
|
128
154
|
const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
|
|
129
155
|
const NAV_BAR_HEIGHT = 44;
|
|
156
|
+
// iOS 26: under an inline title the content starts 10 below the bar's 44
|
|
157
|
+
// row (measured in the iOS 26.2 simulator); in a sheet it does not.
|
|
158
|
+
const NAV_BAR_INLINE_BOTTOM = 10;
|
|
130
159
|
// iOS 26: the large title's row is 62 (52 before).
|
|
131
160
|
const LARGE_TITLE_HEIGHT = 62;
|
|
132
161
|
/** Chevron glyph plus its gap, in px. */
|
|
@@ -146,30 +175,38 @@ const SHEET_MEDIUM_HEIGHT = 433;
|
|
|
146
175
|
const SHEET_CONTENT_TOP = 16;
|
|
147
176
|
const LIST_INSET = 16;
|
|
148
177
|
const LIST_CELL_PADDING = 16;
|
|
149
|
-
// iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity
|
|
150
|
-
// at least 52 tall with
|
|
151
|
-
// is a sentence-case headline-sized line that starts
|
|
152
|
-
// between cards and ends 10 above its card
|
|
153
|
-
|
|
178
|
+
// iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity, in thirds
|
|
179
|
+
// of a point): rows are at least 52 tall with 15 above and below their text;
|
|
180
|
+
// a section's header is a sentence-case headline-sized line that starts 8
|
|
181
|
+
// into the 35.67 gap between cards and ends 10 above its card; a footer is
|
|
182
|
+
// 7.33 below its card.
|
|
183
|
+
const LIST_ROW_VPAD = 15;
|
|
154
184
|
// A row holding just a toggle or a stepper has 10 above and below it (so
|
|
155
|
-
// both come out 52 tall)
|
|
185
|
+
// both come out 52 tall), a segmented picker 14.5 (61).
|
|
156
186
|
const LIST_ROW_CONTROL_VPAD = 10;
|
|
157
|
-
const LIST_ROW_SEGMENTED_VPAD = 14;
|
|
187
|
+
const LIST_ROW_SEGMENTED_VPAD = 14.5;
|
|
158
188
|
const LIST_ROW_MIN_HEIGHT = 52;
|
|
189
|
+
// iPadOS sidebar rows (`sidebar` style): at least 44 tall, 11 above and
|
|
190
|
+
// below their text.
|
|
191
|
+
const LIST_SIDEBAR_ROW_MIN_HEIGHT = 44;
|
|
192
|
+
const LIST_SIDEBAR_ROW_VPAD = 11;
|
|
159
193
|
const LIST_TOP = 18;
|
|
160
194
|
const LIST_BOTTOM = 20;
|
|
161
|
-
const LIST_HEADER_LIFT =
|
|
195
|
+
const LIST_HEADER_LIFT = 8;
|
|
162
196
|
const LIST_HEADER_GAP = 10;
|
|
163
|
-
const LIST_FOOTER_GAP =
|
|
164
|
-
const LIST_SECTION_GAP =
|
|
165
|
-
// Below a section's footer the gap to the next section is
|
|
166
|
-
const LIST_SECTION_GAP_AFTER_FOOTER =
|
|
197
|
+
const LIST_FOOTER_GAP = 22 / 3;
|
|
198
|
+
const LIST_SECTION_GAP = 107 / 3;
|
|
199
|
+
// Below a section's footer the gap to the next section is 11 shorter.
|
|
200
|
+
const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
|
|
167
201
|
const LIST_CHEVRON_WIDTH = 20;
|
|
168
202
|
// Phase 4.
|
|
169
|
-
// iOS 26: the tab bar is a 62-tall glass capsule floating
|
|
170
|
-
//
|
|
171
|
-
//
|
|
203
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 22 above the bottom
|
|
204
|
+
// edge (8 when the tab view ends above the screen bottom), its items in equal
|
|
205
|
+
// slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
|
|
206
|
+
// the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
|
|
207
|
+
// the width less 21 either side (four tabs fill that on an iPhone).
|
|
172
208
|
const TAB_BAR_HEIGHT = 62;
|
|
209
|
+
const TAB_BAR_ITEM_WIDTH = 268 / 3;
|
|
173
210
|
const TAB_BAR_MARGIN_H = 21;
|
|
174
211
|
const TAB_BAR_MARGIN_BOTTOM = 22;
|
|
175
212
|
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
@@ -209,7 +246,18 @@ const NAV_ITEM_MARGIN = 16;
|
|
|
209
246
|
const NAV_GLASS_SIZE = 44;
|
|
210
247
|
const NAV_GLASS_PAD_H = 12;
|
|
211
248
|
const NAV_GROUP_SPACING = 8;
|
|
212
|
-
|
|
249
|
+
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
250
|
+
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
251
|
+
// screen bottom (as the search field does) and 28 in from the sides.
|
|
252
|
+
// Consecutive items share a capsule: an icon takes a 50 slot, text its width
|
|
253
|
+
// and 18 either side, 6 between slots; a lone icon is a 48 circle.
|
|
254
|
+
const BOTTOM_GLASS_HEIGHT = 48;
|
|
255
|
+
const BOTTOM_GLASS_MARGIN_H = 28;
|
|
256
|
+
const BOTTOM_ICON_SLOT = 50;
|
|
257
|
+
const BOTTOM_TEXT_PAD_H = 18;
|
|
258
|
+
const BOTTOM_ITEM_GAP = 6;
|
|
259
|
+
/** A fixed `ToolbarSpacer` between two bottom groups. */
|
|
260
|
+
const BOTTOM_FIXED_SPACER = 8;
|
|
213
261
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
214
262
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
215
263
|
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
@@ -225,15 +273,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
|
225
273
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
226
274
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
227
275
|
const NAVSPLIT_SEPARATOR_WIDTH = 1;
|
|
276
|
+
/** `.inspector`: the trailing column's default width, and at most this share of the width. */
|
|
277
|
+
const INSPECTOR_WIDTH = 320;
|
|
278
|
+
const INSPECTOR_MAX_FRACTION = 0.5;
|
|
228
279
|
const EPSILON = 1e-6;
|
|
229
280
|
// Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
|
|
230
281
|
/** The segmented −/+ control of a `stepper`, trailing. */
|
|
231
282
|
const STEPPER_WIDTH = 94;
|
|
232
283
|
const STEPPER_HEIGHT = 32;
|
|
233
284
|
const STEPPER_GAP = 8;
|
|
234
|
-
/** A `slider` is
|
|
235
|
-
// iOS 26: a 6pt track and a 38 × 24 pill knob in a
|
|
236
|
-
|
|
285
|
+
/** A `slider` is 31 high (its label row, when present, sits above). */
|
|
286
|
+
// iOS 26: a 6pt track and a 38 × 24 pill knob in a 31 row (a form row
|
|
287
|
+
// holding one is 61: 31 + 2 × 15).
|
|
288
|
+
const SLIDER_HEIGHT = 31;
|
|
237
289
|
const SLIDER_TRACK_HEIGHT = 6;
|
|
238
290
|
const SLIDER_THUMB = 38;
|
|
239
291
|
const SLIDER_THUMB_HEIGHT = 24;
|
|
@@ -467,7 +519,7 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
467
519
|
}
|
|
468
520
|
|
|
469
521
|
/** The default vertical padding above and below a list row's content. */
|
|
470
|
-
function listRowVPad(row: LayoutNode): number {
|
|
522
|
+
function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
471
523
|
switch (row.kind) {
|
|
472
524
|
case 'picker':
|
|
473
525
|
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
@@ -475,7 +527,7 @@ function listRowVPad(row: LayoutNode): number {
|
|
|
475
527
|
case 'stepper':
|
|
476
528
|
return LIST_ROW_CONTROL_VPAD;
|
|
477
529
|
default:
|
|
478
|
-
return LIST_ROW_VPAD;
|
|
530
|
+
return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
|
|
479
531
|
}
|
|
480
532
|
}
|
|
481
533
|
|
|
@@ -530,7 +582,7 @@ interface GridItemSpec {
|
|
|
530
582
|
alignment: string | null;
|
|
531
583
|
}
|
|
532
584
|
|
|
533
|
-
/** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible
|
|
585
|
+
/** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
|
|
534
586
|
function readGridItems(value: unknown): GridItemSpec[] {
|
|
535
587
|
const items: GridItemSpec[] = [];
|
|
536
588
|
if (Array.isArray(value)) {
|
|
@@ -609,7 +661,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
|
|
|
609
661
|
}
|
|
610
662
|
|
|
611
663
|
/** Phase 8 wrappers whose first child is the row content they stand for. */
|
|
612
|
-
const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture']);
|
|
664
|
+
const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture', 'draggable', 'dropdestination']);
|
|
613
665
|
|
|
614
666
|
/**
|
|
615
667
|
* Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
|
|
@@ -625,8 +677,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
|
|
|
625
677
|
return null;
|
|
626
678
|
}
|
|
627
679
|
|
|
628
|
-
/**
|
|
680
|
+
/** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
|
|
681
|
+
const LIST_LABEL_ICON_CENTER = 12;
|
|
682
|
+
const LIST_LABEL_TITLE_X = 40;
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* A `Label` in its default style: an hstack of the marked icon (`labelIcon`
|
|
686
|
+
* on its `styled` wrapper) then the title. In a list row it takes iOS's icon
|
|
687
|
+
* column instead of the stack's spacing.
|
|
688
|
+
*/
|
|
689
|
+
function labelColumnOf(node: LayoutNode): { icon: LayoutNode; title: LayoutNode } | null {
|
|
690
|
+
if (node.kind !== 'hstack' || node.children.length !== 2) return null;
|
|
691
|
+
const [icon, title] = node.children as [LayoutNode, LayoutNode];
|
|
692
|
+
const style = icon.kind === 'styled' ? (icon.props['style'] as Record<string, unknown> | undefined) : undefined;
|
|
693
|
+
if (style?.['labelIcon'] !== true) return null;
|
|
694
|
+
return { icon, title };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Phase 8 edit mode: the width of a `listrow`'s leading control (the selection circle / minus) and of a movable row's trailing grip. */
|
|
629
698
|
export const LIST_EDIT_CONTROL_WIDTH = 44;
|
|
699
|
+
/**
|
|
700
|
+
* Edit mode: how far a control sits in from the card's edge (its 22 pt glyph
|
|
701
|
+
* is centred 28 from the edge, as iOS draws it) and by how much the content
|
|
702
|
+
* shifts right of the leading control, or narrows for the trailing grip.
|
|
703
|
+
*/
|
|
704
|
+
export const LIST_EDIT_CONTROL_INSET = 6;
|
|
705
|
+
export const LIST_EDIT_CONTROL_SHIFT = 40;
|
|
630
706
|
|
|
631
707
|
/** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
|
|
632
708
|
function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
@@ -634,7 +710,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
|
634
710
|
const size = font.size * scale;
|
|
635
711
|
const lineHeight = font.lineHeight * scale;
|
|
636
712
|
const px = (v: number): string => `${Math.round(v * 100) / 100}px`;
|
|
637
|
-
return { ...font, size, lineHeight, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
|
|
713
|
+
return { ...font, size, lineHeight, leading: font.leading * scale, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
|
|
638
714
|
}
|
|
639
715
|
|
|
640
716
|
// ---------------------------------------------------------------------------
|
|
@@ -847,8 +923,12 @@ interface ListRowPlan {
|
|
|
847
923
|
content: Rect;
|
|
848
924
|
/** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
|
|
849
925
|
badge: { rect: Rect; layout: TextLayout } | null;
|
|
850
|
-
/**
|
|
851
|
-
|
|
926
|
+
/**
|
|
927
|
+
* Phase 8 edit mode: the `listrow` the row is, its 44px leading control
|
|
928
|
+
* rect (a selectable or deletable row) and its 44px trailing grip rect (a
|
|
929
|
+
* movable row), relative to the list origin.
|
|
930
|
+
*/
|
|
931
|
+
edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
|
|
852
932
|
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
853
933
|
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
854
934
|
}
|
|
@@ -1006,11 +1086,13 @@ export class LayoutPass {
|
|
|
1006
1086
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
1007
1087
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
1008
1088
|
/**
|
|
1009
|
-
* A
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1089
|
+
* A navigation screen's root list or vertical scroll view starts at the top
|
|
1090
|
+
* of the screen and scrolls under its bar (`bar` tall): its content starts
|
|
1091
|
+
* below the bar and the large title row that scrolls with it, if any.
|
|
1092
|
+
* `nested`: a scroll view deeper in the screen's content that touches the
|
|
1093
|
+
* bar (`scrollEdgeGrowth`), rather than the screen's own content.
|
|
1012
1094
|
*/
|
|
1013
|
-
private readonly
|
|
1095
|
+
private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
|
|
1014
1096
|
|
|
1015
1097
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
1016
1098
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -1055,10 +1137,17 @@ export class LayoutPass {
|
|
|
1055
1137
|
const { screen, safeArea } = this.env;
|
|
1056
1138
|
const ctx = this.rootContext;
|
|
1057
1139
|
for (const root of roots) {
|
|
1058
|
-
if (
|
|
1059
|
-
|
|
1140
|
+
if (
|
|
1141
|
+
root.kind === 'navstack' ||
|
|
1142
|
+
root.kind === 'tabview' ||
|
|
1143
|
+
root.kind === 'navsplit' ||
|
|
1144
|
+
root.kind === 'inspector' ||
|
|
1145
|
+
root.kind === 'alert'
|
|
1146
|
+
) {
|
|
1147
|
+
// These ignore the safe areas: the tab bar and navigation bars
|
|
1060
1148
|
// extend into them and the content is inset instead (a `navsplit`
|
|
1061
|
-
// hands the horizontal insets to its columns, see `placeNavSplit
|
|
1149
|
+
// hands the horizontal insets to its columns, see `placeNavSplit`;
|
|
1150
|
+
// an `inspector` its trailing one to its panel).
|
|
1062
1151
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1063
1152
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1064
1153
|
} else if (root.kind === 'sheet') {
|
|
@@ -1073,6 +1162,14 @@ export class LayoutPass {
|
|
|
1073
1162
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
1074
1163
|
height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
|
|
1075
1164
|
};
|
|
1165
|
+
if (this.env.contentHeight) {
|
|
1166
|
+
// An island: the root takes its content's height and sits at the top.
|
|
1167
|
+
const proposal: Proposal = { width: safe.width, height: null };
|
|
1168
|
+
const size = this.measure(root, ctx, proposal);
|
|
1169
|
+
const { dx } = alignIn(safe, size, 'center');
|
|
1170
|
+
this.place(root, ctx, proposal, safeArea.leading + dx, safeArea.top);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1076
1173
|
const proposal: Proposal = { width: safe.width, height: safe.height };
|
|
1077
1174
|
const size = this.measure(root, ctx, proposal);
|
|
1078
1175
|
const { dx, dy } = alignIn(safe, size, 'center');
|
|
@@ -1082,6 +1179,32 @@ export class LayoutPass {
|
|
|
1082
1179
|
return this.finish();
|
|
1083
1180
|
}
|
|
1084
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* A `host` element (src/hostContent.ts): the page's DOM, measured by the
|
|
1184
|
+
* host measurer. `fill` takes the proposal and the content's own size on an
|
|
1185
|
+
* unconstrained axis; `content` takes the content's ideal width capped by
|
|
1186
|
+
* the proposal and its height at that width, like text. Without a measurer
|
|
1187
|
+
* (headless) it is a 10×10 placeholder, like a shape with no proposal.
|
|
1188
|
+
*/
|
|
1189
|
+
private measureHost(node: LayoutNode, p: Proposal): Size {
|
|
1190
|
+
const props = node.props as { sizing?: string; content?: string };
|
|
1191
|
+
const bounded = (value: number | null): number | null => (value !== null && Number.isFinite(value) ? value : null);
|
|
1192
|
+
const width = bounded(p.width);
|
|
1193
|
+
const height = bounded(p.height);
|
|
1194
|
+
const measure = this.measurer.measureHost?.bind(this.measurer);
|
|
1195
|
+
if (!measure || typeof props.content !== 'string') {
|
|
1196
|
+
return { width: width ?? SHAPE_DEFAULT, height: height ?? SHAPE_DEFAULT };
|
|
1197
|
+
}
|
|
1198
|
+
if (props.sizing === 'fill') {
|
|
1199
|
+
if (width !== null && height !== null) return { width, height };
|
|
1200
|
+
const natural = measure(props.content, width);
|
|
1201
|
+
return { width: width ?? natural.width, height: height ?? natural.height };
|
|
1202
|
+
}
|
|
1203
|
+
const ideal = measure(props.content, null);
|
|
1204
|
+
if (width === null || ideal.width <= width) return ideal;
|
|
1205
|
+
return { width, height: measure(props.content, width).height };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1085
1208
|
private finish(): LayoutResult {
|
|
1086
1209
|
// Only what this run produced needs snapping; the rest was snapped when
|
|
1087
1210
|
// it was produced.
|
|
@@ -1243,10 +1366,20 @@ export class LayoutPass {
|
|
|
1243
1366
|
return { ...ctx, stackAxis: 'vertical', listRow: false };
|
|
1244
1367
|
case 'hstack':
|
|
1245
1368
|
return { ...ctx, stackAxis: 'horizontal', listRow: false };
|
|
1369
|
+
case 'list':
|
|
1370
|
+
case 'scrollview':
|
|
1371
|
+
case 'sheet': {
|
|
1372
|
+
// A bar above is the outermost scroll view's to run under; what a
|
|
1373
|
+
// scroll view or a sheet holds is clear of it.
|
|
1374
|
+
if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
|
|
1375
|
+
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1376
|
+
}
|
|
1246
1377
|
case 'gesture':
|
|
1247
1378
|
case 'swipeactions':
|
|
1248
1379
|
case 'contextmenu':
|
|
1249
1380
|
case 'listrow':
|
|
1381
|
+
case 'draggable':
|
|
1382
|
+
case 'dropdestination':
|
|
1250
1383
|
// Phase 8 wrappers are transparent: a `navlink` under them is still
|
|
1251
1384
|
// the list row (its chevron, its selected fill).
|
|
1252
1385
|
return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
|
|
@@ -1327,8 +1460,11 @@ export class LayoutPass {
|
|
|
1327
1460
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
1328
1461
|
case 'vstack':
|
|
1329
1462
|
return this.stackPlan(node, ctx, p, 'vertical').size;
|
|
1330
|
-
case 'hstack':
|
|
1463
|
+
case 'hstack': {
|
|
1464
|
+
const label = ctx.labelColumn ? labelColumnOf(node) : null;
|
|
1465
|
+
if (label) return this.labelColumnPlan(label, this.childContext(node, ctx), p).size;
|
|
1331
1466
|
return this.stackPlan(node, ctx, p, 'horizontal').size;
|
|
1467
|
+
}
|
|
1332
1468
|
case 'zstack':
|
|
1333
1469
|
return this.measureZStack(node, ctx, p);
|
|
1334
1470
|
case 'styled':
|
|
@@ -1370,6 +1506,8 @@ export class LayoutPass {
|
|
|
1370
1506
|
case 'tabview':
|
|
1371
1507
|
case 'tab':
|
|
1372
1508
|
case 'navsplit':
|
|
1509
|
+
case 'inspector':
|
|
1510
|
+
case 'inspectorpanel':
|
|
1373
1511
|
return {
|
|
1374
1512
|
width: p.width ?? this.env.screen.width,
|
|
1375
1513
|
height: p.height ?? this.env.screen.height,
|
|
@@ -1378,6 +1516,8 @@ export class LayoutPass {
|
|
|
1378
1516
|
return this.viewThatFitsPlan(node, ctx, p).size;
|
|
1379
1517
|
case 'picker':
|
|
1380
1518
|
return this.pickerPlan(node, ctx, p).size;
|
|
1519
|
+
case 'host':
|
|
1520
|
+
return this.measureHost(node, p);
|
|
1381
1521
|
case 'geometry':
|
|
1382
1522
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1383
1523
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
@@ -1400,6 +1540,7 @@ export class LayoutPass {
|
|
|
1400
1540
|
case 'grid':
|
|
1401
1541
|
return this.gridPlan(node, ctx, p).size;
|
|
1402
1542
|
case 'lazyvgrid':
|
|
1543
|
+
case 'lazyhgrid':
|
|
1403
1544
|
return this.lazyGridPlan(node, ctx, p).size;
|
|
1404
1545
|
case 'gauge':
|
|
1405
1546
|
return this.gaugePlan(node, ctx, p).size;
|
|
@@ -1435,6 +1576,8 @@ export class LayoutPass {
|
|
|
1435
1576
|
case 'gesture':
|
|
1436
1577
|
case 'listrow':
|
|
1437
1578
|
case 'tapgesture':
|
|
1579
|
+
case 'draggable':
|
|
1580
|
+
case 'dropdestination':
|
|
1438
1581
|
default: {
|
|
1439
1582
|
// `tapgesture` (its child's size), `toolbar` outside a navscreen,
|
|
1440
1583
|
// `section` outside a list and unknown kinds: lay the children out as a vstack.
|
|
@@ -1457,7 +1600,7 @@ export class LayoutPass {
|
|
|
1457
1600
|
const min = Math.max(1, ctx.minLines ?? 1);
|
|
1458
1601
|
const max = Math.max(min, ctx.lineLimit ?? Infinity);
|
|
1459
1602
|
const lines = Math.min(Math.max(wrapped.lines.length, min), max);
|
|
1460
|
-
return { width, height: lines
|
|
1603
|
+
return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
|
|
1461
1604
|
}
|
|
1462
1605
|
|
|
1463
1606
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
@@ -1467,7 +1610,7 @@ export class LayoutPass {
|
|
|
1467
1610
|
const text = str(node.props['text']) ?? '';
|
|
1468
1611
|
const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1469
1612
|
return {
|
|
1470
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
1613
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
|
|
1471
1614
|
layout: {
|
|
1472
1615
|
font: ctx.font,
|
|
1473
1616
|
lines: wrapped.lines,
|
|
@@ -1506,16 +1649,16 @@ export class LayoutPass {
|
|
|
1506
1649
|
};
|
|
1507
1650
|
}
|
|
1508
1651
|
|
|
1509
|
-
/** An image in a text: a bitmap or remote image at its size, a symbol
|
|
1652
|
+
/** An image in a text: a bitmap or remote image at its size, a symbol its font's symbol square. */
|
|
1510
1653
|
private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
|
|
1511
1654
|
if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
|
|
1512
1655
|
return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
|
|
1513
1656
|
}
|
|
1514
1657
|
const name = str(props['name']);
|
|
1515
|
-
if (name === null) return square(font.
|
|
1658
|
+
if (name === null) return square(font.leading);
|
|
1516
1659
|
const asset = this.env.assets?.images[name];
|
|
1517
1660
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1518
|
-
if (asset.kind === 'symbol') return square(font.
|
|
1661
|
+
if (asset.kind === 'symbol') return square(font.leading);
|
|
1519
1662
|
return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
|
|
1520
1663
|
}
|
|
1521
1664
|
|
|
@@ -1775,6 +1918,25 @@ export class LayoutPass {
|
|
|
1775
1918
|
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1776
1919
|
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1777
1920
|
*/
|
|
1921
|
+
/** A list row's `Label` in iOS's icon column: the icon at its own size, the title in what is left past 40. */
|
|
1922
|
+
private labelColumnPlan(
|
|
1923
|
+
label: { icon: LayoutNode; title: LayoutNode },
|
|
1924
|
+
cctx: Context,
|
|
1925
|
+
p: Proposal,
|
|
1926
|
+
): { size: Size; icon: Size; title: Size; iconProposal: Proposal; titleProposal: Proposal } {
|
|
1927
|
+
const iconProposal: Proposal = { width: null, height: p.height };
|
|
1928
|
+
const icon = this.measure(label.icon, cctx, iconProposal);
|
|
1929
|
+
const titleProposal: Proposal = { width: finite(p.width) ? Math.max(0, p.width - LIST_LABEL_TITLE_X) : p.width, height: p.height };
|
|
1930
|
+
const title = this.measure(label.title, cctx, titleProposal);
|
|
1931
|
+
return {
|
|
1932
|
+
size: { width: LIST_LABEL_TITLE_X + title.width, height: Math.max(icon.height, title.height) },
|
|
1933
|
+
icon,
|
|
1934
|
+
title,
|
|
1935
|
+
iconProposal,
|
|
1936
|
+
titleProposal,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1778
1940
|
private layoutStack(
|
|
1779
1941
|
children: readonly LayoutNode[],
|
|
1780
1942
|
cctx: Context,
|
|
@@ -1947,10 +2109,10 @@ export class LayoutPass {
|
|
|
1947
2109
|
return { width: p.width ?? width, height: p.height ?? height };
|
|
1948
2110
|
}
|
|
1949
2111
|
const name = str(node.props['name']);
|
|
1950
|
-
if (name === null) return square(ctx.font.
|
|
2112
|
+
if (name === null) return square(ctx.font.leading * ctx.imageScale);
|
|
1951
2113
|
const asset = this.env.assets?.images[name];
|
|
1952
2114
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1953
|
-
if (asset.kind === 'symbol') return square(ctx.font.
|
|
2115
|
+
if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
|
|
1954
2116
|
const width = num(asset.width) ?? 0;
|
|
1955
2117
|
const height = num(asset.height) ?? 0;
|
|
1956
2118
|
if (node.props['resizable'] !== true) return { width, height };
|
|
@@ -2153,11 +2315,13 @@ export class LayoutPass {
|
|
|
2153
2315
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
2154
2316
|
// `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
|
|
2155
2317
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
2156
|
-
// the space between sections.
|
|
2318
|
+
// the space between sections. `sidebar` (iPadOS) is laid out like
|
|
2319
|
+
// `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
|
|
2157
2320
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
const
|
|
2321
|
+
const sidebarStyle = listStyle === 'sidebar';
|
|
2322
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
|
|
2323
|
+
const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
2324
|
+
const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
|
|
2161
2325
|
const plain = listStyle === 'plain';
|
|
2162
2326
|
const listTop = plain ? 0 : LIST_TOP;
|
|
2163
2327
|
const listBottom = plain ? 0 : LIST_BOTTOM;
|
|
@@ -2177,9 +2341,10 @@ export class LayoutPass {
|
|
|
2177
2341
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
2178
2342
|
400,
|
|
2179
2343
|
);
|
|
2180
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2181
|
-
// Phase 8: in edit mode
|
|
2182
|
-
//
|
|
2344
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2345
|
+
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
2346
|
+
// leading control (6 in from the card's edge) and its content shifts
|
|
2347
|
+
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
2183
2348
|
const editing = node.props['editing'] === true;
|
|
2184
2349
|
|
|
2185
2350
|
// Group consecutive plain rows into an implicit section. A section's
|
|
@@ -2215,7 +2380,7 @@ export class LayoutPass {
|
|
|
2215
2380
|
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
2216
2381
|
return {
|
|
2217
2382
|
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2218
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
2383
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
|
|
2219
2384
|
};
|
|
2220
2385
|
};
|
|
2221
2386
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
@@ -2256,7 +2421,7 @@ export class LayoutPass {
|
|
|
2256
2421
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2257
2422
|
// width (plus a gap) at the trailing edge.
|
|
2258
2423
|
const hints = rowHintsOf(rowNode);
|
|
2259
|
-
const defaultVPad = listRowVPad(unwrapStyled(rowNode));
|
|
2424
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
|
|
2260
2425
|
const insets: EdgeInsets = hints.insets ?? {
|
|
2261
2426
|
top: defaultVPad,
|
|
2262
2427
|
leading: LIST_CELL_PADDING,
|
|
@@ -2265,10 +2430,16 @@ export class LayoutPass {
|
|
|
2265
2430
|
};
|
|
2266
2431
|
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
|
|
2267
2432
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
2433
|
+
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
2434
|
+
// leading control and its content shifts right; a movable one a
|
|
2435
|
+
// trailing grip, which narrows its content.
|
|
2268
2436
|
const listRow = editing ? listRowOf(rowNode) : null;
|
|
2269
|
-
const
|
|
2437
|
+
const hasControl = listRow !== null && (listRow.props['selectable'] === true || listRow.props['deletable'] === true);
|
|
2438
|
+
const hasGrip = listRow !== null && listRow.props['movable'] === true;
|
|
2439
|
+
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2440
|
+
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2270
2441
|
const rowContentX = cardX + insets.leading + editShift;
|
|
2271
|
-
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
|
|
2442
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
|
|
2272
2443
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
2273
2444
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
2274
2445
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -2291,7 +2462,15 @@ export class LayoutPass {
|
|
|
2291
2462
|
layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
|
|
2292
2463
|
}
|
|
2293
2464
|
: null,
|
|
2294
|
-
edit: listRow
|
|
2465
|
+
edit: listRow
|
|
2466
|
+
? {
|
|
2467
|
+
listRow,
|
|
2468
|
+
control: hasControl ? { x: cardX + LIST_EDIT_CONTROL_INSET, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } : null,
|
|
2469
|
+
reorder: hasGrip
|
|
2470
|
+
? { x: cardX + cardWidth - LIST_EDIT_CONTROL_INSET - LIST_EDIT_CONTROL_WIDTH, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight }
|
|
2471
|
+
: null,
|
|
2472
|
+
}
|
|
2473
|
+
: null,
|
|
2295
2474
|
separatorHidden: hints.separatorHidden,
|
|
2296
2475
|
});
|
|
2297
2476
|
y += rowHeight;
|
|
@@ -2361,6 +2540,11 @@ export class LayoutPass {
|
|
|
2361
2540
|
}
|
|
2362
2541
|
this.hiddenBy.delete(node);
|
|
2363
2542
|
}
|
|
2543
|
+
const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
|
|
2544
|
+
if (grown) {
|
|
2545
|
+
p = grown.p;
|
|
2546
|
+
y = grown.y;
|
|
2547
|
+
}
|
|
2364
2548
|
const size = this.measure(node, ctx, p);
|
|
2365
2549
|
this.setFrame(node, x, y, size);
|
|
2366
2550
|
const cctx = this.childContext(node, ctx);
|
|
@@ -2382,9 +2566,18 @@ export class LayoutPass {
|
|
|
2382
2566
|
case 'shape':
|
|
2383
2567
|
case 'canvas':
|
|
2384
2568
|
case 'video':
|
|
2569
|
+
case 'host':
|
|
2385
2570
|
return;
|
|
2386
2571
|
case 'vstack':
|
|
2387
2572
|
case 'hstack': {
|
|
2573
|
+
const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
|
|
2574
|
+
if (label) {
|
|
2575
|
+
// iOS's list icon column: the icon centred at 12, the title at 40.
|
|
2576
|
+
const plan = this.labelColumnPlan(label, cctx, p);
|
|
2577
|
+
this.place(label.icon, cctx, plan.iconProposal, x + LIST_LABEL_ICON_CENTER - plan.icon.width / 2, y + (size.height - plan.icon.height) / 2);
|
|
2578
|
+
this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2388
2581
|
const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
|
|
2389
2582
|
const plan = this.stackPlan(node, ctx, p, axis);
|
|
2390
2583
|
const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
@@ -2503,8 +2696,9 @@ export class LayoutPass {
|
|
|
2503
2696
|
};
|
|
2504
2697
|
// Phase 7: the selected row of a `navsplit` sidebar has no chevron
|
|
2505
2698
|
// (its label still reserves the chevron's width so selecting a row
|
|
2506
|
-
// never reflows it); elsewhere `selected` keeps the chevron.
|
|
2507
|
-
|
|
2699
|
+
// never reflows it); elsewhere `selected` keeps the chevron. No row
|
|
2700
|
+
// of a `sidebar`-style list has one (iPadOS).
|
|
2701
|
+
if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
|
|
2508
2702
|
chrome['chevron'] = {
|
|
2509
2703
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
2510
2704
|
y,
|
|
@@ -2524,18 +2718,20 @@ export class LayoutPass {
|
|
|
2524
2718
|
// centered on the other axes when smaller than the viewport.
|
|
2525
2719
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2526
2720
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2527
|
-
// Under
|
|
2528
|
-
|
|
2721
|
+
// Under its screen's bar (and the large title that scrolls with it),
|
|
2722
|
+
// the content starts below them.
|
|
2723
|
+
const inset = axes.vertical ? this.scrollInsets.get(node) : undefined;
|
|
2529
2724
|
const content: Rect = {
|
|
2530
2725
|
x: x + dx,
|
|
2531
|
-
y: y + dy + (
|
|
2726
|
+
y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
|
|
2532
2727
|
width: plan.contentSize.width,
|
|
2533
2728
|
height: plan.contentSize.height,
|
|
2534
2729
|
};
|
|
2535
2730
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2536
2731
|
const scrollChrome: Record<string, Rect> = { content };
|
|
2537
2732
|
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2538
|
-
if (
|
|
2733
|
+
if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
|
|
2734
|
+
if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
|
|
2539
2735
|
this.setChrome(node, scrollChrome);
|
|
2540
2736
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2541
2737
|
return;
|
|
@@ -2559,6 +2755,12 @@ export class LayoutPass {
|
|
|
2559
2755
|
case 'navsplit':
|
|
2560
2756
|
this.placeNavSplit(node, cctx, x, y, size);
|
|
2561
2757
|
return;
|
|
2758
|
+
case 'inspector':
|
|
2759
|
+
this.placeInspector(node, cctx, x, y, size);
|
|
2760
|
+
return;
|
|
2761
|
+
case 'inspectorpanel':
|
|
2762
|
+
this.placeInspectorPanel(node, cctx, x, y, size);
|
|
2763
|
+
return;
|
|
2562
2764
|
case 'viewthatfits': {
|
|
2563
2765
|
// Phase 7: only the chosen candidate is placed (at the origin, the
|
|
2564
2766
|
// element is its size); the others have no frame and are hidden.
|
|
@@ -2591,6 +2793,7 @@ export class LayoutPass {
|
|
|
2591
2793
|
this.placeGrid(node, ctx, p, x, y);
|
|
2592
2794
|
return;
|
|
2593
2795
|
case 'lazyvgrid':
|
|
2796
|
+
case 'lazyhgrid':
|
|
2594
2797
|
this.placeLazyGrid(node, ctx, p, x, y);
|
|
2595
2798
|
return;
|
|
2596
2799
|
case 'gauge':
|
|
@@ -2654,8 +2857,10 @@ export class LayoutPass {
|
|
|
2654
2857
|
case 'gesture':
|
|
2655
2858
|
case 'listrow':
|
|
2656
2859
|
case 'tapgesture':
|
|
2860
|
+
case 'draggable':
|
|
2861
|
+
case 'dropdestination':
|
|
2657
2862
|
default: {
|
|
2658
|
-
// `tapgesture`, `gesture`, `listrow` (child at the same origin), `toolbar` outside a navscreen,
|
|
2863
|
+
// `tapgesture`, `gesture`, `listrow`, `draggable`, `dropdestination` (child at the same origin), `toolbar` outside a navscreen,
|
|
2659
2864
|
// `tab` outside a tabview, `section` outside a list and unknown kinds.
|
|
2660
2865
|
const content = this.contentOf(node);
|
|
2661
2866
|
if (content) this.place(content, cctx, p, x, y);
|
|
@@ -2664,26 +2869,71 @@ export class LayoutPass {
|
|
|
2664
2869
|
}
|
|
2665
2870
|
}
|
|
2666
2871
|
|
|
2667
|
-
/**
|
|
2872
|
+
/**
|
|
2873
|
+
* The part of a scrolling view's frame that a tab bar (or its screen's
|
|
2874
|
+
* bottom toolbar or search field) covers (chrome `bottomInset`).
|
|
2875
|
+
*/
|
|
2668
2876
|
private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
|
|
2669
2877
|
if (ctx.bottomBar <= 0) return null;
|
|
2670
2878
|
const bottom = y + size.height;
|
|
2671
|
-
if (bottom < this.env.screen.height - EPSILON) return null;
|
|
2879
|
+
if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
|
|
2672
2880
|
const height = Math.min(size.height, ctx.bottomBar);
|
|
2673
2881
|
return { x, y: bottom - height, width: size.width, height };
|
|
2674
2882
|
}
|
|
2675
2883
|
|
|
2884
|
+
/**
|
|
2885
|
+
* iOS 26: a vertical list or scroll view deeper in a screen's content
|
|
2886
|
+
* whose top edge touches the screen's bar across the screen (`barAbove`)
|
|
2887
|
+
* runs up under it, and one whose bottom edge touches a bar covering the
|
|
2888
|
+
* bottom (`bottomBar`: a tab bar, a bottom toolbar or search field) runs
|
|
2889
|
+
* down under that, as the screen's own scroll view does: the proposal and
|
|
2890
|
+
* frame grow by the bar (the parent's layout is unchanged, as for
|
|
2891
|
+
* `.ignoresSafeArea`) and the content is inset by it (chrome `topInset` /
|
|
2892
|
+
* `bottomInset`), so it scrolls under the bar, where the scroll edge
|
|
2893
|
+
* effect shows (styles.css). Null when nothing grows.
|
|
2894
|
+
*/
|
|
2895
|
+
private scrollEdgeGrowth(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): { p: Proposal; y: number } | null {
|
|
2896
|
+
if (node.kind !== 'list' && !(node.kind === 'scrollview' && this.scrollAxes(node).vertical)) return null;
|
|
2897
|
+
const inset = this.scrollInsets.get(node);
|
|
2898
|
+
const bar = ctx.barAbove;
|
|
2899
|
+
// The screen's own scroll view is inset by `placeNavScreen` (no bar above
|
|
2900
|
+
// here); a nested one that no longer touches the bar loses its inset.
|
|
2901
|
+
if (inset?.nested) this.scrollInsets.delete(node);
|
|
2902
|
+
if (!finite(p.height)) return null;
|
|
2903
|
+
const size = this.measure(node, ctx, p);
|
|
2904
|
+
let top = 0;
|
|
2905
|
+
if (
|
|
2906
|
+
bar &&
|
|
2907
|
+
!(inset && !inset.nested) &&
|
|
2908
|
+
Math.abs(y - bar.top) <= EPSILON &&
|
|
2909
|
+
x <= bar.x + EPSILON &&
|
|
2910
|
+
x + size.width >= bar.x + bar.width - EPSILON
|
|
2911
|
+
) {
|
|
2912
|
+
top = bar.height;
|
|
2913
|
+
this.scrollInsets.set(node, { bar: top, largeTitle: null, nested: true });
|
|
2914
|
+
}
|
|
2915
|
+
let bottom = 0;
|
|
2916
|
+
if (ctx.bottomBar > 0) {
|
|
2917
|
+
const edge = ctx.bottomEdge ?? this.env.screen.height;
|
|
2918
|
+
if (Math.abs(y + size.height - (edge - ctx.bottomBar)) <= EPSILON) bottom = ctx.bottomBar;
|
|
2919
|
+
}
|
|
2920
|
+
if (top === 0 && bottom === 0) return null;
|
|
2921
|
+
return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2676
2924
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2677
2925
|
const plan = this.listPlan(node, ctx, p);
|
|
2678
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2926
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2679
2927
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2680
|
-
// Under
|
|
2681
|
-
|
|
2928
|
+
// Under its screen's bar (and the large title that scrolls with it), the
|
|
2929
|
+
// rows start below them.
|
|
2930
|
+
const scrollInset = this.scrollInsets.get(node);
|
|
2682
2931
|
const listChrome: Record<string, Rect> = {};
|
|
2683
2932
|
if (covered) listChrome['bottomInset'] = covered;
|
|
2684
|
-
if (
|
|
2933
|
+
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
2934
|
+
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
2685
2935
|
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2686
|
-
const inset =
|
|
2936
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
|
|
2687
2937
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2688
2938
|
y += inset;
|
|
2689
2939
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
@@ -2723,8 +2973,11 @@ export class LayoutPass {
|
|
|
2723
2973
|
// Phase 8: the edit control belongs to the `listrow`, which is the
|
|
2724
2974
|
// row itself or sits under its wrappers (its own place() keeps chrome).
|
|
2725
2975
|
if (row.edit) {
|
|
2726
|
-
|
|
2727
|
-
|
|
2976
|
+
const controls: Record<string, Rect> = {};
|
|
2977
|
+
if (row.edit.control) controls['editControl'] = offset(row.edit.control);
|
|
2978
|
+
if (row.edit.reorder) controls['reorderControl'] = offset(row.edit.reorder);
|
|
2979
|
+
if (row.edit.listRow === row.node) Object.assign(chrome, controls);
|
|
2980
|
+
else this.setChrome(row.edit.listRow, controls);
|
|
2728
2981
|
}
|
|
2729
2982
|
const rowX = x + row.content.x;
|
|
2730
2983
|
const rowY = y + row.content.y;
|
|
@@ -2763,13 +3016,14 @@ export class LayoutPass {
|
|
|
2763
3016
|
/**
|
|
2764
3017
|
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
2765
3018
|
* title); a Phase 6 `searchfield` is a capsule floating above the bottom
|
|
2766
|
-
* (iOS 26);
|
|
2767
|
-
* content is proposed the
|
|
2768
|
-
* items are measured at their ideal size and laid out in iOS
|
|
2769
|
-
* capsules (44-tall slots, groups 8 apart) in the 44 row
|
|
2770
|
-
*
|
|
2771
|
-
*
|
|
2772
|
-
*
|
|
3019
|
+
* (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
|
|
3020
|
+
* above the bottom (`placeBottomToolbars`); the content is proposed the
|
|
3021
|
+
* rest. Toolbar items are measured at their ideal size and laid out in iOS
|
|
3022
|
+
* 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
|
|
3023
|
+
* (`leading` after the back button, `trailing` 16 from the trailing edge,
|
|
3024
|
+
* `principal` centered in place of the title). A `ToolbarSpacer` (an empty
|
|
3025
|
+
* toolbar with `spacer`) only separates groups there. The inline title
|
|
3026
|
+
* (`chrome.title`) is centered and truncated to the room the bar items leave.
|
|
2773
3027
|
*/
|
|
2774
3028
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2775
3029
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
@@ -2777,14 +3031,16 @@ export class LayoutPass {
|
|
|
2777
3031
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2778
3032
|
const depth = num(node.props['depth']) ?? 0;
|
|
2779
3033
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2780
|
-
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
3034
|
+
const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
2781
3035
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2782
3036
|
const parts = this.navScreenParts(node);
|
|
2783
3037
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3038
|
+
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
2784
3039
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
2785
|
-
const bottomBarHeight =
|
|
2786
|
-
bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
|
|
2787
3040
|
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
3041
|
+
const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
|
|
3042
|
+
? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
|
|
3043
|
+
: 0;
|
|
2788
3044
|
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
2789
3045
|
const contentTop = barHeight + largeHeight;
|
|
2790
3046
|
const content: Rect = {
|
|
@@ -2795,6 +3051,8 @@ export class LayoutPass {
|
|
|
2795
3051
|
};
|
|
2796
3052
|
const chrome: Record<string, Rect> = {
|
|
2797
3053
|
bar: { x, y, width: size.width, height: barHeight },
|
|
3054
|
+
// The 44 row the bar's items sit in, under the status bar.
|
|
3055
|
+
barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
|
|
2798
3056
|
content,
|
|
2799
3057
|
};
|
|
2800
3058
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
|
|
@@ -2824,7 +3082,11 @@ export class LayoutPass {
|
|
|
2824
3082
|
slots: number[] | null;
|
|
2825
3083
|
total: number;
|
|
2826
3084
|
}
|
|
2827
|
-
|
|
3085
|
+
// The navigation bar's groups; spacers there only separate them (the
|
|
3086
|
+
// groups are 8 apart anyway) and draw nothing.
|
|
3087
|
+
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3088
|
+
const barToolbars = parts.toolbars.filter((t) => str(t.props['placement']) !== 'bottom' && !isSpacer(t));
|
|
3089
|
+
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
2828
3090
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
2829
3091
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
2830
3092
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
@@ -2844,13 +3106,14 @@ export class LayoutPass {
|
|
|
2844
3106
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2845
3107
|
// title no longer shows).
|
|
2846
3108
|
const backLabel = '';
|
|
3109
|
+
const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
|
|
2847
3110
|
if (depth > 0) {
|
|
2848
|
-
chrome['back'] = { x:
|
|
3111
|
+
chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2849
3112
|
}
|
|
2850
3113
|
|
|
2851
3114
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2852
3115
|
const back = chrome['back'];
|
|
2853
|
-
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING :
|
|
3116
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
2854
3117
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2855
3118
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2856
3119
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -2878,19 +3141,6 @@ export class LayoutPass {
|
|
|
2878
3141
|
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
|
|
2879
3142
|
break;
|
|
2880
3143
|
}
|
|
2881
|
-
case 'bottom': {
|
|
2882
|
-
const bar = chrome['bottomBar'] as Rect;
|
|
2883
|
-
this.setFrame(group.toolbar, bar.x, bar.y, bar);
|
|
2884
|
-
const n = group.sizes.length;
|
|
2885
|
-
const inner = Math.max(0, size.width - 2 * NAV_ITEM_MARGIN);
|
|
2886
|
-
const itemsWidth = group.total - Math.max(0, n - 1) * NAV_ITEM_SPACING;
|
|
2887
|
-
if (n === 1) placeGroup(group, x + (size.width - itemsWidth) / 2, bar.y, BOTTOM_BAR_HEIGHT, 0);
|
|
2888
|
-
else if (n > 1) {
|
|
2889
|
-
const gap = Math.max(NAV_ITEM_SPACING, (inner - itemsWidth) / (n - 1));
|
|
2890
|
-
placeGroup(group, x + NAV_ITEM_MARGIN, bar.y, BOTTOM_BAR_HEIGHT, gap);
|
|
2891
|
-
}
|
|
2892
|
-
break;
|
|
2893
|
-
}
|
|
2894
3144
|
default: {
|
|
2895
3145
|
const startX = trailingEdge - group.total;
|
|
2896
3146
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
@@ -2900,6 +3150,9 @@ export class LayoutPass {
|
|
|
2900
3150
|
}
|
|
2901
3151
|
}
|
|
2902
3152
|
}
|
|
3153
|
+
if (bottomBarHeight > 0) {
|
|
3154
|
+
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3155
|
+
}
|
|
2903
3156
|
if (parts.search) {
|
|
2904
3157
|
// The field is the whole capsule (its own measure is a 36pt field).
|
|
2905
3158
|
const field = chrome['searchField'] as Rect;
|
|
@@ -2921,7 +3174,11 @@ export class LayoutPass {
|
|
|
2921
3174
|
// A large title that scrolls away collapses into the inline title, which
|
|
2922
3175
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2923
3176
|
if ((!large || titleScrolls) && !principal) {
|
|
2924
|
-
const leftUsed = Math.max(
|
|
3177
|
+
const leftUsed = Math.max(
|
|
3178
|
+
back ? back.x + back.width - x : 0,
|
|
3179
|
+
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
3180
|
+
cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
|
|
3181
|
+
);
|
|
2925
3182
|
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2926
3183
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2927
3184
|
const available = Math.max(0, size.width - 2 * side);
|
|
@@ -2950,23 +3207,46 @@ export class LayoutPass {
|
|
|
2950
3207
|
|
|
2951
3208
|
const child = parts.content;
|
|
2952
3209
|
if (!child) return;
|
|
3210
|
+
// The room a bottom toolbar or search field takes; scroll views run under
|
|
3211
|
+
// it (iOS 26), other content stops above it.
|
|
3212
|
+
const bottomCover = Math.max(bottomBarHeight, searchBottom);
|
|
2953
3213
|
// Under a tab bar the bar (which includes the bottom safe area) is what
|
|
2954
3214
|
// non-scrolling content must clear; a bottom toolbar already excluded it.
|
|
2955
3215
|
const bottomInset =
|
|
2956
3216
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2957
3217
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2958
3218
|
// large title / search field), which is transparent over it.
|
|
2959
|
-
const contentCtx: Context = {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
3219
|
+
const contentCtx: Context = {
|
|
3220
|
+
...cctx,
|
|
3221
|
+
safeTop: content.y,
|
|
3222
|
+
barInset: 0,
|
|
3223
|
+
barLeading: 0,
|
|
3224
|
+
barAbove: undefined,
|
|
3225
|
+
...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
|
|
3226
|
+
};
|
|
3227
|
+
const scrollsUnderBar =
|
|
3228
|
+
scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
3229
|
+
if (scroller && scrollsUnderBar) {
|
|
3230
|
+
// iOS 26: the scroll view starts at the top of the screen and its
|
|
3231
|
+
// content scrolls under the bar (and the large title moves with it);
|
|
3232
|
+
// the content starts below them. It runs to the bottom of the screen,
|
|
3233
|
+
// under a bottom toolbar or search field too.
|
|
3234
|
+
const top = content.y - y;
|
|
3235
|
+
this.scrollInsets.set(scroller, {
|
|
3236
|
+
bar: barHeight,
|
|
3237
|
+
largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
|
|
3238
|
+
});
|
|
3239
|
+
this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
|
|
2966
3240
|
return;
|
|
2967
3241
|
}
|
|
2968
|
-
if (scroller) this.
|
|
2969
|
-
|
|
3242
|
+
if (scroller) this.scrollInsets.delete(scroller);
|
|
3243
|
+
// A scroll view deeper in the content that touches the bar runs under it
|
|
3244
|
+
// (`scrollEdgeGrowth`); not under a large title, which stays put then.
|
|
3245
|
+
// One that reaches the bottom of the content runs down under all the
|
|
3246
|
+
// content clears there.
|
|
3247
|
+
if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
|
|
3248
|
+
if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
|
|
3249
|
+
if (scroller && scroller.kind === 'scrollview') {
|
|
2970
3250
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2971
3251
|
return;
|
|
2972
3252
|
}
|
|
@@ -2977,9 +3257,101 @@ export class LayoutPass {
|
|
|
2977
3257
|
this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
|
|
2978
3258
|
}
|
|
2979
3259
|
|
|
3260
|
+
/**
|
|
3261
|
+
* iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
|
|
3262
|
+
* float on Liquid Glass in a 48 row at the top of the bottom bar's band
|
|
3263
|
+
* (`top`), 28 in from the sides. Consecutive items share a capsule; a
|
|
3264
|
+
* `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
|
|
3265
|
+
* spacers share the room the groups leave (with none the row is centered)
|
|
3266
|
+
* and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
|
|
3267
|
+
* circle), text its width and 18 either side, 6 between slots; each item
|
|
3268
|
+
* is centered in its slot. The capsules are the screen's chrome
|
|
3269
|
+
* `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
|
|
3270
|
+
* items.
|
|
3271
|
+
*/
|
|
3272
|
+
private placeBottomToolbars(
|
|
3273
|
+
toolbars: LayoutNode[],
|
|
3274
|
+
cctx: Context,
|
|
3275
|
+
chrome: Record<string, Rect>,
|
|
3276
|
+
x: number,
|
|
3277
|
+
top: number,
|
|
3278
|
+
width: number,
|
|
3279
|
+
): void {
|
|
3280
|
+
interface Item {
|
|
3281
|
+
node: LayoutNode;
|
|
3282
|
+
toolbar: LayoutNode;
|
|
3283
|
+
size: Size;
|
|
3284
|
+
slot: number;
|
|
3285
|
+
}
|
|
3286
|
+
type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
|
|
3287
|
+
const tokens: Token[] = [];
|
|
3288
|
+
let open: Item[] = [];
|
|
3289
|
+
const close = (): void => {
|
|
3290
|
+
if (open.length === 0) return;
|
|
3291
|
+
const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
|
|
3292
|
+
const lone = open.length === 1 && icon(open[0] as Item);
|
|
3293
|
+
if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
|
|
3294
|
+
const slots = open.reduce((sum, item) => sum + item.slot, 0);
|
|
3295
|
+
tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
|
|
3296
|
+
open = [];
|
|
3297
|
+
};
|
|
3298
|
+
for (const toolbar of toolbars) {
|
|
3299
|
+
const spacer = str(toolbar.props['spacer']);
|
|
3300
|
+
if (spacer !== null) {
|
|
3301
|
+
close();
|
|
3302
|
+
tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
|
|
3303
|
+
continue;
|
|
3304
|
+
}
|
|
3305
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3306
|
+
for (const node of toolbar.children) {
|
|
3307
|
+
const size = this.measure(node, itemCtx, { width: null, height: null });
|
|
3308
|
+
const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
|
|
3309
|
+
open.push({ node, toolbar, size, slot });
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
close();
|
|
3313
|
+
const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
|
|
3314
|
+
let used = 0;
|
|
3315
|
+
let flexible = 0;
|
|
3316
|
+
for (const token of tokens) {
|
|
3317
|
+
if (token.kind === 'group') used += token.width;
|
|
3318
|
+
else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
|
|
3319
|
+
else flexible += 1;
|
|
3320
|
+
}
|
|
3321
|
+
const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
|
|
3322
|
+
let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
|
|
3323
|
+
const spans = new Map<LayoutNode, { start: number; end: number }>();
|
|
3324
|
+
let glass = 0;
|
|
3325
|
+
for (const token of tokens) {
|
|
3326
|
+
if (token.kind === 'flexible') {
|
|
3327
|
+
cursor += share;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (token.kind === 'fixed') {
|
|
3331
|
+
cursor += BOTTOM_FIXED_SPACER;
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
|
|
3335
|
+
glass += 1;
|
|
3336
|
+
for (const item of token.items) {
|
|
3337
|
+
const itemCtx = this.childContext(item.toolbar, cctx);
|
|
3338
|
+
this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
|
|
3339
|
+
const span = spans.get(item.toolbar);
|
|
3340
|
+
spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
|
|
3341
|
+
cursor += item.slot + BOTTOM_ITEM_GAP;
|
|
3342
|
+
}
|
|
3343
|
+
cursor -= BOTTOM_ITEM_GAP;
|
|
3344
|
+
}
|
|
3345
|
+
for (const toolbar of toolbars) {
|
|
3346
|
+
const span = spans.get(toolbar);
|
|
3347
|
+
if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
2980
3351
|
/**
|
|
2981
3352
|
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
2982
|
-
* content (TAB_BAR_* above),
|
|
3353
|
+
* content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
|
|
3354
|
+
* up to the width less the margins, centered; what it
|
|
2983
3355
|
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
2984
3356
|
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
2985
3357
|
* content out, the others are reported in `hidden`.
|
|
@@ -2989,18 +3361,19 @@ export class LayoutPass {
|
|
|
2989
3361
|
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
2990
3362
|
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
2991
3363
|
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
3364
|
+
const tabs = node.children;
|
|
3365
|
+
const n = tabs.length;
|
|
3366
|
+
const width = Math.min(Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H), n * TAB_BAR_ITEM_WIDTH + 2 * TAB_BAR_ITEM_INSET);
|
|
2992
3367
|
const bar: Rect = {
|
|
2993
|
-
x: x +
|
|
3368
|
+
x: x + (size.width - width) / 2,
|
|
2994
3369
|
y: y + size.height - barHeight,
|
|
2995
|
-
width
|
|
3370
|
+
width,
|
|
2996
3371
|
height: capsuleHeight,
|
|
2997
3372
|
};
|
|
2998
3373
|
const chrome: Record<string, Rect> = {
|
|
2999
3374
|
bar,
|
|
3000
3375
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
3001
3376
|
};
|
|
3002
|
-
const tabs = node.children;
|
|
3003
|
-
const n = tabs.length;
|
|
3004
3377
|
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
3005
3378
|
for (let i = 0; i < n; i++) {
|
|
3006
3379
|
const itemWidth = inner / n;
|
|
@@ -3013,7 +3386,7 @@ export class LayoutPass {
|
|
|
3013
3386
|
}
|
|
3014
3387
|
this.setChrome(node, chrome);
|
|
3015
3388
|
const selected = readSelected(node, n);
|
|
3016
|
-
const tabCtx: Context = { ...cctx, bottomBar: barHeight };
|
|
3389
|
+
const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
|
|
3017
3390
|
tabs.forEach((tab, i) => {
|
|
3018
3391
|
this.setFrame(tab, x, y, size);
|
|
3019
3392
|
if (i !== selected) {
|
|
@@ -3071,6 +3444,12 @@ export class LayoutPass {
|
|
|
3071
3444
|
* to the detail column (when the split view touches those screen edges):
|
|
3072
3445
|
* the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
|
|
3073
3446
|
* navstacks start / end at them.
|
|
3447
|
+
*
|
|
3448
|
+
* iPadOS: a sidebar toggle (a 44 glass circle, chrome `toggle`) sits at the
|
|
3449
|
+
* leading edge of the bar row, in the sidebar's bar or, with the sidebar
|
|
3450
|
+
* hidden (`sidebarVisible: false`), in the detail's; that column's screens
|
|
3451
|
+
* start their back button and leading items after it. A hidden sidebar
|
|
3452
|
+
* slides off the leading edge and the detail column takes the width.
|
|
3074
3453
|
*/
|
|
3075
3454
|
private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3076
3455
|
const { screen, safeArea } = this.env;
|
|
@@ -3078,27 +3457,81 @@ export class LayoutPass {
|
|
|
3078
3457
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3079
3458
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
3080
3459
|
const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
3081
|
-
const
|
|
3082
|
-
|
|
3460
|
+
const visible = node.props['sidebarVisible'] !== false;
|
|
3461
|
+
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
3462
|
+
const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
|
|
3463
|
+
const separatorX = sidebarX + sidebarWidth;
|
|
3464
|
+
const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
|
|
3083
3465
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
3466
|
+
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
3084
3467
|
const chrome: Record<string, Rect> = {
|
|
3085
|
-
sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
|
|
3468
|
+
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
3086
3469
|
separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
|
|
3087
|
-
detail: { x: detailX, y, width: Math.max(0, x + size.width - detailX), height: size.height },
|
|
3470
|
+
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
3471
|
+
toggle: { x: x + leading + NAV_ITEM_MARGIN, y: y + statusBar, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE },
|
|
3088
3472
|
};
|
|
3089
3473
|
this.setChrome(node, chrome);
|
|
3474
|
+
const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
|
|
3090
3475
|
const [sidebar, detail, ...rest] = node.children;
|
|
3091
3476
|
if (sidebar) {
|
|
3092
3477
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
3093
|
-
this.place(sidebar, { ...cctx, splitSidebar: true }, proposal,
|
|
3478
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
3094
3479
|
}
|
|
3095
3480
|
if (detail) {
|
|
3096
3481
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
3097
|
-
this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
|
|
3482
|
+
this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
3098
3483
|
}
|
|
3099
3484
|
for (const extra of rest) this.hide(extra, node);
|
|
3100
3485
|
}
|
|
3101
3486
|
|
|
3487
|
+
/**
|
|
3488
|
+
* `.inspector` (regular width): the children are the content, then the
|
|
3489
|
+
* `inspectorpanel`. The panel is a trailing column, 320 wide (its
|
|
3490
|
+
* content's `inspectorColumnWidth`, at most half the width) plus the
|
|
3491
|
+
* trailing safe-area inset when it touches that edge, with a 1px gap
|
|
3492
|
+
* before it (the panel draws the hairline there); the content is proposed the rest (each content child is
|
|
3493
|
+
* centered in it, as a root view is). With `presented: false` the panel
|
|
3494
|
+
* sits just off the trailing edge (so it slides) and the content takes the
|
|
3495
|
+
* whole width.
|
|
3496
|
+
*/
|
|
3497
|
+
private placeInspector(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3498
|
+
const { screen, safeArea } = this.env;
|
|
3499
|
+
const last = node.children[node.children.length - 1];
|
|
3500
|
+
const panel = last && last.kind === 'inspectorpanel' ? last : null;
|
|
3501
|
+
const main = panel ? node.children.slice(0, -1) : node.children;
|
|
3502
|
+
const presented = node.props['presented'] === true;
|
|
3503
|
+
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3504
|
+
const requested = panel?.children[0] ? num(cellStyle(panel.children[0], 'inspectorColumnWidth')) : null;
|
|
3505
|
+
const columnWidth = Math.min(requested ?? INSPECTOR_WIDTH, size.width * INSPECTOR_MAX_FRACTION);
|
|
3506
|
+
const panelWidth = columnWidth + trailing;
|
|
3507
|
+
const panelX = presented ? x + size.width - panelWidth : x + size.width + NAVSPLIT_SEPARATOR_WIDTH;
|
|
3508
|
+
const mainWidth = presented ? Math.max(0, panelX - NAVSPLIT_SEPARATOR_WIDTH - x) : size.width;
|
|
3509
|
+
const area: Size = { width: mainWidth, height: size.height };
|
|
3510
|
+
for (const child of main) {
|
|
3511
|
+
const childSize = this.measure(child, cctx, area);
|
|
3512
|
+
const { dx, dy } = alignIn(area, childSize, 'center');
|
|
3513
|
+
this.place(child, cctx, area, x + dx, y + dy);
|
|
3514
|
+
}
|
|
3515
|
+
if (panel) this.place(panel, cctx, { width: panelWidth, height: size.height }, panelX, y);
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
/**
|
|
3519
|
+
* The inspector column: its content below the status bar (at the top of
|
|
3520
|
+
* the screen) and clear of the trailing inset, top-aligned and centered
|
|
3521
|
+
* horizontally (a list or form fills it).
|
|
3522
|
+
*/
|
|
3523
|
+
private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3524
|
+
const { screen, safeArea } = this.env;
|
|
3525
|
+
const top = y <= 0 ? safeArea.top : 0;
|
|
3526
|
+
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3527
|
+
const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
|
|
3528
|
+
for (const child of node.children) {
|
|
3529
|
+
const childSize = this.measure(child, cctx, area);
|
|
3530
|
+
const { dx } = alignIn(area, childSize, 'center');
|
|
3531
|
+
this.place(child, cctx, area, x + dx, y + top);
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3102
3535
|
/**
|
|
3103
3536
|
* Phase 7 `ViewThatFits`: the first child whose ideal size fits the
|
|
3104
3537
|
* proposal on the restricted axes (a nil or infinite proposal axis always
|
|
@@ -3350,7 +3783,7 @@ export class LayoutPass {
|
|
|
3350
3783
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
3351
3784
|
return {
|
|
3352
3785
|
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
3353
|
-
height: wrapped.lines.length
|
|
3786
|
+
height: textHeight(wrapped.lines.length, font),
|
|
3354
3787
|
};
|
|
3355
3788
|
};
|
|
3356
3789
|
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
@@ -3823,22 +4256,33 @@ export class LayoutPass {
|
|
|
3823
4256
|
* clamped. With no width the flexible columns take the cells' largest ideal
|
|
3824
4257
|
* width and an adaptive item is one column. Cells fill rows left to right.
|
|
3825
4258
|
*/
|
|
4259
|
+
/**
|
|
4260
|
+
* `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
|
|
4261
|
+
* share the proposed width and its cells fill them row by row. A horizontal
|
|
4262
|
+
* grid is the same along the other axis (its rows share the proposed
|
|
4263
|
+
* height and the cells fill them column by column), so the plan's
|
|
4264
|
+
* `columns` are the tracks across the grid (`x`, `width`: cross axis) and
|
|
4265
|
+
* its `rows` the lines along it (`y`, `height`: main axis).
|
|
4266
|
+
*/
|
|
3826
4267
|
private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
|
|
3827
4268
|
return this.plan(node, p, () => {
|
|
3828
|
-
const
|
|
3829
|
-
const
|
|
4269
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
4270
|
+
const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
|
|
4271
|
+
const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
|
|
4272
|
+
const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
3830
4273
|
const cctx = this.childContext(node, ctx);
|
|
3831
4274
|
const cells = node.children;
|
|
4275
|
+
const cross = crossOf(p);
|
|
3832
4276
|
let idealCell: number | null = null;
|
|
3833
4277
|
const ideal = (): number => {
|
|
3834
4278
|
if (idealCell === null) {
|
|
3835
4279
|
idealCell = 0;
|
|
3836
|
-
for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL)
|
|
4280
|
+
for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
|
|
3837
4281
|
}
|
|
3838
4282
|
return idealCell;
|
|
3839
4283
|
};
|
|
3840
4284
|
const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
|
|
3841
|
-
if (!finite(
|
|
4285
|
+
if (!finite(cross)) {
|
|
3842
4286
|
items.forEach((item, i) => {
|
|
3843
4287
|
if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
|
|
3844
4288
|
});
|
|
@@ -3848,7 +4292,7 @@ export class LayoutPass {
|
|
|
3848
4292
|
let fixed = 0;
|
|
3849
4293
|
for (const item of items) if (item.kind === 'fixed') fixed += item.value;
|
|
3850
4294
|
const sharing = items.filter((item) => item.kind !== 'fixed').length;
|
|
3851
|
-
const remaining = Math.max(0,
|
|
4295
|
+
const remaining = Math.max(0, cross - fixed - gaps);
|
|
3852
4296
|
const share = sharing > 0 ? remaining / sharing : 0;
|
|
3853
4297
|
let flexibleTotal = 0;
|
|
3854
4298
|
let adaptiveCount = 0;
|
|
@@ -3882,9 +4326,9 @@ export class LayoutPass {
|
|
|
3882
4326
|
});
|
|
3883
4327
|
});
|
|
3884
4328
|
const used = cursor;
|
|
3885
|
-
const
|
|
4329
|
+
const gridCross = finite(cross) ? cross : used;
|
|
3886
4330
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
3887
|
-
const offset = safeOffset(
|
|
4331
|
+
const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
|
|
3888
4332
|
for (const column of columns) column.x += offset;
|
|
3889
4333
|
|
|
3890
4334
|
const rows: LazyGridPlan['rows'] = [];
|
|
@@ -3896,28 +4340,31 @@ export class LayoutPass {
|
|
|
3896
4340
|
for (let k = 0; k < perRow && start + k < cells.length; k++) {
|
|
3897
4341
|
const cell = cells[start + k] as LayoutNode;
|
|
3898
4342
|
const column = columns[k] as LazyColumnPlan;
|
|
3899
|
-
const size = this.measure(cell, cctx, { width: column.width, height: null });
|
|
3900
|
-
height = Math.max(height, size.height);
|
|
4343
|
+
const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
|
|
4344
|
+
height = Math.max(height, horizontal ? size.width : size.height);
|
|
3901
4345
|
rowCells.push({ node: cell, column: k, size });
|
|
3902
4346
|
}
|
|
3903
4347
|
rows.push({ y, height, cells: rowCells });
|
|
3904
|
-
y += height +
|
|
4348
|
+
y += height + lineSpacing;
|
|
3905
4349
|
}
|
|
3906
|
-
const
|
|
3907
|
-
|
|
4350
|
+
const gridMain = rows.length > 0 ? y - lineSpacing : 0;
|
|
4351
|
+
const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
|
|
4352
|
+
return { size, columns, rows };
|
|
3908
4353
|
});
|
|
3909
4354
|
}
|
|
3910
4355
|
|
|
3911
4356
|
private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
3912
4357
|
const plan = this.lazyGridPlan(node, ctx, p);
|
|
4358
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
3913
4359
|
const cctx = this.childContext(node, ctx);
|
|
3914
4360
|
const gridAlignment = str(node.props['alignment']) ?? 'center';
|
|
3915
4361
|
for (const row of plan.rows) {
|
|
3916
4362
|
for (const cell of row.cells) {
|
|
3917
4363
|
const column = plan.columns[cell.column] as LazyColumnPlan;
|
|
3918
|
-
const box: Size = { width: column.width, height: row.height };
|
|
4364
|
+
const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
|
|
3919
4365
|
const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
|
|
3920
|
-
this.place(cell.node, cctx, { width:
|
|
4366
|
+
if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
|
|
4367
|
+
else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
|
|
3921
4368
|
}
|
|
3922
4369
|
}
|
|
3923
4370
|
}
|