@swiftbrowser/web 0.3.0 → 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 +492 -91
- 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 +430 -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,18 +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;
|
|
184
|
+
// A row holding just a toggle or a stepper has 10 above and below it (so
|
|
185
|
+
// both come out 52 tall), a segmented picker 14.5 (61).
|
|
186
|
+
const LIST_ROW_CONTROL_VPAD = 10;
|
|
187
|
+
const LIST_ROW_SEGMENTED_VPAD = 14.5;
|
|
154
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;
|
|
155
193
|
const LIST_TOP = 18;
|
|
156
194
|
const LIST_BOTTOM = 20;
|
|
157
|
-
const LIST_HEADER_LIFT =
|
|
195
|
+
const LIST_HEADER_LIFT = 8;
|
|
158
196
|
const LIST_HEADER_GAP = 10;
|
|
159
|
-
const LIST_FOOTER_GAP =
|
|
160
|
-
const LIST_SECTION_GAP =
|
|
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;
|
|
161
201
|
const LIST_CHEVRON_WIDTH = 20;
|
|
162
202
|
// Phase 4.
|
|
163
203
|
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
|
|
@@ -219,15 +259,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
|
219
259
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
220
260
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
221
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;
|
|
222
265
|
const EPSILON = 1e-6;
|
|
223
266
|
// Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
|
|
224
267
|
/** The segmented −/+ control of a `stepper`, trailing. */
|
|
225
268
|
const STEPPER_WIDTH = 94;
|
|
226
269
|
const STEPPER_HEIGHT = 32;
|
|
227
270
|
const STEPPER_GAP = 8;
|
|
228
|
-
/** A `slider` is
|
|
229
|
-
// iOS 26: a 6pt track and a 38 × 24 pill knob in a
|
|
230
|
-
|
|
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;
|
|
231
275
|
const SLIDER_TRACK_HEIGHT = 6;
|
|
232
276
|
const SLIDER_THUMB = 38;
|
|
233
277
|
const SLIDER_THUMB_HEIGHT = 24;
|
|
@@ -460,6 +504,19 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
460
504
|
return undefined;
|
|
461
505
|
}
|
|
462
506
|
|
|
507
|
+
/** The default vertical padding above and below a list row's content. */
|
|
508
|
+
function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
509
|
+
switch (row.kind) {
|
|
510
|
+
case 'picker':
|
|
511
|
+
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
512
|
+
case 'toggle':
|
|
513
|
+
case 'stepper':
|
|
514
|
+
return LIST_ROW_CONTROL_VPAD;
|
|
515
|
+
default:
|
|
516
|
+
return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
463
520
|
/** `gridCellColumns` of a grid cell (at least 1). */
|
|
464
521
|
function gridSpanOf(node: LayoutNode): number {
|
|
465
522
|
const span = num(cellStyle(node, 'gridCellColumns'));
|
|
@@ -472,6 +529,20 @@ function gridAnchorOf(node: LayoutNode): [number, number] | null {
|
|
|
472
529
|
return point ? [point.x, point.y] : null;
|
|
473
530
|
}
|
|
474
531
|
|
|
532
|
+
/** `gridColumnAlignment` of a grid cell as a horizontal unit anchor. */
|
|
533
|
+
function gridColumnAnchorOf(node: LayoutNode): number | null {
|
|
534
|
+
switch (str(cellStyle(node, 'gridColumnAlignment'))) {
|
|
535
|
+
case 'leading':
|
|
536
|
+
return 0;
|
|
537
|
+
case 'center':
|
|
538
|
+
return 0.5;
|
|
539
|
+
case 'trailing':
|
|
540
|
+
return 1;
|
|
541
|
+
default:
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
475
546
|
/** Vertical anchor of a `gridrow` alignment (null → the grid's). */
|
|
476
547
|
function rowAnchor(alignment: string | null, gridAlignment: string): number {
|
|
477
548
|
switch (alignment) {
|
|
@@ -576,7 +647,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
|
|
|
576
647
|
}
|
|
577
648
|
|
|
578
649
|
/** Phase 8 wrappers whose first child is the row content they stand for. */
|
|
579
|
-
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']);
|
|
580
651
|
|
|
581
652
|
/**
|
|
582
653
|
* Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
|
|
@@ -592,8 +663,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
|
|
|
592
663
|
return null;
|
|
593
664
|
}
|
|
594
665
|
|
|
595
|
-
/**
|
|
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. */
|
|
596
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;
|
|
597
692
|
|
|
598
693
|
/** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
|
|
599
694
|
function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
@@ -601,7 +696,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
|
|
|
601
696
|
const size = font.size * scale;
|
|
602
697
|
const lineHeight = font.lineHeight * scale;
|
|
603
698
|
const px = (v: number): string => `${Math.round(v * 100) / 100}px`;
|
|
604
|
-
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]}` };
|
|
605
700
|
}
|
|
606
701
|
|
|
607
702
|
// ---------------------------------------------------------------------------
|
|
@@ -814,8 +909,12 @@ interface ListRowPlan {
|
|
|
814
909
|
content: Rect;
|
|
815
910
|
/** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
|
|
816
911
|
badge: { rect: Rect; layout: TextLayout } | null;
|
|
817
|
-
/**
|
|
818
|
-
|
|
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;
|
|
819
918
|
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
820
919
|
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
821
920
|
}
|
|
@@ -892,6 +991,8 @@ interface GridPlan {
|
|
|
892
991
|
size: Size;
|
|
893
992
|
columns: number[];
|
|
894
993
|
columnX: number[];
|
|
994
|
+
/** Horizontal unit anchor per column from a cell's `gridColumnAlignment` (null → the grid's). */
|
|
995
|
+
columnAnchors: (number | null)[];
|
|
895
996
|
rows: GridRowPlan[];
|
|
896
997
|
}
|
|
897
998
|
|
|
@@ -971,11 +1072,13 @@ export class LayoutPass {
|
|
|
971
1072
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
972
1073
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
973
1074
|
/**
|
|
974
|
-
* A
|
|
975
|
-
*
|
|
976
|
-
*
|
|
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.
|
|
977
1080
|
*/
|
|
978
|
-
private readonly
|
|
1081
|
+
private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
|
|
979
1082
|
|
|
980
1083
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
981
1084
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -1020,10 +1123,17 @@ export class LayoutPass {
|
|
|
1020
1123
|
const { screen, safeArea } = this.env;
|
|
1021
1124
|
const ctx = this.rootContext;
|
|
1022
1125
|
for (const root of roots) {
|
|
1023
|
-
if (
|
|
1024
|
-
|
|
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
|
|
1025
1134
|
// extend into them and the content is inset instead (a `navsplit`
|
|
1026
|
-
// 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).
|
|
1027
1137
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1028
1138
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1029
1139
|
} else if (root.kind === 'sheet') {
|
|
@@ -1038,6 +1148,14 @@ export class LayoutPass {
|
|
|
1038
1148
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
1039
1149
|
height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
|
|
1040
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
|
+
}
|
|
1041
1159
|
const proposal: Proposal = { width: safe.width, height: safe.height };
|
|
1042
1160
|
const size = this.measure(root, ctx, proposal);
|
|
1043
1161
|
const { dx, dy } = alignIn(safe, size, 'center');
|
|
@@ -1047,6 +1165,32 @@ export class LayoutPass {
|
|
|
1047
1165
|
return this.finish();
|
|
1048
1166
|
}
|
|
1049
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
|
+
|
|
1050
1194
|
private finish(): LayoutResult {
|
|
1051
1195
|
// Only what this run produced needs snapping; the rest was snapped when
|
|
1052
1196
|
// it was produced.
|
|
@@ -1208,10 +1352,20 @@ export class LayoutPass {
|
|
|
1208
1352
|
return { ...ctx, stackAxis: 'vertical', listRow: false };
|
|
1209
1353
|
case 'hstack':
|
|
1210
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
|
+
}
|
|
1211
1363
|
case 'gesture':
|
|
1212
1364
|
case 'swipeactions':
|
|
1213
1365
|
case 'contextmenu':
|
|
1214
1366
|
case 'listrow':
|
|
1367
|
+
case 'draggable':
|
|
1368
|
+
case 'dropdestination':
|
|
1215
1369
|
// Phase 8 wrappers are transparent: a `navlink` under them is still
|
|
1216
1370
|
// the list row (its chevron, its selected fill).
|
|
1217
1371
|
return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
|
|
@@ -1292,8 +1446,11 @@ export class LayoutPass {
|
|
|
1292
1446
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
1293
1447
|
case 'vstack':
|
|
1294
1448
|
return this.stackPlan(node, ctx, p, 'vertical').size;
|
|
1295
|
-
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;
|
|
1296
1452
|
return this.stackPlan(node, ctx, p, 'horizontal').size;
|
|
1453
|
+
}
|
|
1297
1454
|
case 'zstack':
|
|
1298
1455
|
return this.measureZStack(node, ctx, p);
|
|
1299
1456
|
case 'styled':
|
|
@@ -1310,8 +1467,15 @@ export class LayoutPass {
|
|
|
1310
1467
|
height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
|
|
1311
1468
|
};
|
|
1312
1469
|
}
|
|
1313
|
-
case 'color':
|
|
1314
1470
|
case 'shape':
|
|
1471
|
+
if (node.props['shape'] === 'circle') {
|
|
1472
|
+
// SwiftUI's `Circle` is the largest square in the proposal (so in a
|
|
1473
|
+
// stack it gives the leftover width to its siblings).
|
|
1474
|
+
// An open axis counts as SHAPE_DEFAULT, as for the other shapes.
|
|
1475
|
+
return square(Math.min(p.width ?? SHAPE_DEFAULT, p.height ?? SHAPE_DEFAULT));
|
|
1476
|
+
}
|
|
1477
|
+
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1478
|
+
case 'color':
|
|
1315
1479
|
case 'canvas':
|
|
1316
1480
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1317
1481
|
case 'video':
|
|
@@ -1328,6 +1492,8 @@ export class LayoutPass {
|
|
|
1328
1492
|
case 'tabview':
|
|
1329
1493
|
case 'tab':
|
|
1330
1494
|
case 'navsplit':
|
|
1495
|
+
case 'inspector':
|
|
1496
|
+
case 'inspectorpanel':
|
|
1331
1497
|
return {
|
|
1332
1498
|
width: p.width ?? this.env.screen.width,
|
|
1333
1499
|
height: p.height ?? this.env.screen.height,
|
|
@@ -1336,6 +1502,8 @@ export class LayoutPass {
|
|
|
1336
1502
|
return this.viewThatFitsPlan(node, ctx, p).size;
|
|
1337
1503
|
case 'picker':
|
|
1338
1504
|
return this.pickerPlan(node, ctx, p).size;
|
|
1505
|
+
case 'host':
|
|
1506
|
+
return this.measureHost(node, p);
|
|
1339
1507
|
case 'geometry':
|
|
1340
1508
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1341
1509
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
@@ -1393,6 +1561,8 @@ export class LayoutPass {
|
|
|
1393
1561
|
case 'gesture':
|
|
1394
1562
|
case 'listrow':
|
|
1395
1563
|
case 'tapgesture':
|
|
1564
|
+
case 'draggable':
|
|
1565
|
+
case 'dropdestination':
|
|
1396
1566
|
default: {
|
|
1397
1567
|
// `tapgesture` (its child's size), `toolbar` outside a navscreen,
|
|
1398
1568
|
// `section` outside a list and unknown kinds: lay the children out as a vstack.
|
|
@@ -1415,7 +1585,7 @@ export class LayoutPass {
|
|
|
1415
1585
|
const min = Math.max(1, ctx.minLines ?? 1);
|
|
1416
1586
|
const max = Math.max(min, ctx.lineLimit ?? Infinity);
|
|
1417
1587
|
const lines = Math.min(Math.max(wrapped.lines.length, min), max);
|
|
1418
|
-
return { width, height: lines
|
|
1588
|
+
return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
|
|
1419
1589
|
}
|
|
1420
1590
|
|
|
1421
1591
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
@@ -1425,7 +1595,7 @@ export class LayoutPass {
|
|
|
1425
1595
|
const text = str(node.props['text']) ?? '';
|
|
1426
1596
|
const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1427
1597
|
return {
|
|
1428
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
1598
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
|
|
1429
1599
|
layout: {
|
|
1430
1600
|
font: ctx.font,
|
|
1431
1601
|
lines: wrapped.lines,
|
|
@@ -1464,16 +1634,16 @@ export class LayoutPass {
|
|
|
1464
1634
|
};
|
|
1465
1635
|
}
|
|
1466
1636
|
|
|
1467
|
-
/** 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. */
|
|
1468
1638
|
private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
|
|
1469
1639
|
if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
|
|
1470
1640
|
return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
|
|
1471
1641
|
}
|
|
1472
1642
|
const name = str(props['name']);
|
|
1473
|
-
if (name === null) return square(font.
|
|
1643
|
+
if (name === null) return square(font.leading);
|
|
1474
1644
|
const asset = this.env.assets?.images[name];
|
|
1475
1645
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1476
|
-
if (asset.kind === 'symbol') return square(font.
|
|
1646
|
+
if (asset.kind === 'symbol') return square(font.leading);
|
|
1477
1647
|
return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
|
|
1478
1648
|
}
|
|
1479
1649
|
|
|
@@ -1733,6 +1903,25 @@ export class LayoutPass {
|
|
|
1733
1903
|
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1734
1904
|
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1735
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
|
+
|
|
1736
1925
|
private layoutStack(
|
|
1737
1926
|
children: readonly LayoutNode[],
|
|
1738
1927
|
cctx: Context,
|
|
@@ -1905,10 +2094,10 @@ export class LayoutPass {
|
|
|
1905
2094
|
return { width: p.width ?? width, height: p.height ?? height };
|
|
1906
2095
|
}
|
|
1907
2096
|
const name = str(node.props['name']);
|
|
1908
|
-
if (name === null) return square(ctx.font.
|
|
2097
|
+
if (name === null) return square(ctx.font.leading * ctx.imageScale);
|
|
1909
2098
|
const asset = this.env.assets?.images[name];
|
|
1910
2099
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1911
|
-
if (asset.kind === 'symbol') return square(ctx.font.
|
|
2100
|
+
if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
|
|
1912
2101
|
const width = num(asset.width) ?? 0;
|
|
1913
2102
|
const height = num(asset.height) ?? 0;
|
|
1914
2103
|
if (node.props['resizable'] !== true) return { width, height };
|
|
@@ -2111,11 +2300,13 @@ export class LayoutPass {
|
|
|
2111
2300
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
2112
2301
|
// `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
|
|
2113
2302
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
2114
|
-
// 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).
|
|
2115
2305
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
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;
|
|
2119
2310
|
const plain = listStyle === 'plain';
|
|
2120
2311
|
const listTop = plain ? 0 : LIST_TOP;
|
|
2121
2312
|
const listBottom = plain ? 0 : LIST_BOTTOM;
|
|
@@ -2135,9 +2326,10 @@ export class LayoutPass {
|
|
|
2135
2326
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
2136
2327
|
400,
|
|
2137
2328
|
);
|
|
2138
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2139
|
-
// Phase 8: in edit mode
|
|
2140
|
-
//
|
|
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.
|
|
2141
2333
|
const editing = node.props['editing'] === true;
|
|
2142
2334
|
|
|
2143
2335
|
// Group consecutive plain rows into an implicit section. A section's
|
|
@@ -2173,12 +2365,16 @@ export class LayoutPass {
|
|
|
2173
2365
|
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
2174
2366
|
return {
|
|
2175
2367
|
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2176
|
-
size: { width: wrapped.width, height: wrapped.lines.length
|
|
2368
|
+
size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
|
|
2177
2369
|
};
|
|
2178
2370
|
};
|
|
2179
2371
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
2180
2372
|
const source = groupsSource[g] as GroupSource;
|
|
2181
|
-
|
|
2373
|
+
const previous = groups[g - 1];
|
|
2374
|
+
if (previous) {
|
|
2375
|
+
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
2376
|
+
y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
2377
|
+
}
|
|
2182
2378
|
const top = y;
|
|
2183
2379
|
let header: ListGroupPlan['header'] = null;
|
|
2184
2380
|
let footer: ListGroupPlan['footer'] = null;
|
|
@@ -2205,13 +2401,12 @@ export class LayoutPass {
|
|
|
2205
2401
|
continue;
|
|
2206
2402
|
}
|
|
2207
2403
|
// A menu or inline picker already reports its row height (44 for
|
|
2208
|
-
// a menu, 44 per option inline);
|
|
2209
|
-
//
|
|
2404
|
+
// a menu, 44 per option inline); a toggle, stepper or segmented
|
|
2405
|
+
// picker has its own vertical padding, other rows 13. Phase 6
|
|
2210
2406
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2211
2407
|
// width (plus a gap) at the trailing edge.
|
|
2212
2408
|
const hints = rowHintsOf(rowNode);
|
|
2213
|
-
const
|
|
2214
|
-
const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
|
|
2409
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
|
|
2215
2410
|
const insets: EdgeInsets = hints.insets ?? {
|
|
2216
2411
|
top: defaultVPad,
|
|
2217
2412
|
leading: LIST_CELL_PADDING,
|
|
@@ -2220,10 +2415,16 @@ export class LayoutPass {
|
|
|
2220
2415
|
};
|
|
2221
2416
|
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
|
|
2222
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.
|
|
2223
2421
|
const listRow = editing ? listRowOf(rowNode) : null;
|
|
2224
|
-
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;
|
|
2225
2426
|
const rowContentX = cardX + insets.leading + editShift;
|
|
2226
|
-
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);
|
|
2227
2428
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
2228
2429
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
2229
2430
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -2246,7 +2447,15 @@ export class LayoutPass {
|
|
|
2246
2447
|
layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
|
|
2247
2448
|
}
|
|
2248
2449
|
: null,
|
|
2249
|
-
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,
|
|
2250
2459
|
separatorHidden: hints.separatorHidden,
|
|
2251
2460
|
});
|
|
2252
2461
|
y += rowHeight;
|
|
@@ -2316,6 +2525,11 @@ export class LayoutPass {
|
|
|
2316
2525
|
}
|
|
2317
2526
|
this.hiddenBy.delete(node);
|
|
2318
2527
|
}
|
|
2528
|
+
const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
|
|
2529
|
+
if (grown) {
|
|
2530
|
+
p = grown.p;
|
|
2531
|
+
y = grown.y;
|
|
2532
|
+
}
|
|
2319
2533
|
const size = this.measure(node, ctx, p);
|
|
2320
2534
|
this.setFrame(node, x, y, size);
|
|
2321
2535
|
const cctx = this.childContext(node, ctx);
|
|
@@ -2337,9 +2551,18 @@ export class LayoutPass {
|
|
|
2337
2551
|
case 'shape':
|
|
2338
2552
|
case 'canvas':
|
|
2339
2553
|
case 'video':
|
|
2554
|
+
case 'host':
|
|
2340
2555
|
return;
|
|
2341
2556
|
case 'vstack':
|
|
2342
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
|
+
}
|
|
2343
2566
|
const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
|
|
2344
2567
|
const plan = this.stackPlan(node, ctx, p, axis);
|
|
2345
2568
|
const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
@@ -2458,8 +2681,9 @@ export class LayoutPass {
|
|
|
2458
2681
|
};
|
|
2459
2682
|
// Phase 7: the selected row of a `navsplit` sidebar has no chevron
|
|
2460
2683
|
// (its label still reserves the chevron's width so selecting a row
|
|
2461
|
-
// never reflows it); elsewhere `selected` keeps the chevron.
|
|
2462
|
-
|
|
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) {
|
|
2463
2687
|
chrome['chevron'] = {
|
|
2464
2688
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
2465
2689
|
y,
|
|
@@ -2479,18 +2703,20 @@ export class LayoutPass {
|
|
|
2479
2703
|
// centered on the other axes when smaller than the viewport.
|
|
2480
2704
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2481
2705
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2482
|
-
// Under
|
|
2483
|
-
|
|
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;
|
|
2484
2709
|
const content: Rect = {
|
|
2485
2710
|
x: x + dx,
|
|
2486
|
-
y: y + dy + (
|
|
2711
|
+
y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
|
|
2487
2712
|
width: plan.contentSize.width,
|
|
2488
2713
|
height: plan.contentSize.height,
|
|
2489
2714
|
};
|
|
2490
2715
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2491
2716
|
const scrollChrome: Record<string, Rect> = { content };
|
|
2492
2717
|
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2493
|
-
if (
|
|
2718
|
+
if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
|
|
2719
|
+
if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
|
|
2494
2720
|
this.setChrome(node, scrollChrome);
|
|
2495
2721
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2496
2722
|
return;
|
|
@@ -2514,6 +2740,12 @@ export class LayoutPass {
|
|
|
2514
2740
|
case 'navsplit':
|
|
2515
2741
|
this.placeNavSplit(node, cctx, x, y, size);
|
|
2516
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;
|
|
2517
2749
|
case 'viewthatfits': {
|
|
2518
2750
|
// Phase 7: only the chosen candidate is placed (at the origin, the
|
|
2519
2751
|
// element is its size); the others have no frame and are hidden.
|
|
@@ -2609,8 +2841,10 @@ export class LayoutPass {
|
|
|
2609
2841
|
case 'gesture':
|
|
2610
2842
|
case 'listrow':
|
|
2611
2843
|
case 'tapgesture':
|
|
2844
|
+
case 'draggable':
|
|
2845
|
+
case 'dropdestination':
|
|
2612
2846
|
default: {
|
|
2613
|
-
// `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,
|
|
2614
2848
|
// `tab` outside a tabview, `section` outside a list and unknown kinds.
|
|
2615
2849
|
const content = this.contentOf(node);
|
|
2616
2850
|
if (content) this.place(content, cctx, p, x, y);
|
|
@@ -2619,26 +2853,71 @@ export class LayoutPass {
|
|
|
2619
2853
|
}
|
|
2620
2854
|
}
|
|
2621
2855
|
|
|
2622
|
-
/**
|
|
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
|
+
*/
|
|
2623
2860
|
private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
|
|
2624
2861
|
if (ctx.bottomBar <= 0) return null;
|
|
2625
2862
|
const bottom = y + size.height;
|
|
2626
|
-
if (bottom < this.env.screen.height - EPSILON) return null;
|
|
2863
|
+
if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
|
|
2627
2864
|
const height = Math.min(size.height, ctx.bottomBar);
|
|
2628
2865
|
return { x, y: bottom - height, width: size.width, height };
|
|
2629
2866
|
}
|
|
2630
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
|
+
|
|
2631
2908
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2632
2909
|
const plan = this.listPlan(node, ctx, p);
|
|
2633
|
-
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' };
|
|
2634
2911
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2635
|
-
// Under
|
|
2636
|
-
|
|
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);
|
|
2637
2915
|
const listChrome: Record<string, Rect> = {};
|
|
2638
2916
|
if (covered) listChrome['bottomInset'] = covered;
|
|
2639
|
-
if (
|
|
2917
|
+
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
2918
|
+
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
2640
2919
|
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2641
|
-
const inset =
|
|
2920
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
|
|
2642
2921
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2643
2922
|
y += inset;
|
|
2644
2923
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
@@ -2678,8 +2957,11 @@ export class LayoutPass {
|
|
|
2678
2957
|
// Phase 8: the edit control belongs to the `listrow`, which is the
|
|
2679
2958
|
// row itself or sits under its wrappers (its own place() keeps chrome).
|
|
2680
2959
|
if (row.edit) {
|
|
2681
|
-
|
|
2682
|
-
|
|
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);
|
|
2683
2965
|
}
|
|
2684
2966
|
const rowX = x + row.content.x;
|
|
2685
2967
|
const rowY = y + row.content.y;
|
|
@@ -2732,7 +3014,7 @@ export class LayoutPass {
|
|
|
2732
3014
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2733
3015
|
const depth = num(node.props['depth']) ?? 0;
|
|
2734
3016
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2735
|
-
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
3017
|
+
const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
2736
3018
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2737
3019
|
const parts = this.navScreenParts(node);
|
|
2738
3020
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
@@ -2750,6 +3032,8 @@ export class LayoutPass {
|
|
|
2750
3032
|
};
|
|
2751
3033
|
const chrome: Record<string, Rect> = {
|
|
2752
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 },
|
|
2753
3037
|
content,
|
|
2754
3038
|
};
|
|
2755
3039
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
|
|
@@ -2799,13 +3083,14 @@ export class LayoutPass {
|
|
|
2799
3083
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2800
3084
|
// title no longer shows).
|
|
2801
3085
|
const backLabel = '';
|
|
3086
|
+
const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
|
|
2802
3087
|
if (depth > 0) {
|
|
2803
|
-
chrome['back'] = { x:
|
|
3088
|
+
chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2804
3089
|
}
|
|
2805
3090
|
|
|
2806
3091
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2807
3092
|
const back = chrome['back'];
|
|
2808
|
-
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING :
|
|
3093
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
2809
3094
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2810
3095
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2811
3096
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -2876,7 +3161,11 @@ export class LayoutPass {
|
|
|
2876
3161
|
// A large title that scrolls away collapses into the inline title, which
|
|
2877
3162
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2878
3163
|
if ((!large || titleScrolls) && !principal) {
|
|
2879
|
-
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
|
+
);
|
|
2880
3169
|
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2881
3170
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2882
3171
|
const available = Math.max(0, size.width - 2 * side);
|
|
@@ -2905,23 +3194,46 @@ export class LayoutPass {
|
|
|
2905
3194
|
|
|
2906
3195
|
const child = parts.content;
|
|
2907
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);
|
|
2908
3200
|
// Under a tab bar the bar (which includes the bottom safe area) is what
|
|
2909
3201
|
// non-scrolling content must clear; a bottom toolbar already excluded it.
|
|
2910
3202
|
const bottomInset =
|
|
2911
3203
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2912
3204
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2913
3205
|
// large title / search field), which is transparent over it.
|
|
2914
|
-
const contentCtx: Context = {
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
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);
|
|
2921
3227
|
return;
|
|
2922
3228
|
}
|
|
2923
|
-
if (scroller) this.
|
|
2924
|
-
|
|
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') {
|
|
2925
3237
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2926
3238
|
return;
|
|
2927
3239
|
}
|
|
@@ -2968,7 +3280,7 @@ export class LayoutPass {
|
|
|
2968
3280
|
}
|
|
2969
3281
|
this.setChrome(node, chrome);
|
|
2970
3282
|
const selected = readSelected(node, n);
|
|
2971
|
-
const tabCtx: Context = { ...cctx, bottomBar: barHeight };
|
|
3283
|
+
const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
|
|
2972
3284
|
tabs.forEach((tab, i) => {
|
|
2973
3285
|
this.setFrame(tab, x, y, size);
|
|
2974
3286
|
if (i !== selected) {
|
|
@@ -3026,6 +3338,12 @@ export class LayoutPass {
|
|
|
3026
3338
|
* to the detail column (when the split view touches those screen edges):
|
|
3027
3339
|
* the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
|
|
3028
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.
|
|
3029
3347
|
*/
|
|
3030
3348
|
private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3031
3349
|
const { screen, safeArea } = this.env;
|
|
@@ -3033,27 +3351,81 @@ export class LayoutPass {
|
|
|
3033
3351
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3034
3352
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
3035
3353
|
const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
3036
|
-
const
|
|
3037
|
-
|
|
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;
|
|
3038
3359
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
3360
|
+
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
3039
3361
|
const chrome: Record<string, Rect> = {
|
|
3040
|
-
sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
|
|
3362
|
+
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
3041
3363
|
separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
|
|
3042
|
-
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 },
|
|
3043
3366
|
};
|
|
3044
3367
|
this.setChrome(node, chrome);
|
|
3368
|
+
const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
|
|
3045
3369
|
const [sidebar, detail, ...rest] = node.children;
|
|
3046
3370
|
if (sidebar) {
|
|
3047
3371
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
3048
|
-
this.place(sidebar, { ...cctx, splitSidebar: true }, proposal,
|
|
3372
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
3049
3373
|
}
|
|
3050
3374
|
if (detail) {
|
|
3051
3375
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
3052
|
-
this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
|
|
3376
|
+
this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
3053
3377
|
}
|
|
3054
3378
|
for (const extra of rest) this.hide(extra, node);
|
|
3055
3379
|
}
|
|
3056
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
|
+
|
|
3057
3429
|
/**
|
|
3058
3430
|
* Phase 7 `ViewThatFits`: the first child whose ideal size fits the
|
|
3059
3431
|
* proposal on the restricted axes (a nil or infinite proposal axis always
|
|
@@ -3305,7 +3677,7 @@ export class LayoutPass {
|
|
|
3305
3677
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
3306
3678
|
return {
|
|
3307
3679
|
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
3308
|
-
height: wrapped.lines.length
|
|
3680
|
+
height: textHeight(wrapped.lines.length, font),
|
|
3309
3681
|
};
|
|
3310
3682
|
};
|
|
3311
3683
|
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
@@ -3613,8 +3985,12 @@ export class LayoutPass {
|
|
|
3613
3985
|
* whose non-spanning cells are all flexible (or that has none) is flexible;
|
|
3614
3986
|
* the others take the largest ideal width of their non-spanning cells. The
|
|
3615
3987
|
* flexible columns share what is left of the proposal width equally (their
|
|
3616
|
-
* ideal width with a nil proposal).
|
|
3617
|
-
* cell
|
|
3988
|
+
* ideal width with a nil proposal). A non-`gridrow` child is one full-span
|
|
3989
|
+
* cell; one wider than the columns (a `Divider` takes the whole proposal)
|
|
3990
|
+
* widens the grid, every column by the same amount (unless it has
|
|
3991
|
+
* `gridCellUnsizedAxes(.horizontal)`). Row heights come from
|
|
3992
|
+
* measuring every cell at its cell width. A cell's `gridColumnAlignment`
|
|
3993
|
+
* aligns its whole column.
|
|
3618
3994
|
*/
|
|
3619
3995
|
private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
|
|
3620
3996
|
return this.plan(node, p, () => {
|
|
@@ -3677,6 +4053,30 @@ export class LayoutPass {
|
|
|
3677
4053
|
if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
|
|
3678
4054
|
}
|
|
3679
4055
|
}
|
|
4056
|
+
// Full-span children wider than the columns widen every column alike.
|
|
4057
|
+
const fullProposal: Proposal = { width: finite(p.width) ? p.width : null, height: null };
|
|
4058
|
+
let fullWidth = 0;
|
|
4059
|
+
for (const source of sources) {
|
|
4060
|
+
if (!source.full) continue;
|
|
4061
|
+
const cctx = this.gridCellContext(node, null, ctx);
|
|
4062
|
+
for (const cell of source.cells) {
|
|
4063
|
+
if (cellStyle(cell.node, 'gridCellUnsizedAxes') === true) continue;
|
|
4064
|
+
fullWidth = Math.max(fullWidth, this.measure(cell.node, cctx, fullProposal).width);
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
const columnsWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
|
|
4068
|
+
if (fullWidth > columnsWidth + EPSILON) {
|
|
4069
|
+
const extra = (fullWidth - columnsWidth) / columnCount;
|
|
4070
|
+
for (let j = 0; j < columnCount; j++) columns[j] = (columns[j] as number) + extra;
|
|
4071
|
+
}
|
|
4072
|
+
const columnAnchors = new Array<number | null>(columnCount).fill(null);
|
|
4073
|
+
for (const source of sources) {
|
|
4074
|
+
if (source.full) continue;
|
|
4075
|
+
for (const cell of source.cells) {
|
|
4076
|
+
const anchor = cell.span === 1 ? gridColumnAnchorOf(cell.node) : null;
|
|
4077
|
+
if (anchor !== null && columnAnchors[cell.column] === null) columnAnchors[cell.column] = anchor;
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
3680
4080
|
const columnX = new Array<number>(columnCount).fill(0);
|
|
3681
4081
|
for (let j = 1; j < columnCount; j++) {
|
|
3682
4082
|
columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
|
|
@@ -3713,7 +4113,7 @@ export class LayoutPass {
|
|
|
3713
4113
|
y += height + vSpacing;
|
|
3714
4114
|
}
|
|
3715
4115
|
const gridHeight = rows.length > 0 ? y - vSpacing : 0;
|
|
3716
|
-
return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
|
|
4116
|
+
return { size: { width: gridWidth, height: gridHeight }, columns, columnX, columnAnchors, rows };
|
|
3717
4117
|
});
|
|
3718
4118
|
}
|
|
3719
4119
|
|
|
@@ -3727,7 +4127,8 @@ export class LayoutPass {
|
|
|
3727
4127
|
const rowY = rowAnchor(row.alignment, gridAlignment);
|
|
3728
4128
|
for (const cell of row.cells) {
|
|
3729
4129
|
const box: Size = { width: cell.width, height: row.height };
|
|
3730
|
-
const
|
|
4130
|
+
const columnX = cell.full ? null : (plan.columnAnchors[cell.column] ?? null);
|
|
4131
|
+
const [ax, ay] = gridAnchorOf(cell.node) ?? [columnX ?? gridX, rowY];
|
|
3731
4132
|
const dx = safeOffset(box.width, cell.size.width, ax);
|
|
3732
4133
|
const dy = safeOffset(box.height, cell.size.height, ay);
|
|
3733
4134
|
const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);
|