@swiftbrowser/web 0.3.1 → 0.4.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 +3 -1
- package/package.json +4 -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 +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- 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 +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- 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,24 +175,29 @@ 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
203
|
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
|
|
@@ -225,15 +259,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
|
225
259
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
226
260
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
227
261
|
const NAVSPLIT_SEPARATOR_WIDTH = 1;
|
|
262
|
+
/** `.inspector`: the trailing column's default width, and at most this share of the width. */
|
|
263
|
+
const INSPECTOR_WIDTH = 320;
|
|
264
|
+
const INSPECTOR_MAX_FRACTION = 0.5;
|
|
228
265
|
const EPSILON = 1e-6;
|
|
229
266
|
// Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
|
|
230
267
|
/** The segmented −/+ control of a `stepper`, trailing. */
|
|
231
268
|
const STEPPER_WIDTH = 94;
|
|
232
269
|
const STEPPER_HEIGHT = 32;
|
|
233
270
|
const STEPPER_GAP = 8;
|
|
234
|
-
/** A `slider` is
|
|
235
|
-
// iOS 26: a 6pt track and a 38 × 24 pill knob in a
|
|
236
|
-
|
|
271
|
+
/** A `slider` is 31 high (its label row, when present, sits above). */
|
|
272
|
+
// iOS 26: a 6pt track and a 38 × 24 pill knob in a 31 row (a form row
|
|
273
|
+
// holding one is 61: 31 + 2 × 15).
|
|
274
|
+
const SLIDER_HEIGHT = 31;
|
|
237
275
|
const SLIDER_TRACK_HEIGHT = 6;
|
|
238
276
|
const SLIDER_THUMB = 38;
|
|
239
277
|
const SLIDER_THUMB_HEIGHT = 24;
|
|
@@ -467,7 +505,7 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
467
505
|
}
|
|
468
506
|
|
|
469
507
|
/** The default vertical padding above and below a list row's content. */
|
|
470
|
-
function listRowVPad(row: LayoutNode): number {
|
|
508
|
+
function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
471
509
|
switch (row.kind) {
|
|
472
510
|
case 'picker':
|
|
473
511
|
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
@@ -475,7 +513,7 @@ function listRowVPad(row: LayoutNode): number {
|
|
|
475
513
|
case 'stepper':
|
|
476
514
|
return LIST_ROW_CONTROL_VPAD;
|
|
477
515
|
default:
|
|
478
|
-
return LIST_ROW_VPAD;
|
|
516
|
+
return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
|
|
479
517
|
}
|
|
480
518
|
}
|
|
481
519
|
|
|
@@ -609,7 +647,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
|
|
|
609
647
|
}
|
|
610
648
|
|
|
611
649
|
/** 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']);
|
|
650
|
+
const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture', 'draggable', 'dropdestination']);
|
|
613
651
|
|
|
614
652
|
/**
|
|
615
653
|
* Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
|
|
@@ -625,8 +663,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
|
|
|
625
663
|
return null;
|
|
626
664
|
}
|
|
627
665
|
|
|
628
|
-
/**
|
|
666
|
+
/** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
|
|
667
|
+
const LIST_LABEL_ICON_CENTER = 12;
|
|
668
|
+
const LIST_LABEL_TITLE_X = 40;
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* A `Label` in its default style: an hstack of the marked icon (`labelIcon`
|
|
672
|
+
* on its `styled` wrapper) then the title. In a list row it takes iOS's icon
|
|
673
|
+
* column instead of the stack's spacing.
|
|
674
|
+
*/
|
|
675
|
+
function labelColumnOf(node: LayoutNode): { icon: LayoutNode; title: LayoutNode } | null {
|
|
676
|
+
if (node.kind !== 'hstack' || node.children.length !== 2) return null;
|
|
677
|
+
const [icon, title] = node.children as [LayoutNode, LayoutNode];
|
|
678
|
+
const style = icon.kind === 'styled' ? (icon.props['style'] as Record<string, unknown> | undefined) : undefined;
|
|
679
|
+
if (style?.['labelIcon'] !== true) return null;
|
|
680
|
+
return { icon, title };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 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
684
|
export const LIST_EDIT_CONTROL_WIDTH = 44;
|
|
685
|
+
/**
|
|
686
|
+
* Edit mode: how far a control sits in from the card's edge (its 22 pt glyph
|
|
687
|
+
* is centred 28 from the edge, as iOS draws it) and by how much the content
|
|
688
|
+
* shifts right of the leading control, or narrows for the trailing grip.
|
|
689
|
+
*/
|
|
690
|
+
export const LIST_EDIT_CONTROL_INSET = 6;
|
|
691
|
+
export const LIST_EDIT_CONTROL_SHIFT = 40;
|
|
630
692
|
|
|
631
693
|
/** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
|
|
632
694
|
function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
@@ -634,7 +696,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
|
634
696
|
const size = font.size * scale;
|
|
635
697
|
const lineHeight = font.lineHeight * scale;
|
|
636
698
|
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]}` };
|
|
699
|
+
return { ...font, size, lineHeight, leading: font.leading * scale, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
|
|
638
700
|
}
|
|
639
701
|
|
|
640
702
|
// ---------------------------------------------------------------------------
|
|
@@ -847,8 +909,12 @@ interface ListRowPlan {
|
|
|
847
909
|
content: Rect;
|
|
848
910
|
/** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
|
|
849
911
|
badge: { rect: Rect; layout: TextLayout } | null;
|
|
850
|
-
/**
|
|
851
|
-
|
|
912
|
+
/**
|
|
913
|
+
* Phase 8 edit mode: the `listrow` the row is, its 44px leading control
|
|
914
|
+
* rect (a selectable or deletable row) and its 44px trailing grip rect (a
|
|
915
|
+
* movable row), relative to the list origin.
|
|
916
|
+
*/
|
|
917
|
+
edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
|
|
852
918
|
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
853
919
|
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
854
920
|
}
|
|
@@ -1006,11 +1072,13 @@ export class LayoutPass {
|
|
|
1006
1072
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
1007
1073
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
1008
1074
|
/**
|
|
1009
|
-
* A
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1075
|
+
* A navigation screen's root list or vertical scroll view starts at the top
|
|
1076
|
+
* of the screen and scrolls under its bar (`bar` tall): its content starts
|
|
1077
|
+
* below the bar and the large title row that scrolls with it, if any.
|
|
1078
|
+
* `nested`: a scroll view deeper in the screen's content that touches the
|
|
1079
|
+
* bar (`scrollEdgeGrowth`), rather than the screen's own content.
|
|
1012
1080
|
*/
|
|
1013
|
-
private readonly
|
|
1081
|
+
private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
|
|
1014
1082
|
|
|
1015
1083
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
1016
1084
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -1055,10 +1123,17 @@ export class LayoutPass {
|
|
|
1055
1123
|
const { screen, safeArea } = this.env;
|
|
1056
1124
|
const ctx = this.rootContext;
|
|
1057
1125
|
for (const root of roots) {
|
|
1058
|
-
if (
|
|
1059
|
-
|
|
1126
|
+
if (
|
|
1127
|
+
root.kind === 'navstack' ||
|
|
1128
|
+
root.kind === 'tabview' ||
|
|
1129
|
+
root.kind === 'navsplit' ||
|
|
1130
|
+
root.kind === 'inspector' ||
|
|
1131
|
+
root.kind === 'alert'
|
|
1132
|
+
) {
|
|
1133
|
+
// These ignore the safe areas: the tab bar and navigation bars
|
|
1060
1134
|
// extend into them and the content is inset instead (a `navsplit`
|
|
1061
|
-
// hands the horizontal insets to its columns, see `placeNavSplit
|
|
1135
|
+
// hands the horizontal insets to its columns, see `placeNavSplit`;
|
|
1136
|
+
// an `inspector` its trailing one to its panel).
|
|
1062
1137
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1063
1138
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1064
1139
|
} else if (root.kind === 'sheet') {
|
|
@@ -1073,6 +1148,14 @@ export class LayoutPass {
|
|
|
1073
1148
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
1074
1149
|
height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
|
|
1075
1150
|
};
|
|
1151
|
+
if (this.env.contentHeight) {
|
|
1152
|
+
// An island: the root takes its content's height and sits at the top.
|
|
1153
|
+
const proposal: Proposal = { width: safe.width, height: null };
|
|
1154
|
+
const size = this.measure(root, ctx, proposal);
|
|
1155
|
+
const { dx } = alignIn(safe, size, 'center');
|
|
1156
|
+
this.place(root, ctx, proposal, safeArea.leading + dx, safeArea.top);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1076
1159
|
const proposal: Proposal = { width: safe.width, height: safe.height };
|
|
1077
1160
|
const size = this.measure(root, ctx, proposal);
|
|
1078
1161
|
const { dx, dy } = alignIn(safe, size, 'center');
|
|
@@ -1082,6 +1165,32 @@ export class LayoutPass {
|
|
|
1082
1165
|
return this.finish();
|
|
1083
1166
|
}
|
|
1084
1167
|
|
|
1168
|
+
/**
|
|
1169
|
+
* A `host` element (src/hostContent.ts): the page's DOM, measured by the
|
|
1170
|
+
* host measurer. `fill` takes the proposal and the content's own size on an
|
|
1171
|
+
* unconstrained axis; `content` takes the content's ideal width capped by
|
|
1172
|
+
* the proposal and its height at that width, like text. Without a measurer
|
|
1173
|
+
* (headless) it is a 10×10 placeholder, like a shape with no proposal.
|
|
1174
|
+
*/
|
|
1175
|
+
private measureHost(node: LayoutNode, p: Proposal): Size {
|
|
1176
|
+
const props = node.props as { sizing?: string; content?: string };
|
|
1177
|
+
const bounded = (value: number | null): number | null => (value !== null && Number.isFinite(value) ? value : null);
|
|
1178
|
+
const width = bounded(p.width);
|
|
1179
|
+
const height = bounded(p.height);
|
|
1180
|
+
const measure = this.measurer.measureHost?.bind(this.measurer);
|
|
1181
|
+
if (!measure || typeof props.content !== 'string') {
|
|
1182
|
+
return { width: width ?? SHAPE_DEFAULT, height: height ?? SHAPE_DEFAULT };
|
|
1183
|
+
}
|
|
1184
|
+
if (props.sizing === 'fill') {
|
|
1185
|
+
if (width !== null && height !== null) return { width, height };
|
|
1186
|
+
const natural = measure(props.content, width);
|
|
1187
|
+
return { width: width ?? natural.width, height: height ?? natural.height };
|
|
1188
|
+
}
|
|
1189
|
+
const ideal = measure(props.content, null);
|
|
1190
|
+
if (width === null || ideal.width <= width) return ideal;
|
|
1191
|
+
return { width, height: measure(props.content, width).height };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1085
1194
|
private finish(): LayoutResult {
|
|
1086
1195
|
// Only what this run produced needs snapping; the rest was snapped when
|
|
1087
1196
|
// it was produced.
|
|
@@ -1243,10 +1352,20 @@ export class LayoutPass {
|
|
|
1243
1352
|
return { ...ctx, stackAxis: 'vertical', listRow: false };
|
|
1244
1353
|
case 'hstack':
|
|
1245
1354
|
return { ...ctx, stackAxis: 'horizontal', listRow: false };
|
|
1355
|
+
case 'list':
|
|
1356
|
+
case 'scrollview':
|
|
1357
|
+
case 'sheet': {
|
|
1358
|
+
// A bar above is the outermost scroll view's to run under; what a
|
|
1359
|
+
// scroll view or a sheet holds is clear of it.
|
|
1360
|
+
if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
|
|
1361
|
+
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1362
|
+
}
|
|
1246
1363
|
case 'gesture':
|
|
1247
1364
|
case 'swipeactions':
|
|
1248
1365
|
case 'contextmenu':
|
|
1249
1366
|
case 'listrow':
|
|
1367
|
+
case 'draggable':
|
|
1368
|
+
case 'dropdestination':
|
|
1250
1369
|
// Phase 8 wrappers are transparent: a `navlink` under them is still
|
|
1251
1370
|
// the list row (its chevron, its selected fill).
|
|
1252
1371
|
return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
|
|
@@ -1327,8 +1446,11 @@ export class LayoutPass {
|
|
|
1327
1446
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
1328
1447
|
case 'vstack':
|
|
1329
1448
|
return this.stackPlan(node, ctx, p, 'vertical').size;
|
|
1330
|
-
case 'hstack':
|
|
1449
|
+
case 'hstack': {
|
|
1450
|
+
const label = ctx.labelColumn ? labelColumnOf(node) : null;
|
|
1451
|
+
if (label) return this.labelColumnPlan(label, this.childContext(node, ctx), p).size;
|
|
1331
1452
|
return this.stackPlan(node, ctx, p, 'horizontal').size;
|
|
1453
|
+
}
|
|
1332
1454
|
case 'zstack':
|
|
1333
1455
|
return this.measureZStack(node, ctx, p);
|
|
1334
1456
|
case 'styled':
|
|
@@ -1370,6 +1492,8 @@ export class LayoutPass {
|
|
|
1370
1492
|
case 'tabview':
|
|
1371
1493
|
case 'tab':
|
|
1372
1494
|
case 'navsplit':
|
|
1495
|
+
case 'inspector':
|
|
1496
|
+
case 'inspectorpanel':
|
|
1373
1497
|
return {
|
|
1374
1498
|
width: p.width ?? this.env.screen.width,
|
|
1375
1499
|
height: p.height ?? this.env.screen.height,
|
|
@@ -1378,6 +1502,8 @@ export class LayoutPass {
|
|
|
1378
1502
|
return this.viewThatFitsPlan(node, ctx, p).size;
|
|
1379
1503
|
case 'picker':
|
|
1380
1504
|
return this.pickerPlan(node, ctx, p).size;
|
|
1505
|
+
case 'host':
|
|
1506
|
+
return this.measureHost(node, p);
|
|
1381
1507
|
case 'geometry':
|
|
1382
1508
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1383
1509
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
@@ -1435,6 +1561,8 @@ export class LayoutPass {
|
|
|
1435
1561
|
case 'gesture':
|
|
1436
1562
|
case 'listrow':
|
|
1437
1563
|
case 'tapgesture':
|
|
1564
|
+
case 'draggable':
|
|
1565
|
+
case 'dropdestination':
|
|
1438
1566
|
default: {
|
|
1439
1567
|
// `tapgesture` (its child's size), `toolbar` outside a navscreen,
|
|
1440
1568
|
// `section` outside a list and unknown kinds: lay the children out as a vstack.
|
|
@@ -1457,7 +1585,7 @@ export class LayoutPass {
|
|
|
1457
1585
|
const min = Math.max(1, ctx.minLines ?? 1);
|
|
1458
1586
|
const max = Math.max(min, ctx.lineLimit ?? Infinity);
|
|
1459
1587
|
const lines = Math.min(Math.max(wrapped.lines.length, min), max);
|
|
1460
|
-
return { width, height: lines
|
|
1588
|
+
return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
|
|
1461
1589
|
}
|
|
1462
1590
|
|
|
1463
1591
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
@@ -1467,7 +1595,7 @@ export class LayoutPass {
|
|
|
1467
1595
|
const text = str(node.props['text']) ?? '';
|
|
1468
1596
|
const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1469
1597
|
return {
|
|
1470
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
1598
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
|
|
1471
1599
|
layout: {
|
|
1472
1600
|
font: ctx.font,
|
|
1473
1601
|
lines: wrapped.lines,
|
|
@@ -1506,16 +1634,16 @@ export class LayoutPass {
|
|
|
1506
1634
|
};
|
|
1507
1635
|
}
|
|
1508
1636
|
|
|
1509
|
-
/** An image in a text: a bitmap or remote image at its size, a symbol
|
|
1637
|
+
/** An image in a text: a bitmap or remote image at its size, a symbol its font's symbol square. */
|
|
1510
1638
|
private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
|
|
1511
1639
|
if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
|
|
1512
1640
|
return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
|
|
1513
1641
|
}
|
|
1514
1642
|
const name = str(props['name']);
|
|
1515
|
-
if (name === null) return square(font.
|
|
1643
|
+
if (name === null) return square(font.leading);
|
|
1516
1644
|
const asset = this.env.assets?.images[name];
|
|
1517
1645
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1518
|
-
if (asset.kind === 'symbol') return square(font.
|
|
1646
|
+
if (asset.kind === 'symbol') return square(font.leading);
|
|
1519
1647
|
return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
|
|
1520
1648
|
}
|
|
1521
1649
|
|
|
@@ -1775,6 +1903,25 @@ export class LayoutPass {
|
|
|
1775
1903
|
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1776
1904
|
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1777
1905
|
*/
|
|
1906
|
+
/** A list row's `Label` in iOS's icon column: the icon at its own size, the title in what is left past 40. */
|
|
1907
|
+
private labelColumnPlan(
|
|
1908
|
+
label: { icon: LayoutNode; title: LayoutNode },
|
|
1909
|
+
cctx: Context,
|
|
1910
|
+
p: Proposal,
|
|
1911
|
+
): { size: Size; icon: Size; title: Size; iconProposal: Proposal; titleProposal: Proposal } {
|
|
1912
|
+
const iconProposal: Proposal = { width: null, height: p.height };
|
|
1913
|
+
const icon = this.measure(label.icon, cctx, iconProposal);
|
|
1914
|
+
const titleProposal: Proposal = { width: finite(p.width) ? Math.max(0, p.width - LIST_LABEL_TITLE_X) : p.width, height: p.height };
|
|
1915
|
+
const title = this.measure(label.title, cctx, titleProposal);
|
|
1916
|
+
return {
|
|
1917
|
+
size: { width: LIST_LABEL_TITLE_X + title.width, height: Math.max(icon.height, title.height) },
|
|
1918
|
+
icon,
|
|
1919
|
+
title,
|
|
1920
|
+
iconProposal,
|
|
1921
|
+
titleProposal,
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1778
1925
|
private layoutStack(
|
|
1779
1926
|
children: readonly LayoutNode[],
|
|
1780
1927
|
cctx: Context,
|
|
@@ -1947,10 +2094,10 @@ export class LayoutPass {
|
|
|
1947
2094
|
return { width: p.width ?? width, height: p.height ?? height };
|
|
1948
2095
|
}
|
|
1949
2096
|
const name = str(node.props['name']);
|
|
1950
|
-
if (name === null) return square(ctx.font.
|
|
2097
|
+
if (name === null) return square(ctx.font.leading * ctx.imageScale);
|
|
1951
2098
|
const asset = this.env.assets?.images[name];
|
|
1952
2099
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1953
|
-
if (asset.kind === 'symbol') return square(ctx.font.
|
|
2100
|
+
if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
|
|
1954
2101
|
const width = num(asset.width) ?? 0;
|
|
1955
2102
|
const height = num(asset.height) ?? 0;
|
|
1956
2103
|
if (node.props['resizable'] !== true) return { width, height };
|
|
@@ -2153,11 +2300,13 @@ export class LayoutPass {
|
|
|
2153
2300
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
2154
2301
|
// `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
|
|
2155
2302
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
2156
|
-
// the space between sections.
|
|
2303
|
+
// the space between sections. `sidebar` (iPadOS) is laid out like
|
|
2304
|
+
// `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
|
|
2157
2305
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
const
|
|
2306
|
+
const sidebarStyle = listStyle === 'sidebar';
|
|
2307
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
|
|
2308
|
+
const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
2309
|
+
const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
|
|
2161
2310
|
const plain = listStyle === 'plain';
|
|
2162
2311
|
const listTop = plain ? 0 : LIST_TOP;
|
|
2163
2312
|
const listBottom = plain ? 0 : LIST_BOTTOM;
|
|
@@ -2177,9 +2326,10 @@ export class LayoutPass {
|
|
|
2177
2326
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
2178
2327
|
400,
|
|
2179
2328
|
);
|
|
2180
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2181
|
-
// Phase 8: in edit mode
|
|
2182
|
-
//
|
|
2329
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2330
|
+
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
2331
|
+
// leading control (6 in from the card's edge) and its content shifts
|
|
2332
|
+
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
2183
2333
|
const editing = node.props['editing'] === true;
|
|
2184
2334
|
|
|
2185
2335
|
// Group consecutive plain rows into an implicit section. A section's
|
|
@@ -2215,7 +2365,7 @@ export class LayoutPass {
|
|
|
2215
2365
|
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
2216
2366
|
return {
|
|
2217
2367
|
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2218
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
2368
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
|
|
2219
2369
|
};
|
|
2220
2370
|
};
|
|
2221
2371
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
@@ -2256,7 +2406,7 @@ export class LayoutPass {
|
|
|
2256
2406
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2257
2407
|
// width (plus a gap) at the trailing edge.
|
|
2258
2408
|
const hints = rowHintsOf(rowNode);
|
|
2259
|
-
const defaultVPad = listRowVPad(unwrapStyled(rowNode));
|
|
2409
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
|
|
2260
2410
|
const insets: EdgeInsets = hints.insets ?? {
|
|
2261
2411
|
top: defaultVPad,
|
|
2262
2412
|
leading: LIST_CELL_PADDING,
|
|
@@ -2265,10 +2415,16 @@ export class LayoutPass {
|
|
|
2265
2415
|
};
|
|
2266
2416
|
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
|
|
2267
2417
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
2418
|
+
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
2419
|
+
// leading control and its content shifts right; a movable one a
|
|
2420
|
+
// trailing grip, which narrows its content.
|
|
2268
2421
|
const listRow = editing ? listRowOf(rowNode) : null;
|
|
2269
|
-
const
|
|
2422
|
+
const hasControl = listRow !== null && (listRow.props['selectable'] === true || listRow.props['deletable'] === true);
|
|
2423
|
+
const hasGrip = listRow !== null && listRow.props['movable'] === true;
|
|
2424
|
+
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2425
|
+
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2270
2426
|
const rowContentX = cardX + insets.leading + editShift;
|
|
2271
|
-
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
|
|
2427
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
|
|
2272
2428
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
2273
2429
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
2274
2430
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -2291,7 +2447,15 @@ export class LayoutPass {
|
|
|
2291
2447
|
layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
|
|
2292
2448
|
}
|
|
2293
2449
|
: null,
|
|
2294
|
-
edit: listRow
|
|
2450
|
+
edit: listRow
|
|
2451
|
+
? {
|
|
2452
|
+
listRow,
|
|
2453
|
+
control: hasControl ? { x: cardX + LIST_EDIT_CONTROL_INSET, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } : null,
|
|
2454
|
+
reorder: hasGrip
|
|
2455
|
+
? { x: cardX + cardWidth - LIST_EDIT_CONTROL_INSET - LIST_EDIT_CONTROL_WIDTH, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight }
|
|
2456
|
+
: null,
|
|
2457
|
+
}
|
|
2458
|
+
: null,
|
|
2295
2459
|
separatorHidden: hints.separatorHidden,
|
|
2296
2460
|
});
|
|
2297
2461
|
y += rowHeight;
|
|
@@ -2361,6 +2525,11 @@ export class LayoutPass {
|
|
|
2361
2525
|
}
|
|
2362
2526
|
this.hiddenBy.delete(node);
|
|
2363
2527
|
}
|
|
2528
|
+
const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
|
|
2529
|
+
if (grown) {
|
|
2530
|
+
p = grown.p;
|
|
2531
|
+
y = grown.y;
|
|
2532
|
+
}
|
|
2364
2533
|
const size = this.measure(node, ctx, p);
|
|
2365
2534
|
this.setFrame(node, x, y, size);
|
|
2366
2535
|
const cctx = this.childContext(node, ctx);
|
|
@@ -2382,9 +2551,18 @@ export class LayoutPass {
|
|
|
2382
2551
|
case 'shape':
|
|
2383
2552
|
case 'canvas':
|
|
2384
2553
|
case 'video':
|
|
2554
|
+
case 'host':
|
|
2385
2555
|
return;
|
|
2386
2556
|
case 'vstack':
|
|
2387
2557
|
case 'hstack': {
|
|
2558
|
+
const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
|
|
2559
|
+
if (label) {
|
|
2560
|
+
// iOS's list icon column: the icon centred at 12, the title at 40.
|
|
2561
|
+
const plan = this.labelColumnPlan(label, cctx, p);
|
|
2562
|
+
this.place(label.icon, cctx, plan.iconProposal, x + LIST_LABEL_ICON_CENTER - plan.icon.width / 2, y + (size.height - plan.icon.height) / 2);
|
|
2563
|
+
this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2388
2566
|
const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
|
|
2389
2567
|
const plan = this.stackPlan(node, ctx, p, axis);
|
|
2390
2568
|
const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
@@ -2503,8 +2681,9 @@ export class LayoutPass {
|
|
|
2503
2681
|
};
|
|
2504
2682
|
// Phase 7: the selected row of a `navsplit` sidebar has no chevron
|
|
2505
2683
|
// (its label still reserves the chevron's width so selecting a row
|
|
2506
|
-
// never reflows it); elsewhere `selected` keeps the chevron.
|
|
2507
|
-
|
|
2684
|
+
// never reflows it); elsewhere `selected` keeps the chevron. No row
|
|
2685
|
+
// of a `sidebar`-style list has one (iPadOS).
|
|
2686
|
+
if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
|
|
2508
2687
|
chrome['chevron'] = {
|
|
2509
2688
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
2510
2689
|
y,
|
|
@@ -2524,18 +2703,20 @@ export class LayoutPass {
|
|
|
2524
2703
|
// centered on the other axes when smaller than the viewport.
|
|
2525
2704
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2526
2705
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2527
|
-
// Under
|
|
2528
|
-
|
|
2706
|
+
// Under its screen's bar (and the large title that scrolls with it),
|
|
2707
|
+
// the content starts below them.
|
|
2708
|
+
const inset = axes.vertical ? this.scrollInsets.get(node) : undefined;
|
|
2529
2709
|
const content: Rect = {
|
|
2530
2710
|
x: x + dx,
|
|
2531
|
-
y: y + dy + (
|
|
2711
|
+
y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
|
|
2532
2712
|
width: plan.contentSize.width,
|
|
2533
2713
|
height: plan.contentSize.height,
|
|
2534
2714
|
};
|
|
2535
2715
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2536
2716
|
const scrollChrome: Record<string, Rect> = { content };
|
|
2537
2717
|
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2538
|
-
if (
|
|
2718
|
+
if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
|
|
2719
|
+
if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
|
|
2539
2720
|
this.setChrome(node, scrollChrome);
|
|
2540
2721
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2541
2722
|
return;
|
|
@@ -2559,6 +2740,12 @@ export class LayoutPass {
|
|
|
2559
2740
|
case 'navsplit':
|
|
2560
2741
|
this.placeNavSplit(node, cctx, x, y, size);
|
|
2561
2742
|
return;
|
|
2743
|
+
case 'inspector':
|
|
2744
|
+
this.placeInspector(node, cctx, x, y, size);
|
|
2745
|
+
return;
|
|
2746
|
+
case 'inspectorpanel':
|
|
2747
|
+
this.placeInspectorPanel(node, cctx, x, y, size);
|
|
2748
|
+
return;
|
|
2562
2749
|
case 'viewthatfits': {
|
|
2563
2750
|
// Phase 7: only the chosen candidate is placed (at the origin, the
|
|
2564
2751
|
// element is its size); the others have no frame and are hidden.
|
|
@@ -2654,8 +2841,10 @@ export class LayoutPass {
|
|
|
2654
2841
|
case 'gesture':
|
|
2655
2842
|
case 'listrow':
|
|
2656
2843
|
case 'tapgesture':
|
|
2844
|
+
case 'draggable':
|
|
2845
|
+
case 'dropdestination':
|
|
2657
2846
|
default: {
|
|
2658
|
-
// `tapgesture`, `gesture`, `listrow` (child at the same origin), `toolbar` outside a navscreen,
|
|
2847
|
+
// `tapgesture`, `gesture`, `listrow`, `draggable`, `dropdestination` (child at the same origin), `toolbar` outside a navscreen,
|
|
2659
2848
|
// `tab` outside a tabview, `section` outside a list and unknown kinds.
|
|
2660
2849
|
const content = this.contentOf(node);
|
|
2661
2850
|
if (content) this.place(content, cctx, p, x, y);
|
|
@@ -2664,26 +2853,71 @@ export class LayoutPass {
|
|
|
2664
2853
|
}
|
|
2665
2854
|
}
|
|
2666
2855
|
|
|
2667
|
-
/**
|
|
2856
|
+
/**
|
|
2857
|
+
* The part of a scrolling view's frame that a tab bar (or its screen's
|
|
2858
|
+
* bottom toolbar or search field) covers (chrome `bottomInset`).
|
|
2859
|
+
*/
|
|
2668
2860
|
private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
|
|
2669
2861
|
if (ctx.bottomBar <= 0) return null;
|
|
2670
2862
|
const bottom = y + size.height;
|
|
2671
|
-
if (bottom < this.env.screen.height - EPSILON) return null;
|
|
2863
|
+
if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
|
|
2672
2864
|
const height = Math.min(size.height, ctx.bottomBar);
|
|
2673
2865
|
return { x, y: bottom - height, width: size.width, height };
|
|
2674
2866
|
}
|
|
2675
2867
|
|
|
2868
|
+
/**
|
|
2869
|
+
* iOS 26: a vertical list or scroll view deeper in a screen's content
|
|
2870
|
+
* whose top edge touches the screen's bar across the screen (`barAbove`)
|
|
2871
|
+
* runs up under it, and one whose bottom edge touches a bar covering the
|
|
2872
|
+
* bottom (`bottomBar`: a tab bar, a bottom toolbar or search field) runs
|
|
2873
|
+
* down under that, as the screen's own scroll view does: the proposal and
|
|
2874
|
+
* frame grow by the bar (the parent's layout is unchanged, as for
|
|
2875
|
+
* `.ignoresSafeArea`) and the content is inset by it (chrome `topInset` /
|
|
2876
|
+
* `bottomInset`), so it scrolls under the bar, where the scroll edge
|
|
2877
|
+
* effect shows (styles.css). Null when nothing grows.
|
|
2878
|
+
*/
|
|
2879
|
+
private scrollEdgeGrowth(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): { p: Proposal; y: number } | null {
|
|
2880
|
+
if (node.kind !== 'list' && !(node.kind === 'scrollview' && this.scrollAxes(node).vertical)) return null;
|
|
2881
|
+
const inset = this.scrollInsets.get(node);
|
|
2882
|
+
const bar = ctx.barAbove;
|
|
2883
|
+
// The screen's own scroll view is inset by `placeNavScreen` (no bar above
|
|
2884
|
+
// here); a nested one that no longer touches the bar loses its inset.
|
|
2885
|
+
if (inset?.nested) this.scrollInsets.delete(node);
|
|
2886
|
+
if (!finite(p.height)) return null;
|
|
2887
|
+
const size = this.measure(node, ctx, p);
|
|
2888
|
+
let top = 0;
|
|
2889
|
+
if (
|
|
2890
|
+
bar &&
|
|
2891
|
+
!(inset && !inset.nested) &&
|
|
2892
|
+
Math.abs(y - bar.top) <= EPSILON &&
|
|
2893
|
+
x <= bar.x + EPSILON &&
|
|
2894
|
+
x + size.width >= bar.x + bar.width - EPSILON
|
|
2895
|
+
) {
|
|
2896
|
+
top = bar.height;
|
|
2897
|
+
this.scrollInsets.set(node, { bar: top, largeTitle: null, nested: true });
|
|
2898
|
+
}
|
|
2899
|
+
let bottom = 0;
|
|
2900
|
+
if (ctx.bottomBar > 0) {
|
|
2901
|
+
const edge = ctx.bottomEdge ?? this.env.screen.height;
|
|
2902
|
+
if (Math.abs(y + size.height - (edge - ctx.bottomBar)) <= EPSILON) bottom = ctx.bottomBar;
|
|
2903
|
+
}
|
|
2904
|
+
if (top === 0 && bottom === 0) return null;
|
|
2905
|
+
return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2676
2908
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2677
2909
|
const plan = this.listPlan(node, ctx, p);
|
|
2678
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2910
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2679
2911
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2680
|
-
// Under
|
|
2681
|
-
|
|
2912
|
+
// Under its screen's bar (and the large title that scrolls with it), the
|
|
2913
|
+
// rows start below them.
|
|
2914
|
+
const scrollInset = this.scrollInsets.get(node);
|
|
2682
2915
|
const listChrome: Record<string, Rect> = {};
|
|
2683
2916
|
if (covered) listChrome['bottomInset'] = covered;
|
|
2684
|
-
if (
|
|
2917
|
+
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
2918
|
+
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
2685
2919
|
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2686
|
-
const inset =
|
|
2920
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
|
|
2687
2921
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2688
2922
|
y += inset;
|
|
2689
2923
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
@@ -2723,8 +2957,11 @@ export class LayoutPass {
|
|
|
2723
2957
|
// Phase 8: the edit control belongs to the `listrow`, which is the
|
|
2724
2958
|
// row itself or sits under its wrappers (its own place() keeps chrome).
|
|
2725
2959
|
if (row.edit) {
|
|
2726
|
-
|
|
2727
|
-
|
|
2960
|
+
const controls: Record<string, Rect> = {};
|
|
2961
|
+
if (row.edit.control) controls['editControl'] = offset(row.edit.control);
|
|
2962
|
+
if (row.edit.reorder) controls['reorderControl'] = offset(row.edit.reorder);
|
|
2963
|
+
if (row.edit.listRow === row.node) Object.assign(chrome, controls);
|
|
2964
|
+
else this.setChrome(row.edit.listRow, controls);
|
|
2728
2965
|
}
|
|
2729
2966
|
const rowX = x + row.content.x;
|
|
2730
2967
|
const rowY = y + row.content.y;
|
|
@@ -2777,7 +3014,7 @@ export class LayoutPass {
|
|
|
2777
3014
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2778
3015
|
const depth = num(node.props['depth']) ?? 0;
|
|
2779
3016
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2780
|
-
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
3017
|
+
const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
2781
3018
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2782
3019
|
const parts = this.navScreenParts(node);
|
|
2783
3020
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
@@ -2795,6 +3032,8 @@ export class LayoutPass {
|
|
|
2795
3032
|
};
|
|
2796
3033
|
const chrome: Record<string, Rect> = {
|
|
2797
3034
|
bar: { x, y, width: size.width, height: barHeight },
|
|
3035
|
+
// The 44 row the bar's items sit in, under the status bar.
|
|
3036
|
+
barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
|
|
2798
3037
|
content,
|
|
2799
3038
|
};
|
|
2800
3039
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
|
|
@@ -2844,13 +3083,14 @@ export class LayoutPass {
|
|
|
2844
3083
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2845
3084
|
// title no longer shows).
|
|
2846
3085
|
const backLabel = '';
|
|
3086
|
+
const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
|
|
2847
3087
|
if (depth > 0) {
|
|
2848
|
-
chrome['back'] = { x:
|
|
3088
|
+
chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2849
3089
|
}
|
|
2850
3090
|
|
|
2851
3091
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2852
3092
|
const back = chrome['back'];
|
|
2853
|
-
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING :
|
|
3093
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
2854
3094
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2855
3095
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2856
3096
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -2921,7 +3161,11 @@ export class LayoutPass {
|
|
|
2921
3161
|
// A large title that scrolls away collapses into the inline title, which
|
|
2922
3162
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2923
3163
|
if ((!large || titleScrolls) && !principal) {
|
|
2924
|
-
const leftUsed = Math.max(
|
|
3164
|
+
const leftUsed = Math.max(
|
|
3165
|
+
back ? back.x + back.width - x : 0,
|
|
3166
|
+
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
3167
|
+
cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
|
|
3168
|
+
);
|
|
2925
3169
|
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2926
3170
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2927
3171
|
const available = Math.max(0, size.width - 2 * side);
|
|
@@ -2950,23 +3194,46 @@ export class LayoutPass {
|
|
|
2950
3194
|
|
|
2951
3195
|
const child = parts.content;
|
|
2952
3196
|
if (!child) return;
|
|
3197
|
+
// The room a bottom toolbar or search field takes; scroll views run under
|
|
3198
|
+
// it (iOS 26), other content stops above it.
|
|
3199
|
+
const bottomCover = Math.max(bottomBarHeight, searchBottom);
|
|
2953
3200
|
// Under a tab bar the bar (which includes the bottom safe area) is what
|
|
2954
3201
|
// non-scrolling content must clear; a bottom toolbar already excluded it.
|
|
2955
3202
|
const bottomInset =
|
|
2956
3203
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2957
3204
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2958
3205
|
// large title / search field), which is transparent over it.
|
|
2959
|
-
const contentCtx: Context = {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
3206
|
+
const contentCtx: Context = {
|
|
3207
|
+
...cctx,
|
|
3208
|
+
safeTop: content.y,
|
|
3209
|
+
barInset: 0,
|
|
3210
|
+
barLeading: 0,
|
|
3211
|
+
barAbove: undefined,
|
|
3212
|
+
...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
|
|
3213
|
+
};
|
|
3214
|
+
const scrollsUnderBar =
|
|
3215
|
+
scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
3216
|
+
if (scroller && scrollsUnderBar) {
|
|
3217
|
+
// iOS 26: the scroll view starts at the top of the screen and its
|
|
3218
|
+
// content scrolls under the bar (and the large title moves with it);
|
|
3219
|
+
// the content starts below them. It runs to the bottom of the screen,
|
|
3220
|
+
// under a bottom toolbar or search field too.
|
|
3221
|
+
const top = content.y - y;
|
|
3222
|
+
this.scrollInsets.set(scroller, {
|
|
3223
|
+
bar: barHeight,
|
|
3224
|
+
largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
|
|
3225
|
+
});
|
|
3226
|
+
this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
|
|
2966
3227
|
return;
|
|
2967
3228
|
}
|
|
2968
|
-
if (scroller) this.
|
|
2969
|
-
|
|
3229
|
+
if (scroller) this.scrollInsets.delete(scroller);
|
|
3230
|
+
// A scroll view deeper in the content that touches the bar runs under it
|
|
3231
|
+
// (`scrollEdgeGrowth`); not under a large title, which stays put then.
|
|
3232
|
+
// One that reaches the bottom of the content runs down under all the
|
|
3233
|
+
// content clears there.
|
|
3234
|
+
if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
|
|
3235
|
+
if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
|
|
3236
|
+
if (scroller && scroller.kind === 'scrollview') {
|
|
2970
3237
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2971
3238
|
return;
|
|
2972
3239
|
}
|
|
@@ -3013,7 +3280,7 @@ export class LayoutPass {
|
|
|
3013
3280
|
}
|
|
3014
3281
|
this.setChrome(node, chrome);
|
|
3015
3282
|
const selected = readSelected(node, n);
|
|
3016
|
-
const tabCtx: Context = { ...cctx, bottomBar: barHeight };
|
|
3283
|
+
const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
|
|
3017
3284
|
tabs.forEach((tab, i) => {
|
|
3018
3285
|
this.setFrame(tab, x, y, size);
|
|
3019
3286
|
if (i !== selected) {
|
|
@@ -3071,6 +3338,12 @@ export class LayoutPass {
|
|
|
3071
3338
|
* to the detail column (when the split view touches those screen edges):
|
|
3072
3339
|
* the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
|
|
3073
3340
|
* navstacks start / end at them.
|
|
3341
|
+
*
|
|
3342
|
+
* iPadOS: a sidebar toggle (a 44 glass circle, chrome `toggle`) sits at the
|
|
3343
|
+
* leading edge of the bar row, in the sidebar's bar or, with the sidebar
|
|
3344
|
+
* hidden (`sidebarVisible: false`), in the detail's; that column's screens
|
|
3345
|
+
* start their back button and leading items after it. A hidden sidebar
|
|
3346
|
+
* slides off the leading edge and the detail column takes the width.
|
|
3074
3347
|
*/
|
|
3075
3348
|
private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3076
3349
|
const { screen, safeArea } = this.env;
|
|
@@ -3078,27 +3351,81 @@ export class LayoutPass {
|
|
|
3078
3351
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3079
3352
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
3080
3353
|
const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
3081
|
-
const
|
|
3082
|
-
|
|
3354
|
+
const visible = node.props['sidebarVisible'] !== false;
|
|
3355
|
+
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
3356
|
+
const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
|
|
3357
|
+
const separatorX = sidebarX + sidebarWidth;
|
|
3358
|
+
const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
|
|
3083
3359
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
3360
|
+
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
3084
3361
|
const chrome: Record<string, Rect> = {
|
|
3085
|
-
sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
|
|
3362
|
+
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
3086
3363
|
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 },
|
|
3364
|
+
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
3365
|
+
toggle: { x: x + leading + NAV_ITEM_MARGIN, y: y + statusBar, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE },
|
|
3088
3366
|
};
|
|
3089
3367
|
this.setChrome(node, chrome);
|
|
3368
|
+
const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
|
|
3090
3369
|
const [sidebar, detail, ...rest] = node.children;
|
|
3091
3370
|
if (sidebar) {
|
|
3092
3371
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
3093
|
-
this.place(sidebar, { ...cctx, splitSidebar: true }, proposal,
|
|
3372
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
3094
3373
|
}
|
|
3095
3374
|
if (detail) {
|
|
3096
3375
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
3097
|
-
this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
|
|
3376
|
+
this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
3098
3377
|
}
|
|
3099
3378
|
for (const extra of rest) this.hide(extra, node);
|
|
3100
3379
|
}
|
|
3101
3380
|
|
|
3381
|
+
/**
|
|
3382
|
+
* `.inspector` (regular width): the children are the content, then the
|
|
3383
|
+
* `inspectorpanel`. The panel is a trailing column, 320 wide (its
|
|
3384
|
+
* content's `inspectorColumnWidth`, at most half the width) plus the
|
|
3385
|
+
* trailing safe-area inset when it touches that edge, with a 1px gap
|
|
3386
|
+
* before it (the panel draws the hairline there); the content is proposed the rest (each content child is
|
|
3387
|
+
* centered in it, as a root view is). With `presented: false` the panel
|
|
3388
|
+
* sits just off the trailing edge (so it slides) and the content takes the
|
|
3389
|
+
* whole width.
|
|
3390
|
+
*/
|
|
3391
|
+
private placeInspector(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3392
|
+
const { screen, safeArea } = this.env;
|
|
3393
|
+
const last = node.children[node.children.length - 1];
|
|
3394
|
+
const panel = last && last.kind === 'inspectorpanel' ? last : null;
|
|
3395
|
+
const main = panel ? node.children.slice(0, -1) : node.children;
|
|
3396
|
+
const presented = node.props['presented'] === true;
|
|
3397
|
+
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3398
|
+
const requested = panel?.children[0] ? num(cellStyle(panel.children[0], 'inspectorColumnWidth')) : null;
|
|
3399
|
+
const columnWidth = Math.min(requested ?? INSPECTOR_WIDTH, size.width * INSPECTOR_MAX_FRACTION);
|
|
3400
|
+
const panelWidth = columnWidth + trailing;
|
|
3401
|
+
const panelX = presented ? x + size.width - panelWidth : x + size.width + NAVSPLIT_SEPARATOR_WIDTH;
|
|
3402
|
+
const mainWidth = presented ? Math.max(0, panelX - NAVSPLIT_SEPARATOR_WIDTH - x) : size.width;
|
|
3403
|
+
const area: Size = { width: mainWidth, height: size.height };
|
|
3404
|
+
for (const child of main) {
|
|
3405
|
+
const childSize = this.measure(child, cctx, area);
|
|
3406
|
+
const { dx, dy } = alignIn(area, childSize, 'center');
|
|
3407
|
+
this.place(child, cctx, area, x + dx, y + dy);
|
|
3408
|
+
}
|
|
3409
|
+
if (panel) this.place(panel, cctx, { width: panelWidth, height: size.height }, panelX, y);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
/**
|
|
3413
|
+
* The inspector column: its content below the status bar (at the top of
|
|
3414
|
+
* the screen) and clear of the trailing inset, top-aligned and centered
|
|
3415
|
+
* horizontally (a list or form fills it).
|
|
3416
|
+
*/
|
|
3417
|
+
private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3418
|
+
const { screen, safeArea } = this.env;
|
|
3419
|
+
const top = y <= 0 ? safeArea.top : 0;
|
|
3420
|
+
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3421
|
+
const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
|
|
3422
|
+
for (const child of node.children) {
|
|
3423
|
+
const childSize = this.measure(child, cctx, area);
|
|
3424
|
+
const { dx } = alignIn(area, childSize, 'center');
|
|
3425
|
+
this.place(child, cctx, area, x + dx, y + top);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3102
3429
|
/**
|
|
3103
3430
|
* Phase 7 `ViewThatFits`: the first child whose ideal size fits the
|
|
3104
3431
|
* proposal on the restricted axes (a nil or infinite proposal axis always
|
|
@@ -3350,7 +3677,7 @@ export class LayoutPass {
|
|
|
3350
3677
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
3351
3678
|
return {
|
|
3352
3679
|
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
3353
|
-
height: wrapped.lines.length
|
|
3680
|
+
height: textHeight(wrapped.lines.length, font),
|
|
3354
3681
|
};
|
|
3355
3682
|
};
|
|
3356
3683
|
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|