@swiftbrowser/web 0.26.2 → 0.26.3
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/app/index.html +9 -3
- package/node/dist/node/apps.d.ts +6 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +5 -0
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/plugins/site-layout.d.ts +2 -0
- package/node/dist/plugins/site-layout.d.ts.map +1 -1
- package/node/dist/plugins/site-layout.js.map +1 -1
- package/package.json +1 -1
- package/plugins/site-layout.ts +2 -0
- package/src/deviceFrame.ts +38 -14
- package/src/layout/engine.ts +787 -175
- package/src/layout/types.ts +3 -0
- package/src/layout/typography.ts +27 -6
- package/src/layoutDom.ts +64 -3
- package/src/main.ts +13 -4
- package/src/protocol.ts +11 -0
- package/src/renderer.ts +22 -5
- package/src/styles.css +752 -0
- package/src/typography.ts +11 -10
- package/src/vite-env.d.ts +2 -0
package/src/layout/engine.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface Context {
|
|
|
85
85
|
imageScale: number;
|
|
86
86
|
/** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
|
|
87
87
|
splitSidebar: boolean;
|
|
88
|
+
/** Inside either column of a `navsplit` (on a Mac the window's unified title bar spans every column). */
|
|
89
|
+
splitColumn?: boolean;
|
|
88
90
|
/** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
|
|
89
91
|
navBarItem?: boolean;
|
|
90
92
|
/** Inside an item of a bottom toolbar (a bordered or prominent button there takes the bar's sizes). */
|
|
@@ -98,6 +100,26 @@ export interface Context {
|
|
|
98
100
|
* the symbols' widths, and with the edit-mode controls.
|
|
99
101
|
*/
|
|
100
102
|
labelColumn?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The Mac idiom, inside a list row's whole subtree: symbols measure the
|
|
105
|
+
* font's point size (not its leading) and a `Label`'s title sits 6 after
|
|
106
|
+
* its icon, as the Mac app draws them.
|
|
107
|
+
*/
|
|
108
|
+
listCell?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* The Mac idiom: a tab view's toolbar row at the top of the window. The
|
|
111
|
+
* screens of a tab's navigation stack draw their bar as that row: no
|
|
112
|
+
* title (the tab picker is there), the back button and the items at the
|
|
113
|
+
* row's ends.
|
|
114
|
+
*/
|
|
115
|
+
tabToolbar?: Rect;
|
|
116
|
+
/**
|
|
117
|
+
* The Mac idiom: the navigation stack that fills a sheet. Its root screen
|
|
118
|
+
* draws the sheet's 40 title bar (the title leading, 13 bold) and puts
|
|
119
|
+
* its text bar items (a cancel or confirm action) in the sheet's bottom
|
|
120
|
+
* bar, `actions`, when the sheet has one.
|
|
121
|
+
*/
|
|
122
|
+
macSheet?: { actions: Rect | null };
|
|
101
123
|
/**
|
|
102
124
|
* Phase 8: the y (screen coordinates) where content clears the bars above
|
|
103
125
|
* it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
|
|
@@ -214,6 +236,12 @@ const LIST_SECTION_GAP = 107 / 3;
|
|
|
214
236
|
// Below a section's footer the gap to the next section is 11 shorter.
|
|
215
237
|
const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
|
|
216
238
|
const LIST_CHEVRON_WIDTH = 20;
|
|
239
|
+
/**
|
|
240
|
+
* A `disclosuregroup` among a list's rows is an outline (`outlineRows`
|
|
241
|
+
* below): each level's rows start this much further in than the level
|
|
242
|
+
* above. SwiftUI's value, as yet unmeasured against the simulator.
|
|
243
|
+
*/
|
|
244
|
+
const LIST_OUTLINE_INDENT = 20;
|
|
217
245
|
// Phase 4.
|
|
218
246
|
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
|
|
219
247
|
// edge (8 when the tab view ends above the screen bottom), its items in equal
|
|
@@ -285,6 +313,126 @@ const NAV_GLASS_PAD_H = 12;
|
|
|
285
313
|
const NAV_GROUP_SPACING = 12;
|
|
286
314
|
const NAV_RUN_GAP = 12;
|
|
287
315
|
const NAV_RUN_PAD = 1;
|
|
316
|
+
// The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
|
|
317
|
+
// `macos`): the window's title bar stands in for the navigation bar. A 52
|
|
318
|
+
// unified title bar holds the back button, the bar items and the title,
|
|
319
|
+
// leading-aligned, as SwiftUI on macOS 14 lays a NavigationStack's toolbar
|
|
320
|
+
// out; a window with nothing in its bar but the title has the plain 28 one,
|
|
321
|
+
// the title centered. There are no large titles and no glass: the items
|
|
322
|
+
// are 28 tall, in slots 8 either side, 8 apart. On the screen's own window
|
|
323
|
+
// (the top of the screen, when the safe area carries a title bar) the three
|
|
324
|
+
// window buttons take the first 80 of the bar. Every number here is a
|
|
325
|
+
// first pass, to be measured against the real app (the Mac fidelity
|
|
326
|
+
// harness, stage 3).
|
|
327
|
+
const MAC_TITLE_BAR_UNIFIED = 52;
|
|
328
|
+
const MAC_TITLE_BAR_PLAIN = 28;
|
|
329
|
+
const MAC_BAR_ITEM_SIZE = 28;
|
|
330
|
+
const MAC_BAR_ITEM_PAD_H = 6;
|
|
331
|
+
const MAC_BAR_RUN_GAP = 10;
|
|
332
|
+
/** The window buttons' room: the first item's slot starts 90 in; a title with no item before it 2 further. */
|
|
333
|
+
const MAC_WINDOW_BUTTONS_ROOM = 90;
|
|
334
|
+
const MAC_TITLE_INSET = 2;
|
|
335
|
+
/** A Mac bar's trailing items end 13 before the window's edge; a tab view's toolbar's 7. */
|
|
336
|
+
const MAC_BAR_MARGIN_TRAILING = 13;
|
|
337
|
+
const MAC_TAB_TOOLBAR_MARGIN_TRAILING = 7;
|
|
338
|
+
// A Mac tab view's bar is a segmented control at the top of the content,
|
|
339
|
+
// centered in a 44 row: 24 tall, each tab a 96 segment inside a 1 inset, at
|
|
340
|
+
// most the width less 20 either side.
|
|
341
|
+
const MAC_TAB_ROW_HEIGHT = 38;
|
|
342
|
+
const MAC_TAB_BAR_HEIGHT = 22;
|
|
343
|
+
/** A tab picker's segment: its title (13 regular) and 12 either side. */
|
|
344
|
+
const MAC_TAB_ITEM_PAD_H = 12;
|
|
345
|
+
const MAC_TAB_ITEM_INSET = 1;
|
|
346
|
+
const MAC_TAB_BAR_MARGIN_H = 20;
|
|
347
|
+
// Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
|
|
348
|
+
// either side of its label, and every `automatic` button outside a list row
|
|
349
|
+
// and a bar is bordered; a checkbox is 14 square, its label 6 after it, and a
|
|
350
|
+
// toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
|
|
351
|
+
// pop-up button is 22 tall (24 in a row), its value in a bordered button
|
|
352
|
+
// with 8 either side.
|
|
353
|
+
const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
354
|
+
regular: { padV: 3, padH: 10, minHeight: 22 },
|
|
355
|
+
mini: { padV: 1, padH: 6, minHeight: 16, textStyle: 'caption' },
|
|
356
|
+
small: { padV: 2, padH: 8, minHeight: 19, textStyle: 'subheadline' },
|
|
357
|
+
large: { padV: 5, padH: 14, minHeight: 28 },
|
|
358
|
+
extraLarge: { padV: 7, padH: 18, minHeight: 34 },
|
|
359
|
+
};
|
|
360
|
+
const MAC_CHECKBOX_SIZE = 14;
|
|
361
|
+
const MAC_CHECKBOX_GAP = 6;
|
|
362
|
+
const MAC_PICKER_MENU_HEIGHT = 22;
|
|
363
|
+
// A Mac's segmented control is 22 tall with 13-point regular text, its
|
|
364
|
+
// label (which iOS drops) 10 before it.
|
|
365
|
+
const MAC_SEGMENTED_HEIGHT = 22;
|
|
366
|
+
const MAC_SEGMENTED_FONT_SIZE = 13;
|
|
367
|
+
const MAC_SEGMENTED_LABEL_GAP = 10;
|
|
368
|
+
const MAC_PICKER_MENU_ROW_HEIGHT = 24;
|
|
369
|
+
const MAC_PICKER_POPUP_PAD = 8;
|
|
370
|
+
// Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
|
|
371
|
+
// rows full width with 16 beside their content, at least 24 tall (28 in a
|
|
372
|
+
// sidebar) with 4 (6) above and below their content, a bordered button's 3;
|
|
373
|
+
// every row has a separator under it. A section's header (11 bold) starts
|
|
374
|
+
// 7 below the top, 5 into the 22 between sections after the first; a rule
|
|
375
|
+
// across the whole list sits 8 under it and the rows 10 under the rule. A
|
|
376
|
+
// footer (11) sits 8 under the last row, with a rule 5 under it. A
|
|
377
|
+
// sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
|
|
378
|
+
const MAC_LIST_ROW_MIN_HEIGHT = 24;
|
|
379
|
+
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 28;
|
|
380
|
+
const MAC_LIST_ROW_VPAD = 4;
|
|
381
|
+
const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
|
|
382
|
+
const MAC_LIST_BUTTON_ROW_VPAD = 3;
|
|
383
|
+
const MAC_LIST_CELL_PADDING = 16;
|
|
384
|
+
/** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
|
|
385
|
+
const MAC_LIST_LABEL_GAP = 6;
|
|
386
|
+
const MAC_LIST_INSET_SIDEBAR = 10;
|
|
387
|
+
const MAC_LIST_TOP = 6;
|
|
388
|
+
const MAC_LIST_BOTTOM = 8;
|
|
389
|
+
const MAC_LIST_SECTION_GAP = 22;
|
|
390
|
+
const MAC_LIST_HEADER_TOP = 4;
|
|
391
|
+
/** Under a Mac header: 8 to its rule and the rule; the first section's rows start 10 further down. */
|
|
392
|
+
const MAC_LIST_HEADER_GAP = 9;
|
|
393
|
+
const MAC_LIST_FIRST_ROWS_GAP = 10;
|
|
394
|
+
const MAC_LIST_FOOTER_GAP = 8;
|
|
395
|
+
/** Under a Mac footer: 5 to its rule, the rule. */
|
|
396
|
+
const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
397
|
+
const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
|
|
398
|
+
// A Mac's search field is in the toolbar, at its trailing end: 22 tall, 180
|
|
399
|
+
// wide (less when the bar is narrow), 16 from the edge. A sheet is a
|
|
400
|
+
// window-modal panel that drops from the title bar, 480 × 400 at most,
|
|
401
|
+
// centered, with no grabber.
|
|
402
|
+
const MAC_SEARCH_FIELD_WIDTH = 180;
|
|
403
|
+
const MAC_SEARCH_FIELD_HEIGHT = 22;
|
|
404
|
+
// A Mac's sheet (measured in the Mac app): a 470 panel centered in the
|
|
405
|
+
// window, its 40 title bar (the title 13 bold at 16) over the content at its
|
|
406
|
+
// ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
407
|
+
// confirm) as 22 push buttons at its trailing end, 16 in and 8 apart; at
|
|
408
|
+
// least 40 clear of the window's edges.
|
|
409
|
+
const MAC_SHEET_WIDTH = 470;
|
|
410
|
+
const MAC_SHEET_TITLE_BAR = 40;
|
|
411
|
+
const MAC_SHEET_ACTIONS_BAR = 60;
|
|
412
|
+
const MAC_SHEET_ACTIONS_INSET = 16;
|
|
413
|
+
const MAC_SHEET_ACTIONS_GAP = 8;
|
|
414
|
+
const MAC_SHEET_MARGIN = 40;
|
|
415
|
+
// A Mac's text field is 22 tall whatever its style; a stepper's control is
|
|
416
|
+
// the 13 × 22 pair of arrows (up over down) 4 after the label; an alert is
|
|
417
|
+
// a 260 panel, its title and message centered 16 in, its buttons 22-tall
|
|
418
|
+
// push buttons 16 in, side by side 12 apart for two, stacked 8 apart otherwise.
|
|
419
|
+
const MAC_TEXTFIELD_HEIGHT = 22;
|
|
420
|
+
const MAC_STEPPER_WIDTH = 13;
|
|
421
|
+
const MAC_STEPPER_HEIGHT = 22;
|
|
422
|
+
const MAC_STEPPER_GAP = 4;
|
|
423
|
+
// A Mac's slider is a 4 track with a 20 round knob in a 20 row; a compact
|
|
424
|
+
// date picker's date and time are 22-tall bordered fields 6 apart with 6
|
|
425
|
+
// beside their text; a menu outside a bar or a list row is a pull-down
|
|
426
|
+
// button, 22 tall with 10 beside its label and the chevron's 16 after it.
|
|
427
|
+
const MAC_SLIDER_HEIGHT = 20;
|
|
428
|
+
const MAC_SLIDER_TRACK_HEIGHT = 4;
|
|
429
|
+
const MAC_SLIDER_THUMB = 20;
|
|
430
|
+
const MAC_DATEPICKER_HEIGHT = 22;
|
|
431
|
+
const MAC_DATEPICKER_CAPSULE_PAD_H = 6;
|
|
432
|
+
const MAC_DATEPICKER_CAPSULE_GAP = 6;
|
|
433
|
+
const MAC_PULLDOWN_HEIGHT = 22;
|
|
434
|
+
const MAC_PULLDOWN_PAD_H = 10;
|
|
435
|
+
const MAC_PULLDOWN_CHEVRON = 16;
|
|
288
436
|
|
|
289
437
|
/**
|
|
290
438
|
* The children an item draws in the bar: a menu or context menu draws its
|
|
@@ -309,11 +457,11 @@ function isSymbolItem(item: LayoutNode): boolean {
|
|
|
309
457
|
return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
|
|
310
458
|
}
|
|
311
459
|
|
|
312
|
-
/** A bar item's slot in its glass capsule. */
|
|
313
|
-
function navSlot(item: LayoutNode, width: number): number {
|
|
460
|
+
/** A bar item's slot in its glass capsule (`glass` square for a symbol, else its width and `pad` either side, at least `glass`). */
|
|
461
|
+
function navSlot(item: LayoutNode, width: number, glass = NAV_GLASS_SIZE, pad = NAV_GLASS_PAD_H): number {
|
|
314
462
|
if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
|
|
315
|
-
if (isSymbolItem(item) && width <=
|
|
316
|
-
return Math.max(
|
|
463
|
+
if (isSymbolItem(item) && width <= glass) return glass;
|
|
464
|
+
return Math.max(glass, width + 2 * pad);
|
|
317
465
|
}
|
|
318
466
|
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
319
467
|
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
@@ -457,7 +605,8 @@ const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
|
|
|
457
605
|
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
458
606
|
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
459
607
|
* in from the card's sides, side by side 8 apart for two, stacked 8 apart
|
|
460
|
-
* otherwise.
|
|
608
|
+
* otherwise. `ALERT_METRICS` gathers them for `placeAlert`; `MAC_ALERT_METRICS`
|
|
609
|
+
* is the Mac's panel (MAC_* above).
|
|
461
610
|
*/
|
|
462
611
|
const ALERT_WIDTH = 320;
|
|
463
612
|
const ALERT_PAD_TOP = 21;
|
|
@@ -473,6 +622,58 @@ const ALERT_BUTTON_HEIGHT = 46;
|
|
|
473
622
|
const ALERT_FIELD_HEIGHT = 36;
|
|
474
623
|
const ALERT_FIELD_SPACING = 8;
|
|
475
624
|
const ALERT_FIELDS_GAP = 16;
|
|
625
|
+
interface AlertMetrics {
|
|
626
|
+
width: number;
|
|
627
|
+
padTop: number;
|
|
628
|
+
padH: number;
|
|
629
|
+
buttonInset: number;
|
|
630
|
+
buttonsGap: number;
|
|
631
|
+
padBottom: number;
|
|
632
|
+
buttonSpacing: number;
|
|
633
|
+
rowSpacing: number;
|
|
634
|
+
titleGap: number;
|
|
635
|
+
buttonHeight: number;
|
|
636
|
+
fieldHeight: number;
|
|
637
|
+
fieldSpacing: number;
|
|
638
|
+
fieldsGap: number;
|
|
639
|
+
textAlignment: 'leading' | 'center';
|
|
640
|
+
}
|
|
641
|
+
const ALERT_METRICS: AlertMetrics = {
|
|
642
|
+
width: ALERT_WIDTH,
|
|
643
|
+
padTop: ALERT_PAD_TOP,
|
|
644
|
+
padH: ALERT_PAD_H,
|
|
645
|
+
buttonInset: ALERT_BUTTON_INSET,
|
|
646
|
+
buttonsGap: ALERT_BUTTONS_GAP,
|
|
647
|
+
padBottom: ALERT_PAD_BOTTOM,
|
|
648
|
+
buttonSpacing: ALERT_BUTTON_SPACING,
|
|
649
|
+
rowSpacing: ALERT_BUTTON_SPACING,
|
|
650
|
+
titleGap: ALERT_TITLE_GAP,
|
|
651
|
+
buttonHeight: ALERT_BUTTON_HEIGHT,
|
|
652
|
+
fieldHeight: ALERT_FIELD_HEIGHT,
|
|
653
|
+
fieldSpacing: ALERT_FIELD_SPACING,
|
|
654
|
+
fieldsGap: ALERT_FIELDS_GAP,
|
|
655
|
+
textAlignment: 'leading',
|
|
656
|
+
};
|
|
657
|
+
// Measured in the Mac app: the app icon (54, which the page draws as a
|
|
658
|
+
// placeholder) 25 under the top and 25 over the title, the message 9 under
|
|
659
|
+
// it, 28-tall buttons 17 under the message and 16 over the bottom, two
|
|
660
|
+
// side by side 8 apart.
|
|
661
|
+
const MAC_ALERT_METRICS: AlertMetrics = {
|
|
662
|
+
width: 260,
|
|
663
|
+
padTop: 104,
|
|
664
|
+
padH: 16,
|
|
665
|
+
buttonInset: 16,
|
|
666
|
+
buttonsGap: 17,
|
|
667
|
+
padBottom: 16,
|
|
668
|
+
buttonSpacing: 8,
|
|
669
|
+
rowSpacing: 8,
|
|
670
|
+
titleGap: 9,
|
|
671
|
+
buttonHeight: 28,
|
|
672
|
+
fieldHeight: 22,
|
|
673
|
+
fieldSpacing: 8,
|
|
674
|
+
fieldsGap: 12,
|
|
675
|
+
textAlignment: 'center',
|
|
676
|
+
};
|
|
476
677
|
/** Gap between an action sheet's title and its message. */
|
|
477
678
|
const DIALOG_TITLE_GAP = 4;
|
|
478
679
|
/** An action sheet's button labels are inset this much from its rows. */
|
|
@@ -742,7 +943,8 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
742
943
|
}
|
|
743
944
|
|
|
744
945
|
/** The default vertical padding above and below a list row's content. */
|
|
745
|
-
function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
946
|
+
function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
|
|
947
|
+
if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
|
|
746
948
|
switch (row.kind) {
|
|
747
949
|
case 'picker':
|
|
748
950
|
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
@@ -923,6 +1125,34 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
|
|
|
923
1125
|
return null;
|
|
924
1126
|
}
|
|
925
1127
|
|
|
1128
|
+
/** A list's row as `outlineRows` names it: the view, how deep it sits, and the `disclosuregroup` it is the label of. */
|
|
1129
|
+
interface RowSource {
|
|
1130
|
+
node: LayoutNode;
|
|
1131
|
+
depth: number;
|
|
1132
|
+
group: LayoutNode | null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The rows a section's children make. A `disclosuregroup` among them is an
|
|
1137
|
+
* outline, as SwiftUI lays one out in a `List`: its label (its first child)
|
|
1138
|
+
* is a row, with the group's chevron at the trailing edge, and while it is
|
|
1139
|
+
* expanded each of its content children is a row under it, a level deeper
|
|
1140
|
+
* (`LIST_OUTLINE_INDENT` further in), a nested group among them opening the
|
|
1141
|
+
* same way. Collapsed, its content is not laid out.
|
|
1142
|
+
*/
|
|
1143
|
+
function outlineRows(nodes: readonly LayoutNode[], depth: number, out: RowSource[]): RowSource[] {
|
|
1144
|
+
for (const row of nodes) {
|
|
1145
|
+
if (row.kind === 'disclosuregroup') {
|
|
1146
|
+
const [label, ...content] = row.children;
|
|
1147
|
+
if (label) out.push({ node: label, depth, group: row });
|
|
1148
|
+
if (row.props['expanded'] === true) outlineRows(content, depth + 1, out);
|
|
1149
|
+
} else {
|
|
1150
|
+
out.push({ node: row, depth, group: null });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
926
1156
|
/** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
|
|
927
1157
|
const LIST_LABEL_ICON_CENTER = 12;
|
|
928
1158
|
const LIST_LABEL_TITLE_X = 40;
|
|
@@ -1189,6 +1419,12 @@ interface ListRowPlan {
|
|
|
1189
1419
|
edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
|
|
1190
1420
|
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
1191
1421
|
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
1422
|
+
/**
|
|
1423
|
+
* An outline's row (`outlineRows`): how deep it sits, and the
|
|
1424
|
+
* `disclosuregroup` it is the label of, when it is one (the group's
|
|
1425
|
+
* chevron and toggle go over this row).
|
|
1426
|
+
*/
|
|
1427
|
+
outline?: { depth: number; group: LayoutNode | null } | null;
|
|
1192
1428
|
}
|
|
1193
1429
|
|
|
1194
1430
|
/** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
|
|
@@ -1209,6 +1445,8 @@ interface ListGroupPlan {
|
|
|
1209
1445
|
footerView: ListSupplementPlan | null;
|
|
1210
1446
|
card: Rect;
|
|
1211
1447
|
rows: ListRowPlan[];
|
|
1448
|
+
/** Each `disclosuregroup` among the rows, with the rect its rows span (its label's, then its content's while expanded). */
|
|
1449
|
+
outlines: { group: LayoutNode; frame: Rect }[];
|
|
1212
1450
|
}
|
|
1213
1451
|
|
|
1214
1452
|
interface ListPlan {
|
|
@@ -1416,7 +1654,7 @@ export class LayoutPass {
|
|
|
1416
1654
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1417
1655
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1418
1656
|
} else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
|
|
1419
|
-
const rect = this.formSheetRect();
|
|
1657
|
+
const rect = this.formSheetRect(root, ctx);
|
|
1420
1658
|
this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
|
|
1421
1659
|
} else if (root.kind === 'sheet') {
|
|
1422
1660
|
const floats = this.sheetFloats(root);
|
|
@@ -1779,7 +2017,8 @@ export class LayoutPass {
|
|
|
1779
2017
|
const style = str(node.props['style']);
|
|
1780
2018
|
return {
|
|
1781
2019
|
width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
|
|
1782
|
-
|
|
2020
|
+
// A Mac's field is 22 tall in every style.
|
|
2021
|
+
height: this.env.platform === 'macos' ? MAC_TEXTFIELD_HEIGHT : style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
|
|
1783
2022
|
};
|
|
1784
2023
|
}
|
|
1785
2024
|
case 'shape':
|
|
@@ -1824,7 +2063,10 @@ export class LayoutPass {
|
|
|
1824
2063
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1825
2064
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1826
2065
|
case 'sheet':
|
|
1827
|
-
if (this.sheetIsForm(node))
|
|
2066
|
+
if (this.sheetIsForm(node)) {
|
|
2067
|
+
const rect = this.formSheetRect(node, ctx);
|
|
2068
|
+
return { width: p.width ?? rect.width, height: rect.height };
|
|
2069
|
+
}
|
|
1828
2070
|
return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
|
|
1829
2071
|
// Phase 8 controls.
|
|
1830
2072
|
case 'alert':
|
|
@@ -1859,12 +2101,19 @@ export class LayoutPass {
|
|
|
1859
2101
|
}
|
|
1860
2102
|
case 'menu': {
|
|
1861
2103
|
// Phase 6: the label (the first child) is the only laid-out child.
|
|
2104
|
+
// The Mac idiom: outside a bar and a list row the menu is a pull-down
|
|
2105
|
+
// button around its label.
|
|
1862
2106
|
const label = node.children[0];
|
|
2107
|
+
if (this.menuIsPullDown(ctx)) {
|
|
2108
|
+
const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
|
|
2109
|
+
const labelSize = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
|
|
2110
|
+
return { width: labelSize.width + 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON, height: Math.max(MAC_PULLDOWN_HEIGHT, labelSize.height) };
|
|
2111
|
+
}
|
|
1863
2112
|
return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1864
2113
|
}
|
|
1865
2114
|
case 'searchfield':
|
|
1866
|
-
// Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field.
|
|
1867
|
-
return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: SEARCH_FIELD_HEIGHT };
|
|
2115
|
+
// Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field (22 on a Mac).
|
|
2116
|
+
return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: this.env.platform === 'macos' ? MAC_SEARCH_FIELD_HEIGHT : SEARCH_FIELD_HEIGHT };
|
|
1868
2117
|
case 'swipeactions':
|
|
1869
2118
|
case 'contextmenu': {
|
|
1870
2119
|
// Phase 8: the content (the first child) alone; the actions / items
|
|
@@ -2051,14 +2300,17 @@ export class LayoutPass {
|
|
|
2051
2300
|
rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
|
|
2052
2301
|
}
|
|
2053
2302
|
|
|
2054
|
-
private buttonIsBordered(node: LayoutNode): boolean {
|
|
2303
|
+
private buttonIsBordered(node: LayoutNode, ctx: Context): boolean {
|
|
2055
2304
|
const style = str(node.props['style']);
|
|
2056
|
-
|
|
2305
|
+
if (style === 'bordered' || style === 'borderedProminent' || style === 'glass') return true;
|
|
2306
|
+
// The Mac idiom: a button of the automatic style is a push button too
|
|
2307
|
+
// (in a list row as well), except as a bar item's content.
|
|
2308
|
+
return this.env.platform === 'macos' && (style === null || style === 'automatic') && !ctx.navBarItem && !ctx.bottomBarItem;
|
|
2057
2309
|
}
|
|
2058
2310
|
|
|
2059
2311
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
2060
2312
|
const label = this.contentOf(node);
|
|
2061
|
-
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
2313
|
+
if (!this.buttonIsBordered(node, ctx)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
2062
2314
|
const cctx = this.borderedLabelContext(node, ctx);
|
|
2063
2315
|
if (ctx.bottomBarItem) {
|
|
2064
2316
|
// In a bottom bar (measured in the iOS 26.2 simulator, Native
|
|
@@ -2081,7 +2333,9 @@ export class LayoutPass {
|
|
|
2081
2333
|
}
|
|
2082
2334
|
|
|
2083
2335
|
private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
|
|
2084
|
-
|
|
2336
|
+
const controlSize = str(node.props['controlSize']) ?? 'regular';
|
|
2337
|
+
if (this.env.platform === 'macos') return MAC_BORDERED_SIZES[controlSize] ?? (MAC_BORDERED_SIZES['regular'] as { padV: number; padH: number; minHeight: number });
|
|
2338
|
+
return BORDERED_SIZES[controlSize] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
|
|
2085
2339
|
}
|
|
2086
2340
|
|
|
2087
2341
|
/** A bordered button's label context: a small or mini one sets a smaller text style. */
|
|
@@ -2091,8 +2345,20 @@ export class LayoutPass {
|
|
|
2091
2345
|
return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
|
|
2092
2346
|
}
|
|
2093
2347
|
|
|
2094
|
-
|
|
2095
|
-
|
|
2348
|
+
/**
|
|
2349
|
+
* A toggle's control: iOS's switch at the trailing edge; on a Mac a
|
|
2350
|
+
* checkbox before the label, or the small switch in a list row (a grouped
|
|
2351
|
+
* form's), at the trailing edge.
|
|
2352
|
+
*/
|
|
2353
|
+
private switchMetrics(_ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
|
|
2354
|
+
if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
|
|
2355
|
+
// A Mac's toggle is a checkbox before its label, in a list row too.
|
|
2356
|
+
return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
private toggleLabelProposal(ctx: Context, p: Proposal): Proposal {
|
|
2360
|
+
const control = this.switchMetrics(ctx);
|
|
2361
|
+
return { width: shrink(p.width, control.width + control.gap), height: p.height };
|
|
2096
2362
|
}
|
|
2097
2363
|
|
|
2098
2364
|
private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
@@ -2107,21 +2373,24 @@ export class LayoutPass {
|
|
|
2107
2373
|
return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
|
|
2108
2374
|
}
|
|
2109
2375
|
// sized as a bordered button, on or off
|
|
2110
|
-
const
|
|
2376
|
+
const m = this.borderedMetrics(node);
|
|
2377
|
+
const inner: Proposal = { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
|
|
2111
2378
|
const size = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
|
|
2112
|
-
return { width: size.width + 2 *
|
|
2379
|
+
return { width: size.width + 2 * m.padH, height: Math.max(m.minHeight, size.height + 2 * m.padV) };
|
|
2113
2380
|
}
|
|
2381
|
+
const control = this.switchMetrics(ctx);
|
|
2114
2382
|
const labelSize = label
|
|
2115
|
-
? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
|
|
2383
|
+
? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(ctx, p))
|
|
2116
2384
|
: ZERO;
|
|
2117
2385
|
return {
|
|
2118
|
-
width: finite(p.width) ? p.width : labelSize.width +
|
|
2119
|
-
height: Math.max(labelSize.height,
|
|
2386
|
+
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
2387
|
+
height: Math.max(labelSize.height, control.height),
|
|
2120
2388
|
};
|
|
2121
2389
|
}
|
|
2122
2390
|
|
|
2123
2391
|
private navLinkLabelProposal(ctx: Context, p: Proposal): Proposal {
|
|
2124
|
-
|
|
2392
|
+
// A Mac's rows have no chevron.
|
|
2393
|
+
return ctx.listRow && this.env.platform !== 'macos' ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
|
|
2125
2394
|
}
|
|
2126
2395
|
|
|
2127
2396
|
private measureNavLink(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
@@ -2155,14 +2424,23 @@ export class LayoutPass {
|
|
|
2155
2424
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
2156
2425
|
}
|
|
2157
2426
|
|
|
2158
|
-
/** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
|
|
2427
|
+
/** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet; on a Mac a window-modal panel. */
|
|
2159
2428
|
private sheetIsForm(node: LayoutNode): boolean {
|
|
2160
|
-
return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
|
|
2429
|
+
return (this.env.screen.width >= REGULAR_WIDTH || this.env.platform === 'macos') && !this.sheetIsFull(node);
|
|
2161
2430
|
}
|
|
2162
2431
|
|
|
2163
|
-
/**
|
|
2164
|
-
|
|
2432
|
+
/**
|
|
2433
|
+
* The form sheet's card: UIKit's size, clamped to the safe area less a
|
|
2434
|
+
* margin, centered in it. On a Mac the panel is 470 wide, as tall as its
|
|
2435
|
+
* bars and its content's ideal height, centered in the window.
|
|
2436
|
+
*/
|
|
2437
|
+
private formSheetRect(node: LayoutNode, ctx: Context): Rect {
|
|
2165
2438
|
const { screen, safeArea } = this.env;
|
|
2439
|
+
if (this.env.platform === 'macos') {
|
|
2440
|
+
const width = Math.max(0, Math.min(MAC_SHEET_WIDTH, screen.width - 2 * MAC_SHEET_MARGIN));
|
|
2441
|
+
const height = Math.max(0, Math.min(this.macSheetHeight(node, ctx, width), screen.height - 2 * MAC_SHEET_MARGIN));
|
|
2442
|
+
return { x: Math.round((screen.width - width) / 2), y: Math.round((screen.height - height) / 2), width, height };
|
|
2443
|
+
}
|
|
2166
2444
|
const top = safeArea.top + FORM_SHEET_MARGIN;
|
|
2167
2445
|
const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
|
|
2168
2446
|
const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
|
|
@@ -2170,6 +2448,34 @@ export class LayoutPass {
|
|
|
2170
2448
|
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
2171
2449
|
}
|
|
2172
2450
|
|
|
2451
|
+
/** The toolbars of a Mac sheet's root screen that go in its bottom bar: those whose items are all text buttons (a cancel or confirm action). */
|
|
2452
|
+
private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
|
|
2453
|
+
return this.navScreenParts(screen).toolbars.filter((toolbar) => {
|
|
2454
|
+
const placement = str(toolbar.props['placement']);
|
|
2455
|
+
if ((placement !== 'leading' && placement !== 'trailing') || str(toolbar.props['spacer']) !== null) return false;
|
|
2456
|
+
return toolbar.children.length > 0 && toolbar.children.every((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button');
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/** A Mac sheet's root screen, when its content is a navigation stack. */
|
|
2461
|
+
private macSheetScreen(node: LayoutNode): LayoutNode | null {
|
|
2462
|
+
const child = this.contentOf(node);
|
|
2463
|
+
if (!child) return null;
|
|
2464
|
+
const stack = unwrapStyled(child);
|
|
2465
|
+
if (stack.kind !== 'navstack') return null;
|
|
2466
|
+
return stack.children[stack.children.length - 1] ?? null;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/** A Mac sheet's height: its title bar, its content's ideal height at the sheet's width, and its actions bar if it has one. */
|
|
2470
|
+
private macSheetHeight(node: LayoutNode, ctx: Context, width: number): number {
|
|
2471
|
+
const screen = this.macSheetScreen(node);
|
|
2472
|
+
const child = this.contentOf(node);
|
|
2473
|
+
if (!screen) return child ? this.measure(child, this.childContext(node, ctx), { width, height: null }).height : 0;
|
|
2474
|
+
const content = this.navScreenParts(screen).content;
|
|
2475
|
+
const ideal = content ? this.measure(content, this.childContext(screen, this.childContext(node, ctx)), { width, height: null }).height : 0;
|
|
2476
|
+
return MAC_SHEET_TITLE_BAR + ideal + (this.macSheetActionToolbars(screen).length > 0 ? MAC_SHEET_ACTIONS_BAR : 0);
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2173
2479
|
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
2174
2480
|
private sheetFloats(node: LayoutNode): boolean {
|
|
2175
2481
|
if (this.sheetIsForm(node)) return false;
|
|
@@ -2239,16 +2545,20 @@ export class LayoutPass {
|
|
|
2239
2545
|
};
|
|
2240
2546
|
}
|
|
2241
2547
|
if (style === 'segmented') {
|
|
2242
|
-
const
|
|
2548
|
+
const mac = this.env.platform === 'macos';
|
|
2549
|
+
const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2243
2550
|
let natural = 0;
|
|
2244
2551
|
for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
|
|
2552
|
+
// A Mac shows the picker's label before the control.
|
|
2553
|
+
const labelText = mac ? (str(node.props['label']) ?? '') : '';
|
|
2554
|
+
const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
|
|
2245
2555
|
return {
|
|
2246
|
-
size: { width: finite(p.width) ? p.width : natural, height: PICKER_SEGMENTED_HEIGHT },
|
|
2556
|
+
size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
|
|
2247
2557
|
style: 'segmented',
|
|
2248
2558
|
font,
|
|
2249
2559
|
options,
|
|
2250
2560
|
selected,
|
|
2251
|
-
labelWidth
|
|
2561
|
+
labelWidth,
|
|
2252
2562
|
valueWidth: 0,
|
|
2253
2563
|
labelRow: false,
|
|
2254
2564
|
};
|
|
@@ -2256,12 +2566,15 @@ export class LayoutPass {
|
|
|
2256
2566
|
const font = ctx.font;
|
|
2257
2567
|
const labelWidth = this.measurer.measureWidth(str(node.props['label']) ?? '', font);
|
|
2258
2568
|
const value = options[selected] ?? '';
|
|
2259
|
-
const
|
|
2569
|
+
const mac = this.env.platform === 'macos';
|
|
2570
|
+
// The Mac's pop-up button holds the value and the chevrons in a bordered button.
|
|
2571
|
+
const popup = mac && !ctx.listRow;
|
|
2572
|
+
const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
|
|
2260
2573
|
const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
|
|
2261
2574
|
return {
|
|
2262
2575
|
size: {
|
|
2263
2576
|
width: finite(p.width) ? p.width : natural,
|
|
2264
|
-
height: ctx.listRow ? PICKER_MENU_ROW_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2577
|
+
height: ctx.listRow ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2265
2578
|
},
|
|
2266
2579
|
style: 'menu',
|
|
2267
2580
|
font,
|
|
@@ -2286,9 +2599,19 @@ export class LayoutPass {
|
|
|
2286
2599
|
const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
|
|
2287
2600
|
if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
|
|
2288
2601
|
else delete chrome['bar'];
|
|
2289
|
-
|
|
2602
|
+
// The Mac's label before the control (`label`, `track` the control's own frame).
|
|
2603
|
+
const labelWidth = Math.min(plan.labelWidth, size.width);
|
|
2604
|
+
if (labelWidth > 0) {
|
|
2605
|
+
chrome['label'] = { x, y, width: labelWidth - MAC_SEGMENTED_LABEL_GAP, height: size.height };
|
|
2606
|
+
chrome['track'] = { x: x + labelWidth, y, width: Math.max(0, size.width - labelWidth), height: size.height };
|
|
2607
|
+
} else {
|
|
2608
|
+
delete chrome['label'];
|
|
2609
|
+
delete chrome['track'];
|
|
2610
|
+
}
|
|
2611
|
+
const trackX = x + labelWidth;
|
|
2612
|
+
const segmentWidth = Math.max(0, size.width - labelWidth - 2 * inset) / n;
|
|
2290
2613
|
for (let i = 0; i < plan.options.length; i++) {
|
|
2291
|
-
chrome[`segment${i}`] = { x:
|
|
2614
|
+
chrome[`segment${i}`] = { x: trackX + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
|
|
2292
2615
|
}
|
|
2293
2616
|
} else if (plan.style === 'inline') {
|
|
2294
2617
|
// Rows span the list row when there is one (the hit area and the
|
|
@@ -2324,6 +2647,9 @@ export class LayoutPass {
|
|
|
2324
2647
|
valueWidth = Math.min(valueWidth, size.width);
|
|
2325
2648
|
chrome['label'] = { x, y, width: labelWidth, height: size.height };
|
|
2326
2649
|
chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
|
|
2650
|
+
// The page draws the Mac's pop-up button around the value (the engine padded it).
|
|
2651
|
+
if (this.env.platform === 'macos' && !ctx.listRow) chrome['popup'] = chrome['value'];
|
|
2652
|
+
else delete chrome['popup'];
|
|
2327
2653
|
}
|
|
2328
2654
|
this.setChrome(node, chrome);
|
|
2329
2655
|
if (node.id >= 0) this.fonts.set(node.id, plan.font);
|
|
@@ -2365,6 +2691,8 @@ export class LayoutPass {
|
|
|
2365
2691
|
if (n < 2) return [];
|
|
2366
2692
|
// A disclosure group's content sits 8 under its label, as SwiftUI's does.
|
|
2367
2693
|
const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
|
|
2694
|
+
// A Mac list row's Label (no icon column there): the title 6 after the icon.
|
|
2695
|
+
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
|
|
2368
2696
|
if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
|
|
2369
2697
|
const gaps: number[] = [];
|
|
2370
2698
|
for (let i = 0; i < n - 1; i++) {
|
|
@@ -2629,10 +2957,13 @@ export class LayoutPass {
|
|
|
2629
2957
|
return { width: p.width ?? width, height: p.height ?? height };
|
|
2630
2958
|
}
|
|
2631
2959
|
const name = str(node.props['name']);
|
|
2632
|
-
|
|
2960
|
+
// A symbol takes a square of the font's leading; in a Mac list row, of
|
|
2961
|
+
// its point size (13 in a 13-point row, measured in the Mac app).
|
|
2962
|
+
const symbolSide = (ctx.listCell === true ? ctx.font.size : ctx.font.leading) * ctx.imageScale;
|
|
2963
|
+
if (name === null) return square(symbolSide);
|
|
2633
2964
|
const asset = this.env.assets?.images[name];
|
|
2634
2965
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
2635
|
-
if (asset.kind === 'symbol') return square(
|
|
2966
|
+
if (asset.kind === 'symbol') return square(symbolSide);
|
|
2636
2967
|
const width = num(asset.width) ?? 0;
|
|
2637
2968
|
const height = num(asset.height) ?? 0;
|
|
2638
2969
|
if (node.props['resizable'] !== true) return { width, height };
|
|
@@ -2842,28 +3173,43 @@ export class LayoutPass {
|
|
|
2842
3173
|
// (cards, chevrons, 52pt rows) and only its sections' folding remains.
|
|
2843
3174
|
const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
|
|
2844
3175
|
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
|
|
2845
|
-
|
|
2846
|
-
|
|
3176
|
+
// The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
|
|
3177
|
+
// 11-point bold headers, a sidebar's rows inset 10 and a grouped
|
|
3178
|
+
// form's cards 20.
|
|
3179
|
+
const mac = this.env.platform === 'macos';
|
|
3180
|
+
const minRowHeight =
|
|
3181
|
+
num(node.props['minRowHeight']) ??
|
|
3182
|
+
(sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
3183
|
+
// A Mac's lists have no cards: the rows run the whole width (a
|
|
3184
|
+
// sidebar's inset 10).
|
|
3185
|
+
const inset = mac
|
|
3186
|
+
? listStyle === 'sidebar'
|
|
3187
|
+
? MAC_LIST_INSET_SIDEBAR
|
|
3188
|
+
: 0
|
|
3189
|
+
: listStyle === 'insetGrouped' || listStyle === 'sidebar'
|
|
3190
|
+
? LIST_INSET
|
|
3191
|
+
: 0;
|
|
2847
3192
|
const plain = listStyle === 'plain';
|
|
2848
|
-
const
|
|
2849
|
-
const
|
|
2850
|
-
const
|
|
3193
|
+
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
3194
|
+
const listTop = plain ? 0 : mac ? MAC_LIST_TOP : LIST_TOP;
|
|
3195
|
+
const listBottom = plain ? 0 : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
|
|
3196
|
+
const sectionGap = plain ? 0 : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
|
|
3197
|
+
const headerGap = mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
|
|
2851
3198
|
const cardX = inset;
|
|
2852
3199
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
2853
|
-
const contentX = inset +
|
|
2854
|
-
const contentWidth = Math.max(0, layoutWidth - 2 * (inset +
|
|
3200
|
+
const contentX = inset + cellPadding;
|
|
3201
|
+
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
|
|
2855
3202
|
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
2856
|
-
const headerFont =
|
|
2857
|
-
'system',
|
|
2858
|
-
textStyleSize('headline', this.env.dynamicTypeSize),
|
|
2859
|
-
600,
|
|
2860
|
-
);
|
|
3203
|
+
const headerFont = mac
|
|
3204
|
+
? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
|
|
3205
|
+
: makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
|
|
2861
3206
|
const footerFont = makeFont(
|
|
2862
3207
|
'system',
|
|
2863
|
-
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
3208
|
+
textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform),
|
|
2864
3209
|
400,
|
|
2865
3210
|
);
|
|
2866
|
-
|
|
3211
|
+
// A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
|
|
3212
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: !mac, listCell: mac, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2867
3213
|
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
2868
3214
|
// leading control (6 in from the card's edge) and its content shifts
|
|
2869
3215
|
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
@@ -2871,20 +3217,20 @@ export class LayoutPass {
|
|
|
2871
3217
|
|
|
2872
3218
|
// Group consecutive plain rows into an implicit section. A section's
|
|
2873
3219
|
// view header and footer are not rows.
|
|
2874
|
-
type GroupSource = { section: LayoutNode | null; rows:
|
|
3220
|
+
type GroupSource = { section: LayoutNode | null; rows: RowSource[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
2875
3221
|
const groupsSource: GroupSource[] = [];
|
|
2876
3222
|
for (const child of node.children) {
|
|
2877
3223
|
if (child.kind === 'section') {
|
|
2878
3224
|
// `Section(isExpanded:)`: collapsed, its rows are not laid out, in a sidebar-style list (iOS shows the rows in the other styles, whatever the binding).
|
|
2879
3225
|
const collapsed = child.props['collapsible'] === true && child.props['expanded'] === false && rowCtx.sidebarList === true;
|
|
2880
|
-
const rows = collapsed ? [] : child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
|
|
3226
|
+
const rows = collapsed ? [] : outlineRows(child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter'), 0, []);
|
|
2881
3227
|
const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
|
|
2882
3228
|
const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
|
|
2883
3229
|
groupsSource.push({ section: child, rows, headerView, footerView });
|
|
2884
3230
|
} else {
|
|
2885
3231
|
const last = groupsSource[groupsSource.length - 1];
|
|
2886
|
-
if (last && last.section === null) last.rows
|
|
2887
|
-
else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
|
|
3232
|
+
if (last && last.section === null) outlineRows([child], 0, last.rows);
|
|
3233
|
+
else groupsSource.push({ section: null, rows: outlineRows([child], 0, []), headerView: null, footerView: null });
|
|
2888
3234
|
}
|
|
2889
3235
|
}
|
|
2890
3236
|
// View headers and footers: the content width, the header's and the
|
|
@@ -2913,27 +3259,29 @@ export class LayoutPass {
|
|
|
2913
3259
|
const previous = groups[g - 1];
|
|
2914
3260
|
if (previous) {
|
|
2915
3261
|
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
2916
|
-
y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
3262
|
+
y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
2917
3263
|
}
|
|
2918
3264
|
const top = y;
|
|
2919
3265
|
let header: ListGroupPlan['header'] = null;
|
|
2920
3266
|
let footer: ListGroupPlan['footer'] = null;
|
|
2921
3267
|
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2922
3268
|
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2923
|
-
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
3269
|
+
const headerLift = plain || mac ? 0 : LIST_HEADER_LIFT;
|
|
2924
3270
|
const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
|
|
2925
|
-
if (headerView && headerView.rect.height > 0) y += headerView.rect.height +
|
|
3271
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + headerGap - headerLift;
|
|
2926
3272
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
2927
3273
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
2928
3274
|
if (headerText !== null && headerText.length > 0) {
|
|
2929
3275
|
const { layout, size } = makeText(headerText, headerFont);
|
|
3276
|
+
if (mac && previous) y += MAC_LIST_HEADER_TOP;
|
|
2930
3277
|
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
2931
3278
|
header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
2932
|
-
y = headerY + size.height +
|
|
3279
|
+
y = headerY + size.height + headerGap + (mac && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
|
|
2933
3280
|
}
|
|
2934
3281
|
const cardTop = y;
|
|
2935
3282
|
const rows: ListRowPlan[] = [];
|
|
2936
|
-
for (const
|
|
3283
|
+
for (const rowSource of source.rows) {
|
|
3284
|
+
const rowNode = rowSource.node;
|
|
2937
3285
|
if (rowNode.kind === 'lazyspacer') {
|
|
2938
3286
|
// Lazy rows not built yet: a row of no height, without a cell.
|
|
2939
3287
|
const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
|
|
@@ -2946,13 +3294,19 @@ export class LayoutPass {
|
|
|
2946
3294
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2947
3295
|
// width (plus a gap) at the trailing edge.
|
|
2948
3296
|
const hints = rowHintsOf(rowNode);
|
|
2949
|
-
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
3297
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
|
|
3298
|
+
// An outline's rows start LIST_OUTLINE_INDENT further in per level.
|
|
3299
|
+
const indent = rowSource.depth * LIST_OUTLINE_INDENT;
|
|
3300
|
+
const insets: EdgeInsets = hints.insets
|
|
3301
|
+
? { ...hints.insets, leading: hints.insets.leading + indent }
|
|
3302
|
+
: {
|
|
3303
|
+
top: defaultVPad,
|
|
3304
|
+
leading: cellPadding + indent,
|
|
3305
|
+
bottom: defaultVPad,
|
|
3306
|
+
trailing: cellPadding,
|
|
3307
|
+
};
|
|
3308
|
+
// A group's label row keeps the chevron's width at its trailing edge, as a link's does (a Mac's rows have no chevron).
|
|
3309
|
+
const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
|
|
2956
3310
|
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
|
|
2957
3311
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
2958
3312
|
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
@@ -2964,7 +3318,7 @@ export class LayoutPass {
|
|
|
2964
3318
|
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2965
3319
|
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2966
3320
|
const rowContentX = cardX + insets.leading + editShift;
|
|
2967
|
-
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
|
|
3321
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve);
|
|
2968
3322
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
2969
3323
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
2970
3324
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -2997,15 +3351,27 @@ export class LayoutPass {
|
|
|
2997
3351
|
}
|
|
2998
3352
|
: null,
|
|
2999
3353
|
separatorHidden: hints.separatorHidden,
|
|
3354
|
+
outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
|
|
3000
3355
|
});
|
|
3001
3356
|
y += rowHeight;
|
|
3002
3357
|
}
|
|
3358
|
+
// Each group's rect: its label's row, then the rows under it that sit deeper, up to the next at its level or above.
|
|
3359
|
+
const outlines: ListGroupPlan['outlines'] = [];
|
|
3360
|
+
rows.forEach((row, i) => {
|
|
3361
|
+
const group = row.outline?.group;
|
|
3362
|
+
if (!group) return;
|
|
3363
|
+
const depth = row.outline?.depth ?? 0;
|
|
3364
|
+
let end = i;
|
|
3365
|
+
while (end + 1 < rows.length && (rows[end + 1]?.outline?.depth ?? 0) > depth) end += 1;
|
|
3366
|
+
const last = (rows[end] as ListRowPlan).row;
|
|
3367
|
+
outlines.push({ group, frame: { x: row.row.x, y: row.row.y, width: row.row.width, height: last.y + last.height - row.row.y } });
|
|
3368
|
+
});
|
|
3003
3369
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
3004
3370
|
if (footerText !== null && footerText.length > 0) {
|
|
3005
|
-
y += LIST_FOOTER_GAP;
|
|
3371
|
+
y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3006
3372
|
const { layout, size } = makeText(footerText, footerFont);
|
|
3007
3373
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
3008
|
-
y += size.height;
|
|
3374
|
+
y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3009
3375
|
}
|
|
3010
3376
|
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
3011
3377
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
@@ -3019,6 +3385,7 @@ export class LayoutPass {
|
|
|
3019
3385
|
footerView,
|
|
3020
3386
|
card,
|
|
3021
3387
|
rows,
|
|
3388
|
+
outlines,
|
|
3022
3389
|
});
|
|
3023
3390
|
}
|
|
3024
3391
|
const contentHeight = groupsSource.length === 0 ? listTop : y + listBottom;
|
|
@@ -3177,7 +3544,7 @@ export class LayoutPass {
|
|
|
3177
3544
|
case 'button': {
|
|
3178
3545
|
const label = this.contentOf(node);
|
|
3179
3546
|
if (!label) return;
|
|
3180
|
-
if (!this.buttonIsBordered(node)) {
|
|
3547
|
+
if (!this.buttonIsBordered(node, ctx)) {
|
|
3181
3548
|
this.place(label, cctx, p, x, y);
|
|
3182
3549
|
return;
|
|
3183
3550
|
}
|
|
@@ -3195,6 +3562,8 @@ export class LayoutPass {
|
|
|
3195
3562
|
this.setChrome(node, {
|
|
3196
3563
|
...existing,
|
|
3197
3564
|
label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
|
|
3565
|
+
// The page draws the border on an automatic button the engine bordered (the Mac idiom).
|
|
3566
|
+
bordered: { x, y, width: size.width, height: size.height },
|
|
3198
3567
|
});
|
|
3199
3568
|
return;
|
|
3200
3569
|
}
|
|
@@ -3216,9 +3585,10 @@ export class LayoutPass {
|
|
|
3216
3585
|
if (isButtonToggle(node)) {
|
|
3217
3586
|
// the label centered in the button (its fill, while on, the toggle's frame)
|
|
3218
3587
|
if (!label) return;
|
|
3588
|
+
const m = this.borderedMetrics(node);
|
|
3219
3589
|
const inner: Proposal = ctx.bottomBarItem
|
|
3220
3590
|
? { width: null, height: null }
|
|
3221
|
-
: { width: shrink(p.width, 2 *
|
|
3591
|
+
: { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
|
|
3222
3592
|
const labelSize = this.measure(label, cctx, inner);
|
|
3223
3593
|
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
3224
3594
|
this.place(label, cctx, inner, x + dx, y + dy);
|
|
@@ -3226,25 +3596,31 @@ export class LayoutPass {
|
|
|
3226
3596
|
this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
|
|
3227
3597
|
return;
|
|
3228
3598
|
}
|
|
3229
|
-
const
|
|
3599
|
+
const control = this.switchMetrics(ctx);
|
|
3600
|
+
const labelProposal = this.toggleLabelProposal(ctx, p);
|
|
3230
3601
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
3231
3602
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
3232
|
-
|
|
3603
|
+
// A checkbox leads its label; a switch trails it.
|
|
3604
|
+
const labelX = control.checkbox ? x + control.width + control.gap : x;
|
|
3605
|
+
if (label) this.place(label, cctx, labelProposal, labelX, labelY);
|
|
3233
3606
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3607
|
+
const switchRect: Rect = {
|
|
3608
|
+
x: control.checkbox ? x : x + size.width - control.width,
|
|
3609
|
+
y: y + (size.height - control.height) / 2,
|
|
3610
|
+
width: control.width,
|
|
3611
|
+
height: control.height,
|
|
3612
|
+
};
|
|
3234
3613
|
this.setChrome(node, {
|
|
3235
3614
|
...existing,
|
|
3236
3615
|
label: {
|
|
3237
|
-
x,
|
|
3616
|
+
x: labelX,
|
|
3238
3617
|
y: labelY,
|
|
3239
|
-
width: Math.min(labelSize.width, Math.max(0, size.width -
|
|
3618
|
+
width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
|
|
3240
3619
|
height: labelSize.height,
|
|
3241
3620
|
},
|
|
3242
|
-
switch:
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
width: SWITCH_WIDTH,
|
|
3246
|
-
height: SWITCH_HEIGHT,
|
|
3247
|
-
},
|
|
3621
|
+
switch: switchRect,
|
|
3622
|
+
// The page draws a checkbox at it (the Mac idiom).
|
|
3623
|
+
...(control.checkbox ? { checkbox: switchRect } : {}),
|
|
3248
3624
|
});
|
|
3249
3625
|
return;
|
|
3250
3626
|
}
|
|
@@ -3263,8 +3639,8 @@ export class LayoutPass {
|
|
|
3263
3639
|
// Phase 7: the selected row of a `navsplit` sidebar has no chevron
|
|
3264
3640
|
// (its label still reserves the chevron's width so selecting a row
|
|
3265
3641
|
// never reflows it); elsewhere `selected` keeps the chevron. No row
|
|
3266
|
-
// of a `sidebar`-style list has one (iPadOS).
|
|
3267
|
-
if (!(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
|
|
3642
|
+
// of a `sidebar`-style list has one (iPadOS), nor any row on a Mac.
|
|
3643
|
+
if (this.env.platform !== 'macos' && !(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
|
|
3268
3644
|
chrome['chevron'] = {
|
|
3269
3645
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
3270
3646
|
y,
|
|
@@ -3425,7 +3801,16 @@ export class LayoutPass {
|
|
|
3425
3801
|
// Phase 6: the label is placed; the items are only shown in the
|
|
3426
3802
|
// popover the renderer opens, so they are hidden and have no frames.
|
|
3427
3803
|
const [label, ...items] = node.children;
|
|
3428
|
-
if (
|
|
3804
|
+
if (this.menuIsPullDown(ctx)) {
|
|
3805
|
+
// The Mac's pull-down button: the label 10 in, centered in the 22 row, the chevron after it.
|
|
3806
|
+
const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
|
|
3807
|
+
const labelSize = label ? this.measure(label, cctx, inner) : ZERO;
|
|
3808
|
+
if (label) this.place(label, cctx, inner, x + MAC_PULLDOWN_PAD_H, y + (size.height - labelSize.height) / 2);
|
|
3809
|
+
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3810
|
+
this.setChrome(node, { ...existing, pulldown: { x, y, width: size.width, height: size.height } });
|
|
3811
|
+
} else if (label) {
|
|
3812
|
+
this.place(label, cctx, p, x, y);
|
|
3813
|
+
}
|
|
3429
3814
|
for (const item of items) this.hide(item, node);
|
|
3430
3815
|
return;
|
|
3431
3816
|
}
|
|
@@ -3532,7 +3917,9 @@ export class LayoutPass {
|
|
|
3532
3917
|
|
|
3533
3918
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
3534
3919
|
const plan = this.listPlan(node, ctx, p);
|
|
3535
|
-
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn:
|
|
3920
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
|
|
3921
|
+
const mac = this.env.platform === 'macos';
|
|
3922
|
+
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
3536
3923
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
3537
3924
|
// Under its screen's bar (and the large title that scrolls with it), the
|
|
3538
3925
|
// rows start below them.
|
|
@@ -3568,7 +3955,7 @@ export class LayoutPass {
|
|
|
3568
3955
|
if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
|
|
3569
3956
|
}
|
|
3570
3957
|
if (group.section.id >= 0) {
|
|
3571
|
-
this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
|
|
3958
|
+
this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform), 400));
|
|
3572
3959
|
if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
|
|
3573
3960
|
}
|
|
3574
3961
|
// A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
|
|
@@ -3578,6 +3965,25 @@ export class LayoutPass {
|
|
|
3578
3965
|
}
|
|
3579
3966
|
}
|
|
3580
3967
|
}
|
|
3968
|
+
// An outline's `disclosuregroup` is not placed through `place` (its label
|
|
3969
|
+
// and content are the rows): its frame is the rect its rows span, which
|
|
3970
|
+
// is its box too (`row`, so the page lays its rows out from it), its
|
|
3971
|
+
// content is hidden while collapsed, and what a pass before hid is shown.
|
|
3972
|
+
for (const outline of group.outlines) {
|
|
3973
|
+
const g = outline.group;
|
|
3974
|
+
const previouslyHidden = this.hiddenBy.get(g);
|
|
3975
|
+
if (previouslyHidden) {
|
|
3976
|
+
for (const id of previouslyHidden) {
|
|
3977
|
+
this.hidden.delete(id);
|
|
3978
|
+
this.touched.add(id);
|
|
3979
|
+
}
|
|
3980
|
+
this.hiddenBy.delete(g);
|
|
3981
|
+
}
|
|
3982
|
+
const frame = offset(outline.frame);
|
|
3983
|
+
this.setFrame(g, frame.x, frame.y, frame);
|
|
3984
|
+
this.setChrome(g, { row: frame });
|
|
3985
|
+
if (g.props['expanded'] !== true) for (const child of g.children.slice(1)) this.hide(child, g);
|
|
3986
|
+
}
|
|
3581
3987
|
for (const row of group.rows) {
|
|
3582
3988
|
// Set the row chrome before placing so a navlink can merge its own keys.
|
|
3583
3989
|
const chrome: Record<string, Rect> = { row: offset(row.row) };
|
|
@@ -3616,6 +4022,14 @@ export class LayoutPass {
|
|
|
3616
4022
|
const leading = (textX ?? rowX) - (x + row.row.x);
|
|
3617
4023
|
placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
|
|
3618
4024
|
}
|
|
4025
|
+
// A group's label row: the group's toggle lies over it, its chevron at the row's trailing edge (as a link's; none on a Mac).
|
|
4026
|
+
const outlineGroup = row.outline?.group;
|
|
4027
|
+
if (outlineGroup && outlineGroup.id >= 0) {
|
|
4028
|
+
const rowRect = offset(row.row);
|
|
4029
|
+
const groupChrome: Record<string, Rect> = { ...this.chrome.get(outlineGroup.id), toggle: rowRect };
|
|
4030
|
+
if (!mac) groupChrome['chevron'] = { x: rowRect.x + rowRect.width - cellPadding - LIST_CHEVRON_WIDTH, y: rowRect.y, width: LIST_CHEVRON_WIDTH, height: rowRect.height };
|
|
4031
|
+
this.setChrome(outlineGroup, groupChrome);
|
|
4032
|
+
}
|
|
3619
4033
|
}
|
|
3620
4034
|
}
|
|
3621
4035
|
}
|
|
@@ -3682,11 +4096,32 @@ export class LayoutPass {
|
|
|
3682
4096
|
// area. `.bottomBar` hidden: the bottom toolbars draw nothing.
|
|
3683
4097
|
const barHidden = node.props['navigationBarHidden'] === true;
|
|
3684
4098
|
const bottomBarHidden = node.props['bottomBarHidden'] === true;
|
|
4099
|
+
const parts = this.navScreenParts(node);
|
|
4100
|
+
// The Mac idiom (MAC_* above): the title bar in place of the bar, unified
|
|
4101
|
+
// (52) when anything but the title is in it, plain (28) otherwise; no
|
|
4102
|
+
// large titles. The status bar portion is the window's title bar, which
|
|
4103
|
+
// the unified bar takes over; the row is the whole bar.
|
|
4104
|
+
const mac = this.env.platform === 'macos';
|
|
4105
|
+
const hasBarItems = parts.toolbars.some((t) => {
|
|
4106
|
+
const placement = str(t.props['placement']);
|
|
4107
|
+
return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
|
|
4108
|
+
});
|
|
4109
|
+
const searchInBar = mac && parts.search !== null && !barHidden;
|
|
4110
|
+
// A Mac tab view's toolbar row (the window's top): the tab's root screen
|
|
4111
|
+
// draws its bar as that row, without a title (the tab picker is there).
|
|
4112
|
+
const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
|
|
4113
|
+
// A Mac sheet's root screen: the sheet's 40 title bar, its text actions in the sheet's bottom bar.
|
|
4114
|
+
const sheetBar = mac && depth === 0 && cctx.macSheet !== undefined ? cctx.macSheet : undefined;
|
|
4115
|
+
const actionToolbars = sheetBar?.actions ? this.macSheetActionToolbars(node) : [];
|
|
4116
|
+
const unified = mac && tabToolbar === undefined && sheetBar === undefined && (depth > 0 || hasBarItems || searchInBar || (cctx.barLeading ?? 0) > 0 || cctx.splitColumn === true);
|
|
4117
|
+
const rowHeight = mac ? (tabToolbar ? tabToolbar.height : sheetBar ? MAC_SHEET_TITLE_BAR : unified ? MAC_TITLE_BAR_UNIFIED : MAC_TITLE_BAR_PLAIN) : NAV_BAR_HEIGHT;
|
|
4118
|
+
const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
4119
|
+
const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
|
|
4120
|
+
const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
|
|
3685
4121
|
// A compact height (a phone on its side) has no large titles, whatever the
|
|
3686
4122
|
// display mode, as UIKit: the title is the bar's inline one.
|
|
3687
|
-
const large = !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
3688
|
-
const barHeight = barHidden ? statusBar : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
3689
|
-
const parts = this.navScreenParts(node);
|
|
4123
|
+
const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
4124
|
+
const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
3690
4125
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3691
4126
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3692
4127
|
const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
|
|
@@ -3712,9 +4147,9 @@ export class LayoutPass {
|
|
|
3712
4147
|
: 0;
|
|
3713
4148
|
// In a tab view (a tab bar covers the bottom) the field goes under the
|
|
3714
4149
|
// title, as iOS 26 places it; elsewhere it floats at the bottom.
|
|
3715
|
-
const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
|
|
4150
|
+
const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
|
|
3716
4151
|
const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
3717
|
-
const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
4152
|
+
const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
3718
4153
|
// The on-screen keyboard (device mode) covers the screen's bottom. A
|
|
3719
4154
|
// bottom search field rises above it, as iOS 26 docks it on the
|
|
3720
4155
|
// keyboard, so what is typed into it can be seen; the bars stay put,
|
|
@@ -3726,20 +4161,26 @@ export class LayoutPass {
|
|
|
3726
4161
|
const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
|
|
3727
4162
|
const searchLift = searchBottom > 0 ? keyboard : 0;
|
|
3728
4163
|
const contentTop = barHeight + largeHeight + searchRow;
|
|
4164
|
+
const actionsBar = sheetBar?.actions ? sheetBar.actions.height : 0;
|
|
3729
4165
|
const content: Rect = {
|
|
3730
4166
|
x,
|
|
3731
4167
|
y: y + contentTop,
|
|
3732
4168
|
width: size.width,
|
|
3733
|
-
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
4169
|
+
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom) - actionsBar),
|
|
3734
4170
|
};
|
|
3735
4171
|
const chrome: Record<string, Rect> = { content };
|
|
3736
4172
|
if (!barHidden) {
|
|
3737
4173
|
chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
|
|
3738
|
-
// The 44 row the bar's items sit in, under the status bar
|
|
3739
|
-
|
|
4174
|
+
// The 44 row the bar's items sit in, under the status bar (on a Mac the
|
|
4175
|
+
// title bar's row, which ends at the bar's bottom).
|
|
4176
|
+
chrome['barRow'] = { x, y: mac ? y + barHeight - rowHeight : y + statusBar, width: size.width, height: rowHeight };
|
|
4177
|
+
// The page styles a unified title bar's title (leading-aligned, larger).
|
|
4178
|
+
if (unified) chrome['unified'] = chrome['barRow'];
|
|
4179
|
+
// The page styles a Mac sheet's title (leading-aligned, 13 bold).
|
|
4180
|
+
if (sheetBar) chrome['sheetBar'] = chrome['barRow'];
|
|
3740
4181
|
}
|
|
3741
4182
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
3742
|
-
if (parts.search) {
|
|
4183
|
+
if (parts.search && !searchInBar) {
|
|
3743
4184
|
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
3744
4185
|
chrome['searchField'] = {
|
|
3745
4186
|
x: x + marginH,
|
|
@@ -3758,12 +4199,14 @@ export class LayoutPass {
|
|
|
3758
4199
|
chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
|
|
3759
4200
|
}
|
|
3760
4201
|
const title = str(node.props['title']) ?? '';
|
|
3761
|
-
const
|
|
3762
|
-
|
|
3763
|
-
const
|
|
4202
|
+
const { platform } = this.env;
|
|
4203
|
+
// A unified title bar's title is the window title (15 semibold on macOS 14); a plain bar's and iOS's is the headline.
|
|
4204
|
+
const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
|
|
4205
|
+
const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
|
|
4206
|
+
const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize, platform), 400);
|
|
3764
4207
|
|
|
3765
4208
|
// Toolbar items at their ideal size, grouped per toolbar.
|
|
3766
|
-
const rowY = y + statusBar;
|
|
4209
|
+
const rowY = (chrome['barRow'] ?? { y: y + statusBar }).y;
|
|
3767
4210
|
interface Group {
|
|
3768
4211
|
toolbar: LayoutNode;
|
|
3769
4212
|
placement: string;
|
|
@@ -3779,15 +4222,34 @@ export class LayoutPass {
|
|
|
3779
4222
|
? []
|
|
3780
4223
|
: parts.toolbars.filter((t) => {
|
|
3781
4224
|
const placement = placementOf(t);
|
|
3782
|
-
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
|
|
4225
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t) && !actionToolbars.includes(t);
|
|
4226
|
+
});
|
|
4227
|
+
// A Mac sheet's actions: 22 push buttons at the trailing end of the
|
|
4228
|
+
// sheet's bottom bar, the trailing placement's (confirm) last, 8 apart.
|
|
4229
|
+
if (sheetBar?.actions) {
|
|
4230
|
+
const actions = sheetBar.actions;
|
|
4231
|
+
let cursor = actions.x + actions.width - MAC_SHEET_ACTIONS_INSET;
|
|
4232
|
+
const ordered = [...actionToolbars.filter((t) => placementOf(t) === 'trailing'), ...actionToolbars.filter((t) => placementOf(t) === 'leading')];
|
|
4233
|
+
for (const toolbar of ordered) {
|
|
4234
|
+
const itemCtx: Context = { ...this.childContext(toolbar, cctx), navBarItem: false };
|
|
4235
|
+
let first = cursor;
|
|
4236
|
+
let last = cursor;
|
|
4237
|
+
[...toolbar.children].reverse().forEach((item) => {
|
|
4238
|
+
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
4239
|
+
this.place(item, itemCtx, { width: null, height: null }, cursor - itemSize.width, actions.y + (actions.height - itemSize.height) / 2);
|
|
4240
|
+
first = cursor - itemSize.width;
|
|
4241
|
+
cursor = first - MAC_SHEET_ACTIONS_GAP;
|
|
3783
4242
|
});
|
|
4243
|
+
this.setFrame(toolbar, first, actions.y, { width: Math.max(0, last - first), height: actions.height });
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
3784
4246
|
const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
|
|
3785
4247
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3786
4248
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3787
4249
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3788
4250
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
3789
4251
|
if (placement === 'leading' || placement === 'trailing') {
|
|
3790
|
-
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
|
|
4252
|
+
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
|
|
3791
4253
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
3792
4254
|
}
|
|
3793
4255
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
@@ -3811,7 +4273,7 @@ export class LayoutPass {
|
|
|
3811
4273
|
const runWidth = (run: Run): number => {
|
|
3812
4274
|
const slots = run.groups.flatMap((g) => g.slots ?? []);
|
|
3813
4275
|
if (slots.length === 0) return 0;
|
|
3814
|
-
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) *
|
|
4276
|
+
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
|
|
3815
4277
|
return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
|
|
3816
4278
|
};
|
|
3817
4279
|
for (const toolbar of parts.toolbars) {
|
|
@@ -3868,7 +4330,7 @@ export class LayoutPass {
|
|
|
3868
4330
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3869
4331
|
return toolbar.children.map((item) => {
|
|
3870
4332
|
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3871
|
-
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
|
|
4333
|
+
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width, glass, glassPad) };
|
|
3872
4334
|
});
|
|
3873
4335
|
});
|
|
3874
4336
|
// Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
|
|
@@ -3895,26 +4357,38 @@ export class LayoutPass {
|
|
|
3895
4357
|
}
|
|
3896
4358
|
return out;
|
|
3897
4359
|
};
|
|
3898
|
-
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart -
|
|
4360
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - NAV_GROUP_SPACING);
|
|
3899
4361
|
}
|
|
3900
4362
|
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
3901
4363
|
|
|
3902
4364
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
3903
4365
|
// title no longer shows).
|
|
3904
4366
|
const backLabel = '';
|
|
3905
|
-
|
|
4367
|
+
// A Mac window's own title bar (the top of the screen, a title bar in the
|
|
4368
|
+
// safe area) starts with the window buttons; the items follow them.
|
|
4369
|
+
const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
|
|
4370
|
+
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4371
|
+
const glassY = rowY + (rowHeight - glass) / 2;
|
|
3906
4372
|
if (depth > 0 && !barHidden) {
|
|
3907
|
-
chrome['back'] = { x: leadingStart, y:
|
|
4373
|
+
chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
|
|
3908
4374
|
}
|
|
3909
4375
|
|
|
3910
4376
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
3911
4377
|
const back = chrome['back'];
|
|
3912
4378
|
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
3913
|
-
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
4379
|
+
let trailingEdge = x + size.width - (mac ? (tabToolbar ? MAC_TAB_TOOLBAR_MARGIN_TRAILING : MAC_BAR_MARGIN_TRAILING) : NAV_ITEM_MARGIN);
|
|
4380
|
+
// On a Mac the search field is the trailing-most item of the title bar
|
|
4381
|
+
// (chrome `searchBar` tells the page to draw it as the toolbar's field).
|
|
4382
|
+
if (searchInBar) {
|
|
4383
|
+
const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
|
|
4384
|
+
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_SEARCH_FIELD_HEIGHT) / 2, width, height: MAC_SEARCH_FIELD_HEIGHT };
|
|
4385
|
+
chrome['searchBar'] = chrome['searchField'];
|
|
4386
|
+
trailingEdge -= width + NAV_GROUP_SPACING;
|
|
4387
|
+
}
|
|
3914
4388
|
// The More button is the trailing-most item.
|
|
3915
4389
|
if (collapsedSecondary.length > 0) {
|
|
3916
|
-
chrome['more'] = { x: trailingEdge -
|
|
3917
|
-
trailingEdge -=
|
|
4390
|
+
chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
|
|
4391
|
+
trailingEdge -= glass + NAV_GROUP_SPACING;
|
|
3918
4392
|
}
|
|
3919
4393
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
3920
4394
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -3937,7 +4411,7 @@ export class LayoutPass {
|
|
|
3937
4411
|
const placeRun = (run: Run, startX: number): void => {
|
|
3938
4412
|
const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
|
|
3939
4413
|
if (!run.bare) {
|
|
3940
|
-
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y:
|
|
4414
|
+
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
|
|
3941
4415
|
}
|
|
3942
4416
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
3943
4417
|
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
@@ -3947,11 +4421,17 @@ export class LayoutPass {
|
|
|
3947
4421
|
group.toolbar.children.forEach((item, i) => {
|
|
3948
4422
|
const itemSize = group.sizes[i] as Size;
|
|
3949
4423
|
const slot = group.slots?.[i] ?? itemSize.width;
|
|
3950
|
-
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (
|
|
3951
|
-
|
|
4424
|
+
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
|
|
4425
|
+
// A Mac toolbar's text button is a bordered 28 button at its slot
|
|
4426
|
+
// (chrome `bordered`, drawn by the page at that rect); an icon is bare.
|
|
4427
|
+
if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
|
|
4428
|
+
const existing = this.chrome.get(item.id);
|
|
4429
|
+
this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
|
|
4430
|
+
}
|
|
4431
|
+
cursor += slot + runGap;
|
|
3952
4432
|
});
|
|
3953
|
-
const last = group.toolbar.children.length > 0 ? cursor -
|
|
3954
|
-
this.setFrame(group.toolbar, first, rowY, { width: last - first, height:
|
|
4433
|
+
const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
|
|
4434
|
+
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
|
|
3955
4435
|
}
|
|
3956
4436
|
};
|
|
3957
4437
|
for (const run of runs.filter((r) => r.placement === 'leading')) {
|
|
@@ -3970,8 +4450,8 @@ export class LayoutPass {
|
|
|
3970
4450
|
for (const group of groups) {
|
|
3971
4451
|
if (group.placement !== 'principal') continue;
|
|
3972
4452
|
const startX = x + (size.width - group.total) / 2;
|
|
3973
|
-
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height:
|
|
3974
|
-
placeGroup(group, startX, rowY,
|
|
4453
|
+
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: rowHeight });
|
|
4454
|
+
placeGroup(group, startX, rowY, rowHeight, NAV_ITEM_SPACING);
|
|
3975
4455
|
}
|
|
3976
4456
|
// The inline secondary items, right-aligned before the trailing groups,
|
|
3977
4457
|
// each toolbar framed around its own; the collapsed ones are hidden (the
|
|
@@ -3983,12 +4463,12 @@ export class LayoutPass {
|
|
|
3983
4463
|
for (const [i, entry] of inlineSecondary.entries()) {
|
|
3984
4464
|
cursor += secondaryGap(inlineSecondary[i - 1], entry);
|
|
3985
4465
|
const itemCtx = this.childContext(entry.toolbar, cctx);
|
|
3986
|
-
this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (
|
|
4466
|
+
this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (rowHeight - entry.size.height) / 2);
|
|
3987
4467
|
const extent = extents.get(entry.toolbar);
|
|
3988
4468
|
extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
|
|
3989
4469
|
cursor += entry.slot;
|
|
3990
4470
|
}
|
|
3991
|
-
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height:
|
|
4471
|
+
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
|
|
3992
4472
|
trailingEdge -= total + NAV_GROUP_SPACING;
|
|
3993
4473
|
}
|
|
3994
4474
|
for (const toolbar of secondaryToolbars) {
|
|
@@ -4046,7 +4526,19 @@ export class LayoutPass {
|
|
|
4046
4526
|
const titleScrolls = large && scroller !== null;
|
|
4047
4527
|
// A large title that scrolls away collapses into the inline title, which
|
|
4048
4528
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
4049
|
-
if (
|
|
4529
|
+
if (tabToolbar) {
|
|
4530
|
+
// The tab picker stands where the title would be.
|
|
4531
|
+
} else if ((unified || sheetBar) && !principal && !barHidden) {
|
|
4532
|
+
// The unified title bar's title (and a Mac sheet's): leading-aligned
|
|
4533
|
+
// after the back button and the leading items (2 past the window
|
|
4534
|
+
// buttons' room when nothing is before it), cut to the room before
|
|
4535
|
+
// the trailing ones.
|
|
4536
|
+
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
|
|
4537
|
+
const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
|
|
4538
|
+
const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
|
|
4539
|
+
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4540
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4541
|
+
} else if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
4050
4542
|
const leftUsed = Math.max(
|
|
4051
4543
|
back ? back.x + back.width - x : 0,
|
|
4052
4544
|
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
@@ -4067,14 +4559,14 @@ export class LayoutPass {
|
|
|
4067
4559
|
);
|
|
4068
4560
|
const centered = x + (size.width - titleWidth) / 2;
|
|
4069
4561
|
const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
4070
|
-
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height:
|
|
4562
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4071
4563
|
}
|
|
4072
4564
|
this.setChrome(node, chrome);
|
|
4073
4565
|
|
|
4074
4566
|
if (node.id >= 0) {
|
|
4075
4567
|
this.fonts.set(node.id, large ? largeTitleFont : titleFont);
|
|
4076
4568
|
const texts: Record<string, TextLayout> = {
|
|
4077
|
-
title: { font: titleFont, lines: [title], alignment: 'center', truncated: false },
|
|
4569
|
+
title: { font: titleFont, lines: [title], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
|
|
4078
4570
|
};
|
|
4079
4571
|
if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
|
|
4080
4572
|
if (large) {
|
|
@@ -4324,6 +4816,10 @@ export class LayoutPass {
|
|
|
4324
4816
|
*/
|
|
4325
4817
|
private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
4326
4818
|
const { screen } = this.env;
|
|
4819
|
+
if (this.env.platform === 'macos') {
|
|
4820
|
+
this.placeMacTabView(node, cctx, x, y, size);
|
|
4821
|
+
return;
|
|
4822
|
+
}
|
|
4327
4823
|
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
4328
4824
|
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
4329
4825
|
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
@@ -4363,6 +4859,64 @@ export class LayoutPass {
|
|
|
4363
4859
|
});
|
|
4364
4860
|
}
|
|
4365
4861
|
|
|
4862
|
+
/**
|
|
4863
|
+
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a segmented
|
|
4864
|
+
* control centered in a 44 row at the top of the content, below the
|
|
4865
|
+
* window's title bar when the tab view is at the top of the screen; the
|
|
4866
|
+
* content is the rest, and the selected tab fills it (its own `navstack`
|
|
4867
|
+
* reserves no status bar there: the row is above it). Chrome `top` marks
|
|
4868
|
+
* the bar as the top one for the page.
|
|
4869
|
+
*/
|
|
4870
|
+
private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
4871
|
+
const { safeArea } = this.env;
|
|
4872
|
+
// At the top of the window the row is the window's toolbar (38, the
|
|
4873
|
+
// title bar included); lower down, a 38 row at the top of the content.
|
|
4874
|
+
const atTop = y <= 0 && safeArea.top > 0;
|
|
4875
|
+
const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
|
|
4876
|
+
const tabs = node.children;
|
|
4877
|
+
const n = tabs.length;
|
|
4878
|
+
// Each segment is its title (13 regular) and 12 either side.
|
|
4879
|
+
const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
|
|
4880
|
+
const itemWidths = tabs.map((tab) => this.measurer.measureWidth(str(tab.props['title']) ?? '', font) + 2 * MAC_TAB_ITEM_PAD_H);
|
|
4881
|
+
const natural = itemWidths.reduce((sum, w) => sum + w, 0) + 2 * MAC_TAB_ITEM_INSET;
|
|
4882
|
+
const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
|
|
4883
|
+
const scale = natural > 0 ? width / natural : 1;
|
|
4884
|
+
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height - topInset));
|
|
4885
|
+
const bar: Rect = { x: x + (size.width - width) / 2, y: y + topInset + (rowHeight - MAC_TAB_BAR_HEIGHT) / 2, width, height: Math.min(MAC_TAB_BAR_HEIGHT, rowHeight) };
|
|
4886
|
+
const toolbar: Rect = { x, y: y + topInset, width: size.width, height: rowHeight };
|
|
4887
|
+
const contentTop = topInset + rowHeight;
|
|
4888
|
+
const content: Rect = { x, y: y + contentTop, width: size.width, height: Math.max(0, size.height - contentTop) };
|
|
4889
|
+
const chrome: Record<string, Rect> = { bar, content, top: bar };
|
|
4890
|
+
let itemX = bar.x + MAC_TAB_ITEM_INSET;
|
|
4891
|
+
for (let i = 0; i < n; i++) {
|
|
4892
|
+
const itemWidth = (itemWidths[i] as number) * scale;
|
|
4893
|
+
chrome[`item${i}`] = { x: itemX, y: bar.y + MAC_TAB_ITEM_INSET, width: itemWidth, height: Math.max(0, bar.height - 2 * MAC_TAB_ITEM_INSET) };
|
|
4894
|
+
itemX += itemWidth;
|
|
4895
|
+
}
|
|
4896
|
+
this.setChrome(node, chrome);
|
|
4897
|
+
const selected = readSelected(node, n);
|
|
4898
|
+
const tabCtx: Context = { ...cctx, bottomEdge: undefined, barAbove: undefined };
|
|
4899
|
+
const contentSize: Size = { width: content.width, height: content.height };
|
|
4900
|
+
tabs.forEach((tab, i) => {
|
|
4901
|
+
if (i !== selected) {
|
|
4902
|
+
this.setFrame(tab, content.x, content.y, contentSize);
|
|
4903
|
+
this.hide(tab, node);
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4906
|
+
const child = this.contentOf(tab);
|
|
4907
|
+
// A navigation stack at the window's top fills the tab: its root
|
|
4908
|
+
// screen draws the toolbar row as its bar (`tabToolbar`), with the
|
|
4909
|
+
// picker over it; anything else starts under the row.
|
|
4910
|
+
if (atTop && child && unwrapStyled(child).kind === 'navstack') {
|
|
4911
|
+
this.setFrame(tab, x, y, size);
|
|
4912
|
+
this.placeTabContent(tab, this.childContext(tab, { ...tabCtx, tabToolbar: toolbar }), x, y, size, 0);
|
|
4913
|
+
return;
|
|
4914
|
+
}
|
|
4915
|
+
this.setFrame(tab, content.x, content.y, contentSize);
|
|
4916
|
+
this.placeTabContent(tab, this.childContext(tab, tabCtx), content.x, content.y, contentSize, 0);
|
|
4917
|
+
});
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4366
4920
|
/**
|
|
4367
4921
|
* The selected tab's content, like a navscreen's: a `navstack` fills the
|
|
4368
4922
|
* whole tab (it reserves the status bar itself and its lists run under the
|
|
@@ -4430,7 +4984,7 @@ export class LayoutPass {
|
|
|
4430
4984
|
const leading = x <= 0 ? safeArea.leading : 0;
|
|
4431
4985
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
4432
4986
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
4433
|
-
const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
4987
|
+
const sidebarWidth = Math.min(this.env.platform === 'macos' ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
4434
4988
|
const visible = node.props['sidebarVisible'] !== false;
|
|
4435
4989
|
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
4436
4990
|
const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
|
|
@@ -4438,22 +4992,29 @@ export class LayoutPass {
|
|
|
4438
4992
|
const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
|
|
4439
4993
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
4440
4994
|
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
4995
|
+
// On a Mac the toggle is a 28 button centered in the unified title bar's
|
|
4996
|
+
// row (which the columns' screens lay out: `placeNavScreen`), after the
|
|
4997
|
+
// window buttons when the split view is at the top of the screen.
|
|
4998
|
+
const mac = this.env.platform === 'macos';
|
|
4999
|
+
const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
5000
|
+
const toggleX = x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
|
|
5001
|
+
const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
|
|
4441
5002
|
const chrome: Record<string, Rect> = {
|
|
4442
5003
|
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
4443
5004
|
separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
|
|
4444
5005
|
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
4445
|
-
toggle: { x:
|
|
5006
|
+
toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
|
|
4446
5007
|
};
|
|
4447
5008
|
this.setChrome(node, chrome);
|
|
4448
|
-
const toggleRoom =
|
|
5009
|
+
const toggleRoom = toggleSize + NAV_GROUP_SPACING;
|
|
4449
5010
|
const [sidebar, detail, ...rest] = node.children;
|
|
4450
5011
|
if (sidebar) {
|
|
4451
5012
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
4452
|
-
this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
5013
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
4453
5014
|
}
|
|
4454
5015
|
if (detail) {
|
|
4455
5016
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
4456
|
-
this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
5017
|
+
this.place(detail, { ...cctx, splitSidebar: false, splitColumn: true, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
4457
5018
|
}
|
|
4458
5019
|
for (const extra of rest) this.hide(extra, node);
|
|
4459
5020
|
}
|
|
@@ -4577,16 +5138,29 @@ export class LayoutPass {
|
|
|
4577
5138
|
}
|
|
4578
5139
|
|
|
4579
5140
|
private stepperLabelProposal(p: Proposal): Proposal {
|
|
4580
|
-
|
|
5141
|
+
const control = this.stepperMetrics();
|
|
5142
|
+
return { width: shrink(p.width, control.width + control.gap), height: p.height };
|
|
4581
5143
|
}
|
|
4582
5144
|
|
|
4583
5145
|
/** Like a toggle: the label leads, the 94 × 32 control trails; at least 32 high. */
|
|
5146
|
+
/** The Mac idiom: a menu outside a bar and a list row is a pull-down button. */
|
|
5147
|
+
private menuIsPullDown(ctx: Context): boolean {
|
|
5148
|
+
return this.env.platform === 'macos' && !ctx.listRow && !ctx.navBarItem && !ctx.bottomBarItem;
|
|
5149
|
+
}
|
|
5150
|
+
|
|
5151
|
+
/** The stepper's control: iOS's segmented −/+, or the Mac's 13 × 22 arrows. */
|
|
5152
|
+
private stepperMetrics(): { width: number; height: number; gap: number; mac: boolean } {
|
|
5153
|
+
if (this.env.platform === 'macos') return { width: MAC_STEPPER_WIDTH, height: MAC_STEPPER_HEIGHT, gap: MAC_STEPPER_GAP, mac: true };
|
|
5154
|
+
return { width: STEPPER_WIDTH, height: STEPPER_HEIGHT, gap: STEPPER_GAP, mac: false };
|
|
5155
|
+
}
|
|
5156
|
+
|
|
4584
5157
|
private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
4585
5158
|
const label = this.contentOf(node);
|
|
5159
|
+
const control = this.stepperMetrics();
|
|
4586
5160
|
const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
|
|
4587
5161
|
return {
|
|
4588
|
-
width: finite(p.width) ? p.width : labelSize.width +
|
|
4589
|
-
height: Math.max(labelSize.height,
|
|
5162
|
+
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
5163
|
+
height: Math.max(labelSize.height, control.height),
|
|
4590
5164
|
};
|
|
4591
5165
|
}
|
|
4592
5166
|
|
|
@@ -4597,21 +5171,28 @@ export class LayoutPass {
|
|
|
4597
5171
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
4598
5172
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
4599
5173
|
if (label) this.place(label, cctx, labelProposal, x, labelY);
|
|
4600
|
-
const
|
|
4601
|
-
const
|
|
4602
|
-
const
|
|
5174
|
+
const control = this.stepperMetrics();
|
|
5175
|
+
const controlX = x + size.width - control.width;
|
|
5176
|
+
const controlY = y + (size.height - control.height) / 2;
|
|
4603
5177
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
5178
|
+
// iOS: − and + side by side; a Mac: up (increment) over down.
|
|
5179
|
+
const half = control.mac ? control.height / 2 : control.width / 2;
|
|
4604
5180
|
this.setChrome(node, {
|
|
4605
5181
|
...existing,
|
|
4606
5182
|
label: {
|
|
4607
5183
|
x,
|
|
4608
5184
|
y: labelY,
|
|
4609
|
-
width: Math.min(labelSize.width, Math.max(0, size.width -
|
|
5185
|
+
width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
|
|
4610
5186
|
height: labelSize.height,
|
|
4611
5187
|
},
|
|
4612
|
-
control: { x: controlX, y: controlY, width:
|
|
4613
|
-
decrement:
|
|
4614
|
-
|
|
5188
|
+
control: { x: controlX, y: controlY, width: control.width, height: control.height },
|
|
5189
|
+
decrement: control.mac
|
|
5190
|
+
? { x: controlX, y: controlY + half, width: control.width, height: half }
|
|
5191
|
+
: { x: controlX, y: controlY, width: half, height: control.height },
|
|
5192
|
+
increment: control.mac
|
|
5193
|
+
? { x: controlX, y: controlY, width: control.width, height: half }
|
|
5194
|
+
: { x: controlX + half, y: controlY, width: half, height: control.height },
|
|
5195
|
+
...(control.mac ? { arrows: { x: controlX, y: controlY, width: control.width, height: control.height } } : {}),
|
|
4615
5196
|
});
|
|
4616
5197
|
}
|
|
4617
5198
|
|
|
@@ -4622,6 +5203,12 @@ export class LayoutPass {
|
|
|
4622
5203
|
* names the slider for VoiceOver): the page hides it and names the slider
|
|
4623
5204
|
* from it (`aria-labelledby`). Children are named by `labels` in order.
|
|
4624
5205
|
*/
|
|
5206
|
+
/** The slider's row, track and knob: iOS 26's pill knob, or the Mac's round one. */
|
|
5207
|
+
private sliderMetrics(): { height: number; track: number; thumb: number; thumbHeight: number; mac: boolean } {
|
|
5208
|
+
if (this.env.platform === 'macos') return { height: MAC_SLIDER_HEIGHT, track: MAC_SLIDER_TRACK_HEIGHT, thumb: MAC_SLIDER_THUMB, thumbHeight: MAC_SLIDER_THUMB, mac: true };
|
|
5209
|
+
return { height: SLIDER_HEIGHT, track: SLIDER_TRACK_HEIGHT, thumb: SLIDER_THUMB, thumbHeight: SLIDER_THUMB_HEIGHT, mac: false };
|
|
5210
|
+
}
|
|
5211
|
+
|
|
4625
5212
|
private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
|
|
4626
5213
|
return this.plan(node, ctx, p, () => {
|
|
4627
5214
|
const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
|
|
@@ -4638,7 +5225,7 @@ export class LayoutPass {
|
|
|
4638
5225
|
});
|
|
4639
5226
|
const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
|
|
4640
5227
|
const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
|
|
4641
|
-
return { size: { width, height:
|
|
5228
|
+
return { size: { width, height: this.sliderMetrics().height }, label, min, max, minSize, maxSize, cctx };
|
|
4642
5229
|
});
|
|
4643
5230
|
}
|
|
4644
5231
|
|
|
@@ -4646,35 +5233,38 @@ export class LayoutPass {
|
|
|
4646
5233
|
const plan = this.sliderPlan(node, ctx, p);
|
|
4647
5234
|
const { cctx } = plan;
|
|
4648
5235
|
if (plan.label) this.hide(plan.label, node);
|
|
5236
|
+
const m = this.sliderMetrics();
|
|
4649
5237
|
const rowY = y;
|
|
4650
5238
|
let trackX = x;
|
|
4651
5239
|
let trackEnd = x + size.width;
|
|
4652
5240
|
if (plan.min) {
|
|
4653
|
-
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (
|
|
5241
|
+
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
|
|
4654
5242
|
trackX += plan.minSize.width + SLIDER_LABEL_GAP;
|
|
4655
5243
|
}
|
|
4656
5244
|
if (plan.max) {
|
|
4657
|
-
this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (
|
|
5245
|
+
this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (m.height - plan.maxSize.height) / 2);
|
|
4658
5246
|
trackEnd -= plan.maxSize.width + SLIDER_LABEL_GAP;
|
|
4659
5247
|
}
|
|
4660
5248
|
const trackWidth = Math.max(0, trackEnd - trackX);
|
|
4661
|
-
const trackY = rowY + (
|
|
5249
|
+
const trackY = rowY + (m.height - m.track) / 2;
|
|
4662
5250
|
const fraction = sliderFraction(num(node.props['value']) ?? 0, num(node.props['min']) ?? 0, num(node.props['max']) ?? 1);
|
|
4663
5251
|
// The thumb travels inside the track: its center runs from half a thumb
|
|
4664
5252
|
// past the track's start to half a thumb before its end.
|
|
4665
|
-
const travel = Math.max(0, trackWidth -
|
|
4666
|
-
const thumbCenter = trackX +
|
|
5253
|
+
const travel = Math.max(0, trackWidth - m.thumb);
|
|
5254
|
+
const thumbCenter = trackX + m.thumb / 2 + fraction * travel;
|
|
4667
5255
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
4668
5256
|
this.setChrome(node, {
|
|
4669
5257
|
...existing,
|
|
4670
|
-
track: { x: trackX, y: trackY, width: trackWidth, height:
|
|
4671
|
-
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height:
|
|
5258
|
+
track: { x: trackX, y: trackY, width: trackWidth, height: m.track },
|
|
5259
|
+
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: m.track },
|
|
4672
5260
|
thumb: {
|
|
4673
|
-
x: thumbCenter -
|
|
4674
|
-
y: rowY + (
|
|
4675
|
-
width:
|
|
4676
|
-
height:
|
|
5261
|
+
x: thumbCenter - m.thumb / 2,
|
|
5262
|
+
y: rowY + (m.height - m.thumbHeight) / 2,
|
|
5263
|
+
width: m.thumb,
|
|
5264
|
+
height: m.thumbHeight,
|
|
4677
5265
|
},
|
|
5266
|
+
// The page draws the Mac's round knob (styles.css).
|
|
5267
|
+
...(m.mac ? { knob: { x: thumbCenter - m.thumb / 2, y: rowY + (m.height - m.thumbHeight) / 2, width: m.thumb, height: m.thumbHeight } } : {}),
|
|
4678
5268
|
});
|
|
4679
5269
|
}
|
|
4680
5270
|
|
|
@@ -4691,17 +5281,20 @@ export class LayoutPass {
|
|
|
4691
5281
|
const timeZone = str(node.props['timeZone']) ?? undefined;
|
|
4692
5282
|
const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
|
|
4693
5283
|
const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
|
|
5284
|
+
const mac = this.env.platform === 'macos';
|
|
5285
|
+
const capsulePad = mac ? MAC_DATEPICKER_CAPSULE_PAD_H : DATEPICKER_CAPSULE_PAD_H;
|
|
5286
|
+
const capsuleGap = mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP;
|
|
4694
5287
|
const capsule = (text: string | null): number =>
|
|
4695
|
-
text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 *
|
|
5288
|
+
text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * capsulePad;
|
|
4696
5289
|
const dateWidth = capsule(dateText);
|
|
4697
5290
|
const timeWidth = capsule(timeText);
|
|
4698
|
-
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ?
|
|
5291
|
+
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
|
|
4699
5292
|
const label = node.props['labelsHidden'] === true ? null : this.contentOf(node);
|
|
4700
5293
|
const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
|
|
4701
5294
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
4702
5295
|
const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
4703
5296
|
return {
|
|
4704
|
-
size: { width, height: Math.max(labelSize.height, DATEPICKER_HEIGHT) },
|
|
5297
|
+
size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
|
|
4705
5298
|
label,
|
|
4706
5299
|
labelProposal,
|
|
4707
5300
|
labelSize,
|
|
@@ -4721,7 +5314,9 @@ export class LayoutPass {
|
|
|
4721
5314
|
}
|
|
4722
5315
|
const labelY = y + (size.height - plan.labelSize.height) / 2;
|
|
4723
5316
|
if (plan.label) this.place(plan.label, plan.cctx, plan.labelProposal, x, labelY);
|
|
4724
|
-
const
|
|
5317
|
+
const mac = this.env.platform === 'macos';
|
|
5318
|
+
const capsuleHeight = mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT;
|
|
5319
|
+
const capsuleY = y + (size.height - capsuleHeight) / 2;
|
|
4725
5320
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
4726
5321
|
const chrome: Record<string, Rect> = {
|
|
4727
5322
|
...existing,
|
|
@@ -4734,12 +5329,14 @@ export class LayoutPass {
|
|
|
4734
5329
|
};
|
|
4735
5330
|
let capsuleX = x + size.width - plan.capsulesWidth;
|
|
4736
5331
|
if (plan.dateWidth > 0) {
|
|
4737
|
-
chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height:
|
|
4738
|
-
capsuleX += plan.dateWidth + DATEPICKER_CAPSULE_GAP;
|
|
5332
|
+
chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: capsuleHeight };
|
|
5333
|
+
capsuleX += plan.dateWidth + (mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP);
|
|
4739
5334
|
}
|
|
4740
5335
|
if (plan.timeWidth > 0) {
|
|
4741
|
-
chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height:
|
|
5336
|
+
chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: capsuleHeight };
|
|
4742
5337
|
}
|
|
5338
|
+
// The page draws the Mac's bordered fields (styles.css).
|
|
5339
|
+
if (mac) chrome['fields'] = { x: x + size.width - plan.capsulesWidth, y: capsuleY, width: plan.capsulesWidth, height: capsuleHeight };
|
|
4743
5340
|
this.setChrome(node, chrome);
|
|
4744
5341
|
}
|
|
4745
5342
|
|
|
@@ -4762,13 +5359,15 @@ export class LayoutPass {
|
|
|
4762
5359
|
const chrome: Record<string, Rect> = {};
|
|
4763
5360
|
const texts: Record<string, TextLayout> = {};
|
|
4764
5361
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
5362
|
+
// The alert's metrics: iOS's card, or the Mac's panel (`data-sb-panel` for the page).
|
|
5363
|
+
const m = this.env.platform === 'macos' ? MAC_ALERT_METRICS : ALERT_METRICS;
|
|
4765
5364
|
// `textfield` children (`.alert { TextField(...); Button(...) }`) go in
|
|
4766
5365
|
// the card between the message and the buttons, in their order.
|
|
4767
5366
|
const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
|
|
4768
5367
|
const buttons = node.children.filter((child) => !fields.includes(child));
|
|
4769
5368
|
const placeFields = (top: number, left: number, width: number): void => {
|
|
4770
5369
|
fields.forEach((field, i) => {
|
|
4771
|
-
const rect: Rect = { x: left, y: top + i * (
|
|
5370
|
+
const rect: Rect = { x: left, y: top + i * (m.fieldHeight + m.fieldSpacing), width, height: m.fieldHeight };
|
|
4772
5371
|
chrome[`field${i}`] = rect;
|
|
4773
5372
|
const proposal: Proposal = { width: rect.width, height: rect.height };
|
|
4774
5373
|
const fieldSize = this.measure(field, cctx, proposal);
|
|
@@ -4776,7 +5375,7 @@ export class LayoutPass {
|
|
|
4776
5375
|
this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
|
|
4777
5376
|
});
|
|
4778
5377
|
};
|
|
4779
|
-
const fieldsHeight = fields.length > 0 ? fields.length *
|
|
5378
|
+
const fieldsHeight = fields.length > 0 ? fields.length * m.fieldHeight + (fields.length - 1) * m.fieldSpacing : 0;
|
|
4780
5379
|
|
|
4781
5380
|
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
4782
5381
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
@@ -4788,62 +5387,63 @@ export class LayoutPass {
|
|
|
4788
5387
|
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
4789
5388
|
|
|
4790
5389
|
if (style === 'alert') {
|
|
4791
|
-
const cardWidth = Math.min(
|
|
4792
|
-
const textWidth = cardWidth - 2 *
|
|
5390
|
+
const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
|
|
5391
|
+
const textWidth = cardWidth - 2 * m.padH;
|
|
4793
5392
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
4794
5393
|
const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
|
|
4795
5394
|
// Heights first (the card is centered), then the rects.
|
|
4796
|
-
let h =
|
|
5395
|
+
let h = m.padTop;
|
|
4797
5396
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
4798
5397
|
let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
4799
5398
|
if (title.length > 0) {
|
|
4800
|
-
const b = block(title, titleFont, textWidth,
|
|
5399
|
+
const b = block(title, titleFont, textWidth, m.textAlignment);
|
|
4801
5400
|
titleBlock = { ...b, top: h };
|
|
4802
5401
|
h += b.height;
|
|
4803
5402
|
}
|
|
4804
5403
|
if (message.length > 0) {
|
|
4805
|
-
if (titleBlock) h +=
|
|
4806
|
-
const b = block(message, messageFont, textWidth,
|
|
5404
|
+
if (titleBlock) h += m.titleGap;
|
|
5405
|
+
const b = block(message, messageFont, textWidth, m.textAlignment);
|
|
4807
5406
|
messageBlock = { ...b, top: h };
|
|
4808
5407
|
h += b.height;
|
|
4809
5408
|
}
|
|
4810
5409
|
let fieldsTop: number | null = null;
|
|
4811
5410
|
if (fields.length > 0) {
|
|
4812
|
-
if (titleBlock || messageBlock) h +=
|
|
5411
|
+
if (titleBlock || messageBlock) h += m.fieldsGap;
|
|
4813
5412
|
fieldsTop = h;
|
|
4814
5413
|
h += fieldsHeight;
|
|
4815
5414
|
}
|
|
4816
|
-
h +=
|
|
5415
|
+
h += m.buttonsGap;
|
|
4817
5416
|
const headerHeight = h;
|
|
4818
5417
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
4819
|
-
h += rows *
|
|
5418
|
+
h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
|
|
4820
5419
|
// Centered in the safe area, as iOS does.
|
|
4821
5420
|
const { safeArea: inset } = this.env;
|
|
4822
5421
|
const top = y + (y <= 0 ? inset.top : 0);
|
|
4823
5422
|
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
4824
5423
|
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
4825
5424
|
chrome['card'] = card;
|
|
5425
|
+
if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
|
|
4826
5426
|
if (titleBlock) {
|
|
4827
|
-
chrome['title'] = { x: card.x +
|
|
5427
|
+
chrome['title'] = { x: card.x + m.padH, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
4828
5428
|
texts['title'] = titleBlock.layout;
|
|
4829
5429
|
}
|
|
4830
5430
|
if (messageBlock) {
|
|
4831
|
-
chrome['message'] = { x: card.x +
|
|
5431
|
+
chrome['message'] = { x: card.x + m.padH, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
|
|
4832
5432
|
texts['message'] = messageBlock.layout;
|
|
4833
5433
|
}
|
|
4834
5434
|
const buttonsTop = card.y + headerHeight;
|
|
4835
|
-
const buttonsX = card.x +
|
|
4836
|
-
const buttonsWidth = Math.max(0, cardWidth - 2 *
|
|
5435
|
+
const buttonsX = card.x + m.buttonInset;
|
|
5436
|
+
const buttonsWidth = Math.max(0, cardWidth - 2 * m.buttonInset);
|
|
4837
5437
|
if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
|
|
4838
5438
|
if (buttons.length === 0) {
|
|
4839
|
-
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height:
|
|
5439
|
+
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: m.buttonHeight };
|
|
4840
5440
|
} else if (buttons.length === 2) {
|
|
4841
5441
|
// A `cancel` button goes first (leading), as iOS orders them.
|
|
4842
5442
|
const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
|
|
4843
|
-
const half = (buttonsWidth -
|
|
5443
|
+
const half = (buttonsWidth - m.rowSpacing) / 2;
|
|
4844
5444
|
ordered.forEach((button, slot) => {
|
|
4845
5445
|
const i = buttons.indexOf(button as LayoutNode);
|
|
4846
|
-
const rect: Rect = { x: buttonsX + slot * (half +
|
|
5446
|
+
const rect: Rect = { x: buttonsX + slot * (half + m.rowSpacing), y: buttonsTop, width: half, height: m.buttonHeight };
|
|
4847
5447
|
chrome[`button${i}`] = rect;
|
|
4848
5448
|
this.placeAlertButton(button as LayoutNode, cctx, rect);
|
|
4849
5449
|
});
|
|
@@ -4851,9 +5451,9 @@ export class LayoutPass {
|
|
|
4851
5451
|
buttons.forEach((button, i) => {
|
|
4852
5452
|
const rect: Rect = {
|
|
4853
5453
|
x: buttonsX,
|
|
4854
|
-
y: buttonsTop + i * (
|
|
5454
|
+
y: buttonsTop + i * (m.buttonHeight + m.buttonSpacing),
|
|
4855
5455
|
width: buttonsWidth,
|
|
4856
|
-
height:
|
|
5456
|
+
height: m.buttonHeight,
|
|
4857
5457
|
};
|
|
4858
5458
|
chrome[`button${i}`] = rect;
|
|
4859
5459
|
this.placeAlertButton(button, cctx, rect);
|
|
@@ -4905,6 +5505,7 @@ export class LayoutPass {
|
|
|
4905
5505
|
}
|
|
4906
5506
|
const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
|
|
4907
5507
|
chrome['card'] = card;
|
|
5508
|
+
if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
|
|
4908
5509
|
if (titleBlock) {
|
|
4909
5510
|
chrome['title'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
4910
5511
|
texts['title'] = titleBlock.layout;
|
|
@@ -4994,6 +5595,17 @@ export class LayoutPass {
|
|
|
4994
5595
|
const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
|
|
4995
5596
|
const kind = unwrapStyled(child).kind;
|
|
4996
5597
|
if (kind === 'navstack') {
|
|
5598
|
+
// A Mac sheet: its stack draws the sheet's title bar and puts its text
|
|
5599
|
+
// actions in the bottom bar (chrome `actions`, which the page draws).
|
|
5600
|
+
if (this.env.platform === 'macos' && this.sheetIsForm(node)) {
|
|
5601
|
+
const screen = this.macSheetScreen(node);
|
|
5602
|
+
const actions: Rect | null = screen && this.macSheetActionToolbars(screen).length > 0
|
|
5603
|
+
? { x, y: y + size.height - MAC_SHEET_ACTIONS_BAR, width: size.width, height: MAC_SHEET_ACTIONS_BAR }
|
|
5604
|
+
: null;
|
|
5605
|
+
if (actions) this.setChrome(node, { ...chrome, actions });
|
|
5606
|
+
this.place(child, { ...cctx, barInset: 0, macSheet: { actions } }, { width: size.width, height: size.height }, x, y);
|
|
5607
|
+
return;
|
|
5608
|
+
}
|
|
4997
5609
|
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
4998
5610
|
return;
|
|
4999
5611
|
}
|