@swiftbrowser/web 0.26.2 → 0.26.4
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 +1083 -189
- package/src/layout/types.ts +3 -0
- package/src/layout/typography.ts +27 -6
- package/src/layoutDom.ts +96 -3
- package/src/main.ts +13 -4
- package/src/protocol.ts +15 -0
- package/src/renderer.ts +31 -6
- package/src/styles.css +848 -1
- 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,32 @@ 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 };
|
|
123
|
+
/**
|
|
124
|
+
* The Mac idiom: a row of a form in the columns style. A control whose
|
|
125
|
+
* label went to the label column (a stepper's, a date picker's, a
|
|
126
|
+
* picker's) draws without it, at the row's leading edge.
|
|
127
|
+
*/
|
|
128
|
+
formColumn?: boolean;
|
|
101
129
|
/**
|
|
102
130
|
* Phase 8: the y (screen coordinates) where content clears the bars above
|
|
103
131
|
* it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
|
|
@@ -214,6 +242,12 @@ const LIST_SECTION_GAP = 107 / 3;
|
|
|
214
242
|
// Below a section's footer the gap to the next section is 11 shorter.
|
|
215
243
|
const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
|
|
216
244
|
const LIST_CHEVRON_WIDTH = 20;
|
|
245
|
+
/**
|
|
246
|
+
* A `disclosuregroup` among a list's rows is an outline (`outlineRows`
|
|
247
|
+
* below): each level's rows start this much further in than the level
|
|
248
|
+
* above. SwiftUI's value, as yet unmeasured against the simulator.
|
|
249
|
+
*/
|
|
250
|
+
const LIST_OUTLINE_INDENT = 20;
|
|
217
251
|
// Phase 4.
|
|
218
252
|
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
|
|
219
253
|
// edge (8 when the tab view ends above the screen bottom), its items in equal
|
|
@@ -285,6 +319,141 @@ const NAV_GLASS_PAD_H = 12;
|
|
|
285
319
|
const NAV_GROUP_SPACING = 12;
|
|
286
320
|
const NAV_RUN_GAP = 12;
|
|
287
321
|
const NAV_RUN_PAD = 1;
|
|
322
|
+
// The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
|
|
323
|
+
// `macos`): the window's title bar stands in for the navigation bar. A 52
|
|
324
|
+
// unified title bar holds the back button, the bar items and the title,
|
|
325
|
+
// leading-aligned, as SwiftUI on macOS 14 lays a NavigationStack's toolbar
|
|
326
|
+
// out; a window with nothing in its bar but the title has the plain 28 one,
|
|
327
|
+
// the title centered. There are no large titles and no glass: the items
|
|
328
|
+
// are 28 tall, in slots 8 either side, 8 apart. On the screen's own window
|
|
329
|
+
// (the top of the screen, when the safe area carries a title bar) the three
|
|
330
|
+
// window buttons take the first 80 of the bar. Every number here is a
|
|
331
|
+
// first pass, to be measured against the real app (the Mac fidelity
|
|
332
|
+
// harness, stage 3).
|
|
333
|
+
const MAC_TITLE_BAR_UNIFIED = 52;
|
|
334
|
+
const MAC_TITLE_BAR_PLAIN = 28;
|
|
335
|
+
const MAC_BAR_ITEM_SIZE = 28;
|
|
336
|
+
const MAC_BAR_ITEM_PAD_H = 6;
|
|
337
|
+
const MAC_BAR_RUN_GAP = 10;
|
|
338
|
+
/** The window buttons' room: the first item's slot starts 90 in; a title with no item before it 2 further. */
|
|
339
|
+
const MAC_WINDOW_BUTTONS_ROOM = 90;
|
|
340
|
+
const MAC_TITLE_INSET = 2;
|
|
341
|
+
/** A Mac bar's trailing items end 13 before the window's edge; a tab view's toolbar's 7. */
|
|
342
|
+
const MAC_BAR_MARGIN_TRAILING = 13;
|
|
343
|
+
const MAC_TAB_TOOLBAR_MARGIN_TRAILING = 7;
|
|
344
|
+
// A Mac tab view's bar is a segmented control at the top of the content,
|
|
345
|
+
// centered in a 44 row: 24 tall, each tab a 96 segment inside a 1 inset, at
|
|
346
|
+
// most the width less 20 either side.
|
|
347
|
+
const MAC_TAB_ROW_HEIGHT = 38;
|
|
348
|
+
const MAC_TAB_BAR_HEIGHT = 22;
|
|
349
|
+
/** A tab picker's segment: its title (13 regular) and 12 either side. */
|
|
350
|
+
const MAC_TAB_ITEM_PAD_H = 12;
|
|
351
|
+
const MAC_TAB_ITEM_INSET = 1;
|
|
352
|
+
const MAC_TAB_BAR_MARGIN_H = 20;
|
|
353
|
+
// Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
|
|
354
|
+
// either side of its label, and every `automatic` button outside a list row
|
|
355
|
+
// and a bar is bordered; a checkbox is 14 square, its label 6 after it, and a
|
|
356
|
+
// toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
|
|
357
|
+
// pop-up button is 22 tall (24 in a row), its value in a bordered button
|
|
358
|
+
// with 8 either side.
|
|
359
|
+
const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
360
|
+
regular: { padV: 3, padH: 10, minHeight: 22 },
|
|
361
|
+
mini: { padV: 1, padH: 6, minHeight: 16, textStyle: 'caption' },
|
|
362
|
+
small: { padV: 2, padH: 8, minHeight: 19, textStyle: 'subheadline' },
|
|
363
|
+
large: { padV: 5, padH: 14, minHeight: 28 },
|
|
364
|
+
extraLarge: { padV: 7, padH: 18, minHeight: 34 },
|
|
365
|
+
};
|
|
366
|
+
const MAC_CHECKBOX_SIZE = 14;
|
|
367
|
+
const MAC_CHECKBOX_GAP = 6;
|
|
368
|
+
const MAC_PICKER_MENU_HEIGHT = 22;
|
|
369
|
+
// A Mac's segmented control is 22 tall with 13-point regular text, its
|
|
370
|
+
// label (which iOS drops) 10 before it.
|
|
371
|
+
const MAC_SEGMENTED_HEIGHT = 22;
|
|
372
|
+
const MAC_SEGMENTED_FONT_SIZE = 13;
|
|
373
|
+
const MAC_SEGMENTED_LABEL_GAP = 10;
|
|
374
|
+
const MAC_PICKER_MENU_ROW_HEIGHT = 24;
|
|
375
|
+
const MAC_PICKER_POPUP_PAD = 8;
|
|
376
|
+
// Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
|
|
377
|
+
// rows full width with 16 beside their content, at least 24 tall (28 in a
|
|
378
|
+
// sidebar) with 4 (6) above and below their content, a bordered button's 3;
|
|
379
|
+
// every row has a separator under it. A section's header (11 bold) starts
|
|
380
|
+
// 7 below the top, 5 into the 22 between sections after the first; a rule
|
|
381
|
+
// across the whole list sits 8 under it and the rows 10 under the rule. A
|
|
382
|
+
// footer (11) sits 8 under the last row, with a rule 5 under it. A
|
|
383
|
+
// sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
|
|
384
|
+
const MAC_LIST_ROW_MIN_HEIGHT = 24;
|
|
385
|
+
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 28;
|
|
386
|
+
const MAC_LIST_ROW_VPAD = 4;
|
|
387
|
+
const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
|
|
388
|
+
const MAC_LIST_BUTTON_ROW_VPAD = 3;
|
|
389
|
+
const MAC_LIST_CELL_PADDING = 16;
|
|
390
|
+
/** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
|
|
391
|
+
const MAC_LIST_LABEL_GAP = 6;
|
|
392
|
+
const MAC_LIST_INSET_SIDEBAR = 10;
|
|
393
|
+
const MAC_LIST_TOP = 6;
|
|
394
|
+
const MAC_LIST_BOTTOM = 8;
|
|
395
|
+
const MAC_LIST_SECTION_GAP = 22;
|
|
396
|
+
const MAC_LIST_HEADER_TOP = 4;
|
|
397
|
+
/** Under a Mac header: 8 to its rule and the rule; the first section's rows start 10 further down. */
|
|
398
|
+
const MAC_LIST_HEADER_GAP = 9;
|
|
399
|
+
const MAC_LIST_FIRST_ROWS_GAP = 10;
|
|
400
|
+
const MAC_LIST_FOOTER_GAP = 8;
|
|
401
|
+
// A Mac's Form (measured in the Mac app): the columns style, with no
|
|
402
|
+
// insets of its own and centered vertically in the room it gets. The labels
|
|
403
|
+
// (13) right-aligned in a column as wide as the widest, from the leading
|
|
404
|
+
// edge; the controls in a column 10 after it that runs to the trailing
|
|
405
|
+
// edge; a section header and a footer (13 regular, the label color) in the
|
|
406
|
+
// controls column; 8 under a header, rows 7 apart, sections touching, a
|
|
407
|
+
// header 4 under the rows before it (right under a footer), a footer 2
|
|
408
|
+
// under the last row.
|
|
409
|
+
const MAC_FORM_LABEL_GAP = 10;
|
|
410
|
+
const MAC_FORM_HEADER_GAP = 8;
|
|
411
|
+
const MAC_FORM_HEADER_TOP = 4;
|
|
412
|
+
const MAC_FORM_ROW_GAP = 7;
|
|
413
|
+
const MAC_FORM_FOOTER_GAP = 2;
|
|
414
|
+
/** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
|
|
415
|
+
const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
|
|
416
|
+
/** Under a Mac footer: 5 to its rule, the rule. */
|
|
417
|
+
const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
418
|
+
const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
|
|
419
|
+
// A Mac's search field is in the toolbar, at its trailing end: 22 tall, 180
|
|
420
|
+
// wide (less when the bar is narrow), 16 from the edge. A sheet is a
|
|
421
|
+
// window-modal panel that drops from the title bar, 480 × 400 at most,
|
|
422
|
+
// centered, with no grabber.
|
|
423
|
+
const MAC_SEARCH_FIELD_WIDTH = 180;
|
|
424
|
+
const MAC_SEARCH_FIELD_HEIGHT = 22;
|
|
425
|
+
// A Mac's sheet (measured in the Mac app): a 470 panel centered in the
|
|
426
|
+
// window, its 40 title bar (the title 13 bold at 16) over the content at its
|
|
427
|
+
// ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
428
|
+
// confirm) as 22 push buttons at its trailing end, 16 in and 8 apart; at
|
|
429
|
+
// least 40 clear of the window's edges.
|
|
430
|
+
const MAC_SHEET_WIDTH = 470;
|
|
431
|
+
const MAC_SHEET_TITLE_BAR = 40;
|
|
432
|
+
const MAC_SHEET_ACTIONS_BAR = 60;
|
|
433
|
+
const MAC_SHEET_ACTIONS_INSET = 16;
|
|
434
|
+
const MAC_SHEET_ACTIONS_GAP = 8;
|
|
435
|
+
const MAC_SHEET_MARGIN = 40;
|
|
436
|
+
// A Mac's text field is 22 tall whatever its style; a stepper's control is
|
|
437
|
+
// the 13 × 22 pair of arrows (up over down) 4 after the label; an alert is
|
|
438
|
+
// a 260 panel, its title and message centered 16 in, its buttons 22-tall
|
|
439
|
+
// push buttons 16 in, side by side 12 apart for two, stacked 8 apart otherwise.
|
|
440
|
+
const MAC_TEXTFIELD_HEIGHT = 22;
|
|
441
|
+
const MAC_STEPPER_WIDTH = 13;
|
|
442
|
+
const MAC_STEPPER_HEIGHT = 22;
|
|
443
|
+
const MAC_STEPPER_GAP = 4;
|
|
444
|
+
// A Mac's slider is a 4 track with a 20 round knob in a 20 row; a compact
|
|
445
|
+
// date picker's date and time are 22-tall bordered fields 6 apart with 6
|
|
446
|
+
// beside their text; a menu outside a bar or a list row is a pull-down
|
|
447
|
+
// button, 22 tall with 10 beside its label and the chevron's 16 after it.
|
|
448
|
+
const MAC_SLIDER_HEIGHT = 20;
|
|
449
|
+
const MAC_SLIDER_TRACK_HEIGHT = 4;
|
|
450
|
+
const MAC_SLIDER_THUMB = 20;
|
|
451
|
+
const MAC_DATEPICKER_HEIGHT = 22;
|
|
452
|
+
const MAC_DATEPICKER_CAPSULE_PAD_H = 6;
|
|
453
|
+
const MAC_DATEPICKER_CAPSULE_GAP = 6;
|
|
454
|
+
const MAC_PULLDOWN_HEIGHT = 22;
|
|
455
|
+
const MAC_PULLDOWN_PAD_H = 10;
|
|
456
|
+
const MAC_PULLDOWN_CHEVRON = 16;
|
|
288
457
|
|
|
289
458
|
/**
|
|
290
459
|
* The children an item draws in the bar: a menu or context menu draws its
|
|
@@ -309,11 +478,11 @@ function isSymbolItem(item: LayoutNode): boolean {
|
|
|
309
478
|
return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
|
|
310
479
|
}
|
|
311
480
|
|
|
312
|
-
/** A bar item's slot in its glass capsule. */
|
|
313
|
-
function navSlot(item: LayoutNode, width: number): number {
|
|
481
|
+
/** A bar item's slot in its glass capsule (`glass` square for a symbol, else its width and `pad` either side, at least `glass`). */
|
|
482
|
+
function navSlot(item: LayoutNode, width: number, glass = NAV_GLASS_SIZE, pad = NAV_GLASS_PAD_H): number {
|
|
314
483
|
if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
|
|
315
|
-
if (isSymbolItem(item) && width <=
|
|
316
|
-
return Math.max(
|
|
484
|
+
if (isSymbolItem(item) && width <= glass) return glass;
|
|
485
|
+
return Math.max(glass, width + 2 * pad);
|
|
317
486
|
}
|
|
318
487
|
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
319
488
|
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
@@ -457,7 +626,8 @@ const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
|
|
|
457
626
|
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
458
627
|
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
459
628
|
* in from the card's sides, side by side 8 apart for two, stacked 8 apart
|
|
460
|
-
* otherwise.
|
|
629
|
+
* otherwise. `ALERT_METRICS` gathers them for `placeAlert`; `MAC_ALERT_METRICS`
|
|
630
|
+
* is the Mac's panel (MAC_* above).
|
|
461
631
|
*/
|
|
462
632
|
const ALERT_WIDTH = 320;
|
|
463
633
|
const ALERT_PAD_TOP = 21;
|
|
@@ -473,6 +643,58 @@ const ALERT_BUTTON_HEIGHT = 46;
|
|
|
473
643
|
const ALERT_FIELD_HEIGHT = 36;
|
|
474
644
|
const ALERT_FIELD_SPACING = 8;
|
|
475
645
|
const ALERT_FIELDS_GAP = 16;
|
|
646
|
+
interface AlertMetrics {
|
|
647
|
+
width: number;
|
|
648
|
+
padTop: number;
|
|
649
|
+
padH: number;
|
|
650
|
+
buttonInset: number;
|
|
651
|
+
buttonsGap: number;
|
|
652
|
+
padBottom: number;
|
|
653
|
+
buttonSpacing: number;
|
|
654
|
+
rowSpacing: number;
|
|
655
|
+
titleGap: number;
|
|
656
|
+
buttonHeight: number;
|
|
657
|
+
fieldHeight: number;
|
|
658
|
+
fieldSpacing: number;
|
|
659
|
+
fieldsGap: number;
|
|
660
|
+
textAlignment: 'leading' | 'center';
|
|
661
|
+
}
|
|
662
|
+
const ALERT_METRICS: AlertMetrics = {
|
|
663
|
+
width: ALERT_WIDTH,
|
|
664
|
+
padTop: ALERT_PAD_TOP,
|
|
665
|
+
padH: ALERT_PAD_H,
|
|
666
|
+
buttonInset: ALERT_BUTTON_INSET,
|
|
667
|
+
buttonsGap: ALERT_BUTTONS_GAP,
|
|
668
|
+
padBottom: ALERT_PAD_BOTTOM,
|
|
669
|
+
buttonSpacing: ALERT_BUTTON_SPACING,
|
|
670
|
+
rowSpacing: ALERT_BUTTON_SPACING,
|
|
671
|
+
titleGap: ALERT_TITLE_GAP,
|
|
672
|
+
buttonHeight: ALERT_BUTTON_HEIGHT,
|
|
673
|
+
fieldHeight: ALERT_FIELD_HEIGHT,
|
|
674
|
+
fieldSpacing: ALERT_FIELD_SPACING,
|
|
675
|
+
fieldsGap: ALERT_FIELDS_GAP,
|
|
676
|
+
textAlignment: 'leading',
|
|
677
|
+
};
|
|
678
|
+
// Measured in the Mac app: the app icon (54, which the page draws as a
|
|
679
|
+
// placeholder) 25 under the top and 25 over the title, the message 9 under
|
|
680
|
+
// it, 28-tall buttons 17 under the message and 16 over the bottom, two
|
|
681
|
+
// side by side 8 apart.
|
|
682
|
+
const MAC_ALERT_METRICS: AlertMetrics = {
|
|
683
|
+
width: 260,
|
|
684
|
+
padTop: 104,
|
|
685
|
+
padH: 16,
|
|
686
|
+
buttonInset: 16,
|
|
687
|
+
buttonsGap: 17,
|
|
688
|
+
padBottom: 16,
|
|
689
|
+
buttonSpacing: 8,
|
|
690
|
+
rowSpacing: 8,
|
|
691
|
+
titleGap: 9,
|
|
692
|
+
buttonHeight: 28,
|
|
693
|
+
fieldHeight: 22,
|
|
694
|
+
fieldSpacing: 8,
|
|
695
|
+
fieldsGap: 12,
|
|
696
|
+
textAlignment: 'center',
|
|
697
|
+
};
|
|
476
698
|
/** Gap between an action sheet's title and its message. */
|
|
477
699
|
const DIALOG_TITLE_GAP = 4;
|
|
478
700
|
/** An action sheet's button labels are inset this much from its rows. */
|
|
@@ -742,7 +964,8 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
742
964
|
}
|
|
743
965
|
|
|
744
966
|
/** The default vertical padding above and below a list row's content. */
|
|
745
|
-
function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
967
|
+
function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
|
|
968
|
+
if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
|
|
746
969
|
switch (row.kind) {
|
|
747
970
|
case 'picker':
|
|
748
971
|
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
@@ -923,6 +1146,34 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
|
|
|
923
1146
|
return null;
|
|
924
1147
|
}
|
|
925
1148
|
|
|
1149
|
+
/** A list's row as `outlineRows` names it: the view, how deep it sits, and the `disclosuregroup` it is the label of. */
|
|
1150
|
+
interface RowSource {
|
|
1151
|
+
node: LayoutNode;
|
|
1152
|
+
depth: number;
|
|
1153
|
+
group: LayoutNode | null;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* The rows a section's children make. A `disclosuregroup` among them is an
|
|
1158
|
+
* outline, as SwiftUI lays one out in a `List`: its label (its first child)
|
|
1159
|
+
* is a row, with the group's chevron at the trailing edge, and while it is
|
|
1160
|
+
* expanded each of its content children is a row under it, a level deeper
|
|
1161
|
+
* (`LIST_OUTLINE_INDENT` further in), a nested group among them opening the
|
|
1162
|
+
* same way. Collapsed, its content is not laid out.
|
|
1163
|
+
*/
|
|
1164
|
+
function outlineRows(nodes: readonly LayoutNode[], depth: number, out: RowSource[]): RowSource[] {
|
|
1165
|
+
for (const row of nodes) {
|
|
1166
|
+
if (row.kind === 'disclosuregroup') {
|
|
1167
|
+
const [label, ...content] = row.children;
|
|
1168
|
+
if (label) out.push({ node: label, depth, group: row });
|
|
1169
|
+
if (row.props['expanded'] === true) outlineRows(content, depth + 1, out);
|
|
1170
|
+
} else {
|
|
1171
|
+
out.push({ node: row, depth, group: null });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
926
1177
|
/** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
|
|
927
1178
|
const LIST_LABEL_ICON_CENTER = 12;
|
|
928
1179
|
const LIST_LABEL_TITLE_X = 40;
|
|
@@ -1189,6 +1440,12 @@ interface ListRowPlan {
|
|
|
1189
1440
|
edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
|
|
1190
1441
|
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
1191
1442
|
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
1443
|
+
/**
|
|
1444
|
+
* An outline's row (`outlineRows`): how deep it sits, and the
|
|
1445
|
+
* `disclosuregroup` it is the label of, when it is one (the group's
|
|
1446
|
+
* chevron and toggle go over this row).
|
|
1447
|
+
*/
|
|
1448
|
+
outline?: { depth: number; group: LayoutNode | null } | null;
|
|
1192
1449
|
}
|
|
1193
1450
|
|
|
1194
1451
|
/** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
|
|
@@ -1209,12 +1466,28 @@ interface ListGroupPlan {
|
|
|
1209
1466
|
footerView: ListSupplementPlan | null;
|
|
1210
1467
|
card: Rect;
|
|
1211
1468
|
rows: ListRowPlan[];
|
|
1469
|
+
/** Each `disclosuregroup` among the rows, with the rect its rows span (its label's, then its content's while expanded). */
|
|
1470
|
+
outlines: { group: LayoutNode; frame: Rect }[];
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** A Mac form's label column entry: text the page draws (`layout`), or a view (`node`) placed at the rect. */
|
|
1474
|
+
interface FormLabelPlan {
|
|
1475
|
+
/** Relative to the list origin at scroll 0; a view's rect is its own size, at the column's trailing edge. */
|
|
1476
|
+
rect: Rect;
|
|
1477
|
+
layout: TextLayout | null;
|
|
1478
|
+
node: LayoutNode | null;
|
|
1479
|
+
ctx: Context;
|
|
1212
1480
|
}
|
|
1213
1481
|
|
|
1214
1482
|
interface ListPlan {
|
|
1215
1483
|
size: Size;
|
|
1216
1484
|
contentSize: Size;
|
|
1217
1485
|
groups: ListGroupPlan[];
|
|
1486
|
+
/** The Mac idiom's columns form: its label column's entries. */
|
|
1487
|
+
labels?: FormLabelPlan[];
|
|
1488
|
+
columns?: boolean;
|
|
1489
|
+
/** The columns form's controls column: where it starts (relative to the list origin) and its width. */
|
|
1490
|
+
column?: { x: number; width: number };
|
|
1218
1491
|
}
|
|
1219
1492
|
|
|
1220
1493
|
interface ScrollPlan {
|
|
@@ -1416,7 +1689,7 @@ export class LayoutPass {
|
|
|
1416
1689
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1417
1690
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1418
1691
|
} else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
|
|
1419
|
-
const rect = this.formSheetRect();
|
|
1692
|
+
const rect = this.formSheetRect(root, ctx);
|
|
1420
1693
|
this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
|
|
1421
1694
|
} else if (root.kind === 'sheet') {
|
|
1422
1695
|
const floats = this.sheetFloats(root);
|
|
@@ -1779,7 +2052,8 @@ export class LayoutPass {
|
|
|
1779
2052
|
const style = str(node.props['style']);
|
|
1780
2053
|
return {
|
|
1781
2054
|
width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
|
|
1782
|
-
|
|
2055
|
+
// A Mac's field is 22 tall in every style.
|
|
2056
|
+
height: this.env.platform === 'macos' ? MAC_TEXTFIELD_HEIGHT : style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
|
|
1783
2057
|
};
|
|
1784
2058
|
}
|
|
1785
2059
|
case 'shape':
|
|
@@ -1824,7 +2098,10 @@ export class LayoutPass {
|
|
|
1824
2098
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1825
2099
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1826
2100
|
case 'sheet':
|
|
1827
|
-
if (this.sheetIsForm(node))
|
|
2101
|
+
if (this.sheetIsForm(node)) {
|
|
2102
|
+
const rect = this.formSheetRect(node, ctx);
|
|
2103
|
+
return { width: p.width ?? rect.width, height: rect.height };
|
|
2104
|
+
}
|
|
1828
2105
|
return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
|
|
1829
2106
|
// Phase 8 controls.
|
|
1830
2107
|
case 'alert':
|
|
@@ -1859,12 +2136,19 @@ export class LayoutPass {
|
|
|
1859
2136
|
}
|
|
1860
2137
|
case 'menu': {
|
|
1861
2138
|
// Phase 6: the label (the first child) is the only laid-out child.
|
|
2139
|
+
// The Mac idiom: outside a bar and a list row the menu is a pull-down
|
|
2140
|
+
// button around its label.
|
|
1862
2141
|
const label = node.children[0];
|
|
2142
|
+
if (this.menuIsPullDown(ctx)) {
|
|
2143
|
+
const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
|
|
2144
|
+
const labelSize = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
|
|
2145
|
+
return { width: labelSize.width + 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON, height: Math.max(MAC_PULLDOWN_HEIGHT, labelSize.height) };
|
|
2146
|
+
}
|
|
1863
2147
|
return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1864
2148
|
}
|
|
1865
2149
|
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 };
|
|
2150
|
+
// Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field (22 on a Mac).
|
|
2151
|
+
return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: this.env.platform === 'macos' ? MAC_SEARCH_FIELD_HEIGHT : SEARCH_FIELD_HEIGHT };
|
|
1868
2152
|
case 'swipeactions':
|
|
1869
2153
|
case 'contextmenu': {
|
|
1870
2154
|
// Phase 8: the content (the first child) alone; the actions / items
|
|
@@ -2051,14 +2335,17 @@ export class LayoutPass {
|
|
|
2051
2335
|
rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
|
|
2052
2336
|
}
|
|
2053
2337
|
|
|
2054
|
-
private buttonIsBordered(node: LayoutNode): boolean {
|
|
2338
|
+
private buttonIsBordered(node: LayoutNode, ctx: Context): boolean {
|
|
2055
2339
|
const style = str(node.props['style']);
|
|
2056
|
-
|
|
2340
|
+
if (style === 'bordered' || style === 'borderedProminent' || style === 'glass') return true;
|
|
2341
|
+
// The Mac idiom: a button of the automatic style is a push button too
|
|
2342
|
+
// (in a list row as well), except as a bar item's content.
|
|
2343
|
+
return this.env.platform === 'macos' && (style === null || style === 'automatic') && !ctx.navBarItem && !ctx.bottomBarItem;
|
|
2057
2344
|
}
|
|
2058
2345
|
|
|
2059
2346
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
2060
2347
|
const label = this.contentOf(node);
|
|
2061
|
-
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
2348
|
+
if (!this.buttonIsBordered(node, ctx)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
2062
2349
|
const cctx = this.borderedLabelContext(node, ctx);
|
|
2063
2350
|
if (ctx.bottomBarItem) {
|
|
2064
2351
|
// In a bottom bar (measured in the iOS 26.2 simulator, Native
|
|
@@ -2081,7 +2368,9 @@ export class LayoutPass {
|
|
|
2081
2368
|
}
|
|
2082
2369
|
|
|
2083
2370
|
private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
|
|
2084
|
-
|
|
2371
|
+
const controlSize = str(node.props['controlSize']) ?? 'regular';
|
|
2372
|
+
if (this.env.platform === 'macos') return MAC_BORDERED_SIZES[controlSize] ?? (MAC_BORDERED_SIZES['regular'] as { padV: number; padH: number; minHeight: number });
|
|
2373
|
+
return BORDERED_SIZES[controlSize] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
|
|
2085
2374
|
}
|
|
2086
2375
|
|
|
2087
2376
|
/** A bordered button's label context: a small or mini one sets a smaller text style. */
|
|
@@ -2091,8 +2380,20 @@ export class LayoutPass {
|
|
|
2091
2380
|
return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
|
|
2092
2381
|
}
|
|
2093
2382
|
|
|
2094
|
-
|
|
2095
|
-
|
|
2383
|
+
/**
|
|
2384
|
+
* A toggle's control: iOS's switch at the trailing edge; on a Mac a
|
|
2385
|
+
* checkbox before the label, or the small switch in a list row (a grouped
|
|
2386
|
+
* form's), at the trailing edge.
|
|
2387
|
+
*/
|
|
2388
|
+
private switchMetrics(_ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
|
|
2389
|
+
if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
|
|
2390
|
+
// A Mac's toggle is a checkbox before its label, in a list row too.
|
|
2391
|
+
return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
private toggleLabelProposal(ctx: Context, p: Proposal): Proposal {
|
|
2395
|
+
const control = this.switchMetrics(ctx);
|
|
2396
|
+
return { width: shrink(p.width, control.width + control.gap), height: p.height };
|
|
2096
2397
|
}
|
|
2097
2398
|
|
|
2098
2399
|
private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
@@ -2107,21 +2408,24 @@ export class LayoutPass {
|
|
|
2107
2408
|
return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
|
|
2108
2409
|
}
|
|
2109
2410
|
// sized as a bordered button, on or off
|
|
2110
|
-
const
|
|
2411
|
+
const m = this.borderedMetrics(node);
|
|
2412
|
+
const inner: Proposal = { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
|
|
2111
2413
|
const size = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
|
|
2112
|
-
return { width: size.width + 2 *
|
|
2414
|
+
return { width: size.width + 2 * m.padH, height: Math.max(m.minHeight, size.height + 2 * m.padV) };
|
|
2113
2415
|
}
|
|
2416
|
+
const control = this.switchMetrics(ctx);
|
|
2114
2417
|
const labelSize = label
|
|
2115
|
-
? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
|
|
2418
|
+
? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(ctx, p))
|
|
2116
2419
|
: ZERO;
|
|
2117
2420
|
return {
|
|
2118
|
-
width: finite(p.width) ? p.width : labelSize.width +
|
|
2119
|
-
height: Math.max(labelSize.height,
|
|
2421
|
+
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
2422
|
+
height: Math.max(labelSize.height, control.height),
|
|
2120
2423
|
};
|
|
2121
2424
|
}
|
|
2122
2425
|
|
|
2123
2426
|
private navLinkLabelProposal(ctx: Context, p: Proposal): Proposal {
|
|
2124
|
-
|
|
2427
|
+
// A Mac's rows have no chevron.
|
|
2428
|
+
return ctx.listRow && this.env.platform !== 'macos' ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
|
|
2125
2429
|
}
|
|
2126
2430
|
|
|
2127
2431
|
private measureNavLink(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
@@ -2155,14 +2459,23 @@ export class LayoutPass {
|
|
|
2155
2459
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
2156
2460
|
}
|
|
2157
2461
|
|
|
2158
|
-
/** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
|
|
2462
|
+
/** 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
2463
|
private sheetIsForm(node: LayoutNode): boolean {
|
|
2160
|
-
return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
|
|
2464
|
+
return (this.env.screen.width >= REGULAR_WIDTH || this.env.platform === 'macos') && !this.sheetIsFull(node);
|
|
2161
2465
|
}
|
|
2162
2466
|
|
|
2163
|
-
/**
|
|
2164
|
-
|
|
2467
|
+
/**
|
|
2468
|
+
* The form sheet's card: UIKit's size, clamped to the safe area less a
|
|
2469
|
+
* margin, centered in it. On a Mac the panel is 470 wide, as tall as its
|
|
2470
|
+
* bars and its content's ideal height, centered in the window.
|
|
2471
|
+
*/
|
|
2472
|
+
private formSheetRect(node: LayoutNode, ctx: Context): Rect {
|
|
2165
2473
|
const { screen, safeArea } = this.env;
|
|
2474
|
+
if (this.env.platform === 'macos') {
|
|
2475
|
+
const width = Math.max(0, Math.min(MAC_SHEET_WIDTH, screen.width - 2 * MAC_SHEET_MARGIN));
|
|
2476
|
+
const height = Math.max(0, Math.min(this.macSheetHeight(node, ctx, width), screen.height - 2 * MAC_SHEET_MARGIN));
|
|
2477
|
+
return { x: Math.round((screen.width - width) / 2), y: Math.round((screen.height - height) / 2), width, height };
|
|
2478
|
+
}
|
|
2166
2479
|
const top = safeArea.top + FORM_SHEET_MARGIN;
|
|
2167
2480
|
const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
|
|
2168
2481
|
const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
|
|
@@ -2170,6 +2483,34 @@ export class LayoutPass {
|
|
|
2170
2483
|
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
2171
2484
|
}
|
|
2172
2485
|
|
|
2486
|
+
/** 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). */
|
|
2487
|
+
private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
|
|
2488
|
+
return this.navScreenParts(screen).toolbars.filter((toolbar) => {
|
|
2489
|
+
const placement = str(toolbar.props['placement']);
|
|
2490
|
+
if ((placement !== 'leading' && placement !== 'trailing') || str(toolbar.props['spacer']) !== null) return false;
|
|
2491
|
+
return toolbar.children.length > 0 && toolbar.children.every((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button');
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/** A Mac sheet's root screen, when its content is a navigation stack. */
|
|
2496
|
+
private macSheetScreen(node: LayoutNode): LayoutNode | null {
|
|
2497
|
+
const child = this.contentOf(node);
|
|
2498
|
+
if (!child) return null;
|
|
2499
|
+
const stack = unwrapStyled(child);
|
|
2500
|
+
if (stack.kind !== 'navstack') return null;
|
|
2501
|
+
return stack.children[stack.children.length - 1] ?? null;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/** 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. */
|
|
2505
|
+
private macSheetHeight(node: LayoutNode, ctx: Context, width: number): number {
|
|
2506
|
+
const screen = this.macSheetScreen(node);
|
|
2507
|
+
const child = this.contentOf(node);
|
|
2508
|
+
if (!screen) return child ? this.measure(child, this.childContext(node, ctx), { width, height: null }).height : 0;
|
|
2509
|
+
const content = this.navScreenParts(screen).content;
|
|
2510
|
+
const ideal = content ? this.measure(content, this.childContext(screen, this.childContext(node, ctx)), { width, height: null }).height : 0;
|
|
2511
|
+
return MAC_SHEET_TITLE_BAR + ideal + (this.macSheetActionToolbars(screen).length > 0 ? MAC_SHEET_ACTIONS_BAR : 0);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2173
2514
|
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
2174
2515
|
private sheetFloats(node: LayoutNode): boolean {
|
|
2175
2516
|
if (this.sheetIsForm(node)) return false;
|
|
@@ -2239,29 +2580,37 @@ export class LayoutPass {
|
|
|
2239
2580
|
};
|
|
2240
2581
|
}
|
|
2241
2582
|
if (style === 'segmented') {
|
|
2242
|
-
const
|
|
2583
|
+
const mac = this.env.platform === 'macos';
|
|
2584
|
+
const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2243
2585
|
let natural = 0;
|
|
2244
2586
|
for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
|
|
2587
|
+
// A Mac shows the picker's label before the control (in a form's row it is in the label column).
|
|
2588
|
+
const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
|
|
2589
|
+
const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
|
|
2245
2590
|
return {
|
|
2246
|
-
size: { width: finite(p.width) ? p.width : natural, height: PICKER_SEGMENTED_HEIGHT },
|
|
2591
|
+
size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
|
|
2247
2592
|
style: 'segmented',
|
|
2248
2593
|
font,
|
|
2249
2594
|
options,
|
|
2250
2595
|
selected,
|
|
2251
|
-
labelWidth
|
|
2596
|
+
labelWidth,
|
|
2252
2597
|
valueWidth: 0,
|
|
2253
2598
|
labelRow: false,
|
|
2254
2599
|
};
|
|
2255
2600
|
}
|
|
2256
2601
|
const font = ctx.font;
|
|
2257
|
-
|
|
2602
|
+
// In a Mac form's row the label is in the label column and the pop-up spans the controls column.
|
|
2603
|
+
const labelWidth = ctx.formColumn ? 0 : this.measurer.measureWidth(str(node.props['label']) ?? '', font);
|
|
2258
2604
|
const value = options[selected] ?? '';
|
|
2259
|
-
const
|
|
2605
|
+
const mac = this.env.platform === 'macos';
|
|
2606
|
+
// The Mac's pop-up button holds the value and the chevrons in a bordered button.
|
|
2607
|
+
const popup = mac && (!ctx.listRow || ctx.formColumn === true);
|
|
2608
|
+
const valueWidth = ctx.formColumn && finite(p.width) ? p.width : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
|
|
2260
2609
|
const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
|
|
2261
2610
|
return {
|
|
2262
2611
|
size: {
|
|
2263
2612
|
width: finite(p.width) ? p.width : natural,
|
|
2264
|
-
height: ctx.listRow ? PICKER_MENU_ROW_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2613
|
+
height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2265
2614
|
},
|
|
2266
2615
|
style: 'menu',
|
|
2267
2616
|
font,
|
|
@@ -2286,9 +2635,19 @@ export class LayoutPass {
|
|
|
2286
2635
|
const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
|
|
2287
2636
|
if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
|
|
2288
2637
|
else delete chrome['bar'];
|
|
2289
|
-
|
|
2638
|
+
// The Mac's label before the control (`label`, `track` the control's own frame).
|
|
2639
|
+
const labelWidth = Math.min(plan.labelWidth, size.width);
|
|
2640
|
+
if (labelWidth > 0) {
|
|
2641
|
+
chrome['label'] = { x, y, width: labelWidth - MAC_SEGMENTED_LABEL_GAP, height: size.height };
|
|
2642
|
+
chrome['track'] = { x: x + labelWidth, y, width: Math.max(0, size.width - labelWidth), height: size.height };
|
|
2643
|
+
} else {
|
|
2644
|
+
delete chrome['label'];
|
|
2645
|
+
delete chrome['track'];
|
|
2646
|
+
}
|
|
2647
|
+
const trackX = x + labelWidth;
|
|
2648
|
+
const segmentWidth = Math.max(0, size.width - labelWidth - 2 * inset) / n;
|
|
2290
2649
|
for (let i = 0; i < plan.options.length; i++) {
|
|
2291
|
-
chrome[`segment${i}`] = { x:
|
|
2650
|
+
chrome[`segment${i}`] = { x: trackX + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
|
|
2292
2651
|
}
|
|
2293
2652
|
} else if (plan.style === 'inline') {
|
|
2294
2653
|
// Rows span the list row when there is one (the hit area and the
|
|
@@ -2312,7 +2671,8 @@ export class LayoutPass {
|
|
|
2312
2671
|
const room = Math.max(0, size.width - PICKER_MENU_GAP);
|
|
2313
2672
|
let labelWidth = plan.labelWidth;
|
|
2314
2673
|
let valueWidth = plan.valueWidth;
|
|
2315
|
-
|
|
2674
|
+
// In a Mac form's controls column the pop-up spans the column (its label is in the label column).
|
|
2675
|
+
if (!ctx.formColumn && labelWidth + valueWidth > room) {
|
|
2316
2676
|
if (labelWidth <= valueWidth) {
|
|
2317
2677
|
labelWidth = Math.min(labelWidth, room / 2);
|
|
2318
2678
|
valueWidth = room - labelWidth;
|
|
@@ -2324,6 +2684,9 @@ export class LayoutPass {
|
|
|
2324
2684
|
valueWidth = Math.min(valueWidth, size.width);
|
|
2325
2685
|
chrome['label'] = { x, y, width: labelWidth, height: size.height };
|
|
2326
2686
|
chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
|
|
2687
|
+
// The page draws the Mac's pop-up button around the value (the engine padded it).
|
|
2688
|
+
if (this.env.platform === 'macos' && (!ctx.listRow || ctx.formColumn === true)) chrome['popup'] = chrome['value'];
|
|
2689
|
+
else delete chrome['popup'];
|
|
2327
2690
|
}
|
|
2328
2691
|
this.setChrome(node, chrome);
|
|
2329
2692
|
if (node.id >= 0) this.fonts.set(node.id, plan.font);
|
|
@@ -2365,6 +2728,8 @@ export class LayoutPass {
|
|
|
2365
2728
|
if (n < 2) return [];
|
|
2366
2729
|
// A disclosure group's content sits 8 under its label, as SwiftUI's does.
|
|
2367
2730
|
const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
|
|
2731
|
+
// A Mac list row's Label (no icon column there): the title 6 after the icon.
|
|
2732
|
+
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
|
|
2368
2733
|
if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
|
|
2369
2734
|
const gaps: number[] = [];
|
|
2370
2735
|
for (let i = 0; i < n - 1; i++) {
|
|
@@ -2629,10 +2994,13 @@ export class LayoutPass {
|
|
|
2629
2994
|
return { width: p.width ?? width, height: p.height ?? height };
|
|
2630
2995
|
}
|
|
2631
2996
|
const name = str(node.props['name']);
|
|
2632
|
-
|
|
2997
|
+
// A symbol takes a square of the font's leading; in a Mac list row, of
|
|
2998
|
+
// its point size (13 in a 13-point row, measured in the Mac app).
|
|
2999
|
+
const symbolSide = (ctx.listCell === true ? ctx.font.size : ctx.font.leading) * ctx.imageScale;
|
|
3000
|
+
if (name === null) return square(symbolSide);
|
|
2633
3001
|
const asset = this.env.assets?.images[name];
|
|
2634
3002
|
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
2635
|
-
if (asset.kind === 'symbol') return square(
|
|
3003
|
+
if (asset.kind === 'symbol') return square(symbolSide);
|
|
2636
3004
|
const width = num(asset.width) ?? 0;
|
|
2637
3005
|
const height = num(asset.height) ?? 0;
|
|
2638
3006
|
if (node.props['resizable'] !== true) return { width, height };
|
|
@@ -2830,6 +3198,7 @@ export class LayoutPass {
|
|
|
2830
3198
|
|
|
2831
3199
|
private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
2832
3200
|
return this.plan(node, ctx, p, () => {
|
|
3201
|
+
if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
|
|
2833
3202
|
const { screen } = this.env;
|
|
2834
3203
|
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
2835
3204
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
@@ -2842,28 +3211,43 @@ export class LayoutPass {
|
|
|
2842
3211
|
// (cards, chevrons, 52pt rows) and only its sections' folding remains.
|
|
2843
3212
|
const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
|
|
2844
3213
|
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
|
|
2845
|
-
|
|
2846
|
-
|
|
3214
|
+
// The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
|
|
3215
|
+
// 11-point bold headers, a sidebar's rows inset 10 and a grouped
|
|
3216
|
+
// form's cards 20.
|
|
3217
|
+
const mac = this.env.platform === 'macos';
|
|
3218
|
+
const minRowHeight =
|
|
3219
|
+
num(node.props['minRowHeight']) ??
|
|
3220
|
+
(sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
3221
|
+
// A Mac's lists have no cards: the rows run the whole width (a
|
|
3222
|
+
// sidebar's inset 10).
|
|
3223
|
+
const inset = mac
|
|
3224
|
+
? listStyle === 'sidebar'
|
|
3225
|
+
? MAC_LIST_INSET_SIDEBAR
|
|
3226
|
+
: 0
|
|
3227
|
+
: listStyle === 'insetGrouped' || listStyle === 'sidebar'
|
|
3228
|
+
? LIST_INSET
|
|
3229
|
+
: 0;
|
|
2847
3230
|
const plain = listStyle === 'plain';
|
|
2848
|
-
const
|
|
2849
|
-
const
|
|
2850
|
-
const
|
|
3231
|
+
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
3232
|
+
const listTop = plain ? 0 : mac ? MAC_LIST_TOP : LIST_TOP;
|
|
3233
|
+
const listBottom = plain ? 0 : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
|
|
3234
|
+
const sectionGap = plain ? 0 : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
|
|
3235
|
+
const headerGap = mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
|
|
2851
3236
|
const cardX = inset;
|
|
2852
3237
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
2853
|
-
const contentX = inset +
|
|
2854
|
-
const contentWidth = Math.max(0, layoutWidth - 2 * (inset +
|
|
3238
|
+
const contentX = inset + cellPadding;
|
|
3239
|
+
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
|
|
2855
3240
|
// 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
|
-
);
|
|
3241
|
+
const headerFont = mac
|
|
3242
|
+
? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
|
|
3243
|
+
: makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
|
|
2861
3244
|
const footerFont = makeFont(
|
|
2862
3245
|
'system',
|
|
2863
|
-
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
3246
|
+
textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform),
|
|
2864
3247
|
400,
|
|
2865
3248
|
);
|
|
2866
|
-
|
|
3249
|
+
// A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
|
|
3250
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: !mac, listCell: mac, sidebarList: str(node.props['style']) === 'sidebar' };
|
|
2867
3251
|
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
2868
3252
|
// leading control (6 in from the card's edge) and its content shifts
|
|
2869
3253
|
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
@@ -2871,20 +3255,20 @@ export class LayoutPass {
|
|
|
2871
3255
|
|
|
2872
3256
|
// Group consecutive plain rows into an implicit section. A section's
|
|
2873
3257
|
// view header and footer are not rows.
|
|
2874
|
-
type GroupSource = { section: LayoutNode | null; rows:
|
|
3258
|
+
type GroupSource = { section: LayoutNode | null; rows: RowSource[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
2875
3259
|
const groupsSource: GroupSource[] = [];
|
|
2876
3260
|
for (const child of node.children) {
|
|
2877
3261
|
if (child.kind === 'section') {
|
|
2878
3262
|
// `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
3263
|
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');
|
|
3264
|
+
const rows = collapsed ? [] : outlineRows(child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter'), 0, []);
|
|
2881
3265
|
const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
|
|
2882
3266
|
const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
|
|
2883
3267
|
groupsSource.push({ section: child, rows, headerView, footerView });
|
|
2884
3268
|
} else {
|
|
2885
3269
|
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 });
|
|
3270
|
+
if (last && last.section === null) outlineRows([child], 0, last.rows);
|
|
3271
|
+
else groupsSource.push({ section: null, rows: outlineRows([child], 0, []), headerView: null, footerView: null });
|
|
2888
3272
|
}
|
|
2889
3273
|
}
|
|
2890
3274
|
// View headers and footers: the content width, the header's and the
|
|
@@ -2913,27 +3297,29 @@ export class LayoutPass {
|
|
|
2913
3297
|
const previous = groups[g - 1];
|
|
2914
3298
|
if (previous) {
|
|
2915
3299
|
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
2916
|
-
y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
3300
|
+
y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
2917
3301
|
}
|
|
2918
3302
|
const top = y;
|
|
2919
3303
|
let header: ListGroupPlan['header'] = null;
|
|
2920
3304
|
let footer: ListGroupPlan['footer'] = null;
|
|
2921
3305
|
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2922
3306
|
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2923
|
-
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
3307
|
+
const headerLift = plain || mac ? 0 : LIST_HEADER_LIFT;
|
|
2924
3308
|
const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
|
|
2925
|
-
if (headerView && headerView.rect.height > 0) y += headerView.rect.height +
|
|
3309
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + headerGap - headerLift;
|
|
2926
3310
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
2927
3311
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
2928
3312
|
if (headerText !== null && headerText.length > 0) {
|
|
2929
3313
|
const { layout, size } = makeText(headerText, headerFont);
|
|
3314
|
+
if (mac && previous) y += MAC_LIST_HEADER_TOP;
|
|
2930
3315
|
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
2931
3316
|
header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
2932
|
-
y = headerY + size.height +
|
|
3317
|
+
y = headerY + size.height + headerGap + (mac && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
|
|
2933
3318
|
}
|
|
2934
3319
|
const cardTop = y;
|
|
2935
3320
|
const rows: ListRowPlan[] = [];
|
|
2936
|
-
for (const
|
|
3321
|
+
for (const rowSource of source.rows) {
|
|
3322
|
+
const rowNode = rowSource.node;
|
|
2937
3323
|
if (rowNode.kind === 'lazyspacer') {
|
|
2938
3324
|
// Lazy rows not built yet: a row of no height, without a cell.
|
|
2939
3325
|
const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
|
|
@@ -2946,13 +3332,19 @@ export class LayoutPass {
|
|
|
2946
3332
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2947
3333
|
// width (plus a gap) at the trailing edge.
|
|
2948
3334
|
const hints = rowHintsOf(rowNode);
|
|
2949
|
-
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
3335
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
|
|
3336
|
+
// An outline's rows start LIST_OUTLINE_INDENT further in per level.
|
|
3337
|
+
const indent = rowSource.depth * LIST_OUTLINE_INDENT;
|
|
3338
|
+
const insets: EdgeInsets = hints.insets
|
|
3339
|
+
? { ...hints.insets, leading: hints.insets.leading + indent }
|
|
3340
|
+
: {
|
|
3341
|
+
top: defaultVPad,
|
|
3342
|
+
leading: cellPadding + indent,
|
|
3343
|
+
bottom: defaultVPad,
|
|
3344
|
+
trailing: cellPadding,
|
|
3345
|
+
};
|
|
3346
|
+
// 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).
|
|
3347
|
+
const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
|
|
2956
3348
|
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
|
|
2957
3349
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
2958
3350
|
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
@@ -2964,7 +3356,7 @@ export class LayoutPass {
|
|
|
2964
3356
|
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2965
3357
|
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
2966
3358
|
const rowContentX = cardX + insets.leading + editShift;
|
|
2967
|
-
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
|
|
3359
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve);
|
|
2968
3360
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
2969
3361
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
2970
3362
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -2997,15 +3389,27 @@ export class LayoutPass {
|
|
|
2997
3389
|
}
|
|
2998
3390
|
: null,
|
|
2999
3391
|
separatorHidden: hints.separatorHidden,
|
|
3392
|
+
outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
|
|
3000
3393
|
});
|
|
3001
3394
|
y += rowHeight;
|
|
3002
3395
|
}
|
|
3396
|
+
// 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.
|
|
3397
|
+
const outlines: ListGroupPlan['outlines'] = [];
|
|
3398
|
+
rows.forEach((row, i) => {
|
|
3399
|
+
const group = row.outline?.group;
|
|
3400
|
+
if (!group) return;
|
|
3401
|
+
const depth = row.outline?.depth ?? 0;
|
|
3402
|
+
let end = i;
|
|
3403
|
+
while (end + 1 < rows.length && (rows[end + 1]?.outline?.depth ?? 0) > depth) end += 1;
|
|
3404
|
+
const last = (rows[end] as ListRowPlan).row;
|
|
3405
|
+
outlines.push({ group, frame: { x: row.row.x, y: row.row.y, width: row.row.width, height: last.y + last.height - row.row.y } });
|
|
3406
|
+
});
|
|
3003
3407
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
3004
3408
|
if (footerText !== null && footerText.length > 0) {
|
|
3005
|
-
y += LIST_FOOTER_GAP;
|
|
3409
|
+
y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3006
3410
|
const { layout, size } = makeText(footerText, footerFont);
|
|
3007
3411
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
3008
|
-
y += size.height;
|
|
3412
|
+
y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3009
3413
|
}
|
|
3010
3414
|
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
3011
3415
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
@@ -3019,6 +3423,7 @@ export class LayoutPass {
|
|
|
3019
3423
|
footerView,
|
|
3020
3424
|
card,
|
|
3021
3425
|
rows,
|
|
3426
|
+
outlines,
|
|
3022
3427
|
});
|
|
3023
3428
|
}
|
|
3024
3429
|
const contentHeight = groupsSource.length === 0 ? listTop : y + listBottom;
|
|
@@ -3177,7 +3582,7 @@ export class LayoutPass {
|
|
|
3177
3582
|
case 'button': {
|
|
3178
3583
|
const label = this.contentOf(node);
|
|
3179
3584
|
if (!label) return;
|
|
3180
|
-
if (!this.buttonIsBordered(node)) {
|
|
3585
|
+
if (!this.buttonIsBordered(node, ctx)) {
|
|
3181
3586
|
this.place(label, cctx, p, x, y);
|
|
3182
3587
|
return;
|
|
3183
3588
|
}
|
|
@@ -3195,6 +3600,8 @@ export class LayoutPass {
|
|
|
3195
3600
|
this.setChrome(node, {
|
|
3196
3601
|
...existing,
|
|
3197
3602
|
label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
|
|
3603
|
+
// The page draws the border on an automatic button the engine bordered (the Mac idiom).
|
|
3604
|
+
bordered: { x, y, width: size.width, height: size.height },
|
|
3198
3605
|
});
|
|
3199
3606
|
return;
|
|
3200
3607
|
}
|
|
@@ -3216,9 +3623,10 @@ export class LayoutPass {
|
|
|
3216
3623
|
if (isButtonToggle(node)) {
|
|
3217
3624
|
// the label centered in the button (its fill, while on, the toggle's frame)
|
|
3218
3625
|
if (!label) return;
|
|
3626
|
+
const m = this.borderedMetrics(node);
|
|
3219
3627
|
const inner: Proposal = ctx.bottomBarItem
|
|
3220
3628
|
? { width: null, height: null }
|
|
3221
|
-
: { width: shrink(p.width, 2 *
|
|
3629
|
+
: { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
|
|
3222
3630
|
const labelSize = this.measure(label, cctx, inner);
|
|
3223
3631
|
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
3224
3632
|
this.place(label, cctx, inner, x + dx, y + dy);
|
|
@@ -3226,25 +3634,31 @@ export class LayoutPass {
|
|
|
3226
3634
|
this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
|
|
3227
3635
|
return;
|
|
3228
3636
|
}
|
|
3229
|
-
const
|
|
3637
|
+
const control = this.switchMetrics(ctx);
|
|
3638
|
+
const labelProposal = this.toggleLabelProposal(ctx, p);
|
|
3230
3639
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
3231
3640
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
3232
|
-
|
|
3641
|
+
// A checkbox leads its label; a switch trails it.
|
|
3642
|
+
const labelX = control.checkbox ? x + control.width + control.gap : x;
|
|
3643
|
+
if (label) this.place(label, cctx, labelProposal, labelX, labelY);
|
|
3233
3644
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3645
|
+
const switchRect: Rect = {
|
|
3646
|
+
x: control.checkbox ? x : x + size.width - control.width,
|
|
3647
|
+
y: y + (size.height - control.height) / 2,
|
|
3648
|
+
width: control.width,
|
|
3649
|
+
height: control.height,
|
|
3650
|
+
};
|
|
3234
3651
|
this.setChrome(node, {
|
|
3235
3652
|
...existing,
|
|
3236
3653
|
label: {
|
|
3237
|
-
x,
|
|
3654
|
+
x: labelX,
|
|
3238
3655
|
y: labelY,
|
|
3239
|
-
width: Math.min(labelSize.width, Math.max(0, size.width -
|
|
3656
|
+
width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
|
|
3240
3657
|
height: labelSize.height,
|
|
3241
3658
|
},
|
|
3242
|
-
switch:
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
width: SWITCH_WIDTH,
|
|
3246
|
-
height: SWITCH_HEIGHT,
|
|
3247
|
-
},
|
|
3659
|
+
switch: switchRect,
|
|
3660
|
+
// The page draws a checkbox at it (the Mac idiom).
|
|
3661
|
+
...(control.checkbox ? { checkbox: switchRect } : {}),
|
|
3248
3662
|
});
|
|
3249
3663
|
return;
|
|
3250
3664
|
}
|
|
@@ -3263,8 +3677,8 @@ export class LayoutPass {
|
|
|
3263
3677
|
// Phase 7: the selected row of a `navsplit` sidebar has no chevron
|
|
3264
3678
|
// (its label still reserves the chevron's width so selecting a row
|
|
3265
3679
|
// 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))) {
|
|
3680
|
+
// of a `sidebar`-style list has one (iPadOS), nor any row on a Mac.
|
|
3681
|
+
if (this.env.platform !== 'macos' && !(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
|
|
3268
3682
|
chrome['chevron'] = {
|
|
3269
3683
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
3270
3684
|
y,
|
|
@@ -3425,7 +3839,16 @@ export class LayoutPass {
|
|
|
3425
3839
|
// Phase 6: the label is placed; the items are only shown in the
|
|
3426
3840
|
// popover the renderer opens, so they are hidden and have no frames.
|
|
3427
3841
|
const [label, ...items] = node.children;
|
|
3428
|
-
if (
|
|
3842
|
+
if (this.menuIsPullDown(ctx)) {
|
|
3843
|
+
// The Mac's pull-down button: the label 10 in, centered in the 22 row, the chevron after it.
|
|
3844
|
+
const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
|
|
3845
|
+
const labelSize = label ? this.measure(label, cctx, inner) : ZERO;
|
|
3846
|
+
if (label) this.place(label, cctx, inner, x + MAC_PULLDOWN_PAD_H, y + (size.height - labelSize.height) / 2);
|
|
3847
|
+
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3848
|
+
this.setChrome(node, { ...existing, pulldown: { x, y, width: size.width, height: size.height } });
|
|
3849
|
+
} else if (label) {
|
|
3850
|
+
this.place(label, cctx, p, x, y);
|
|
3851
|
+
}
|
|
3429
3852
|
for (const item of items) this.hide(item, node);
|
|
3430
3853
|
return;
|
|
3431
3854
|
}
|
|
@@ -3530,22 +3953,226 @@ export class LayoutPass {
|
|
|
3530
3953
|
return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
|
|
3531
3954
|
}
|
|
3532
3955
|
|
|
3956
|
+
/** The labeled control a form row's stack is shifted to (the form's alignment guide), if it has one. */
|
|
3957
|
+
private macFormGuide(row: LayoutNode): LayoutNode | null {
|
|
3958
|
+
const inner = unwrapStyled(row);
|
|
3959
|
+
if (inner.kind !== 'hstack') return null;
|
|
3960
|
+
return inner.children.find((child) => MAC_FORM_LABELED_KINDS.includes(unwrapStyled(child).kind)) ?? null;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/**
|
|
3964
|
+
* The Mac idiom's `Form` (MAC_FORM_* above, docs/notes/macos.md): the
|
|
3965
|
+
* columns style. A row whose control carries a label (a text field's
|
|
3966
|
+
* title, a picker's, a stepper's, a date picker's, a slider's) puts it
|
|
3967
|
+
* in the label column, right-aligned, and the control in the controls
|
|
3968
|
+
* column at the control's own height; a toggle, a button, text and other
|
|
3969
|
+
* rows go in the controls column whole. A stack row with such a control
|
|
3970
|
+
* in it is shifted so the control starts at the controls column, what is
|
|
3971
|
+
* before it reaching into the label column, as the form's alignment guide
|
|
3972
|
+
* does on a Mac. Headers and footers are 13-point text in the controls
|
|
3973
|
+
* column. No cards, no separators.
|
|
3974
|
+
*/
|
|
3975
|
+
private macFormPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
3976
|
+
const { screen } = this.env;
|
|
3977
|
+
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
3978
|
+
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
3979
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false };
|
|
3980
|
+
const font = rowCtx.font;
|
|
3981
|
+
const textFont = makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 400);
|
|
3982
|
+
type Row = { node: LayoutNode; label: string | null; labelNode: LayoutNode | null; guide: LayoutNode | null };
|
|
3983
|
+
type Source = { section: LayoutNode | null; rows: Row[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
3984
|
+
// The rows, each with what goes in the label column.
|
|
3985
|
+
const classify = (row: LayoutNode): Row => {
|
|
3986
|
+
const inner = unwrapStyled(row);
|
|
3987
|
+
switch (inner.kind) {
|
|
3988
|
+
case 'textfield':
|
|
3989
|
+
return { node: row, label: str(inner.props['placeholder']) || null, labelNode: null, guide: null };
|
|
3990
|
+
case 'picker':
|
|
3991
|
+
return { node: row, label: str(inner.props['label']) || null, labelNode: null, guide: null };
|
|
3992
|
+
case 'stepper':
|
|
3993
|
+
case 'datepicker':
|
|
3994
|
+
return { node: row, label: null, labelNode: inner.props['labelsHidden'] === true ? null : this.contentOf(inner), guide: null };
|
|
3995
|
+
case 'slider': {
|
|
3996
|
+
const names = Array.isArray(inner.props['labels']) ? inner.props['labels'].map(str) : [];
|
|
3997
|
+
const at = names.indexOf('label');
|
|
3998
|
+
return { node: row, label: null, labelNode: at >= 0 ? (inner.children[at] ?? null) : null, guide: null };
|
|
3999
|
+
}
|
|
4000
|
+
default:
|
|
4001
|
+
return { node: row, label: null, labelNode: null, guide: this.macFormGuide(row) };
|
|
4002
|
+
}
|
|
4003
|
+
};
|
|
4004
|
+
const sources: Source[] = [];
|
|
4005
|
+
for (const child of node.children) {
|
|
4006
|
+
if (child.kind === 'section') {
|
|
4007
|
+
const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter').map(classify);
|
|
4008
|
+
sources.push({ section: child, rows, headerView: child.children.find((row) => row.kind === 'sectionheader') ?? null, footerView: child.children.find((row) => row.kind === 'sectionfooter') ?? null });
|
|
4009
|
+
} else {
|
|
4010
|
+
const last = sources[sources.length - 1];
|
|
4011
|
+
if (last && last.section === null) last.rows.push(classify(child));
|
|
4012
|
+
else sources.push({ section: null, rows: [classify(child)], headerView: null, footerView: null });
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
// The label column: as wide as the widest label (a view label at its ideal size).
|
|
4016
|
+
const labelCtx: Context = { ...this.childContext(node, ctx), stackAxis: null, listRow: false, listCell: true };
|
|
4017
|
+
let labelWidth = 0;
|
|
4018
|
+
const labelSizes = new Map<LayoutNode, Size>();
|
|
4019
|
+
for (const source of sources) {
|
|
4020
|
+
for (const row of source.rows) {
|
|
4021
|
+
if (row.label !== null) labelWidth = Math.max(labelWidth, this.measurer.measureWidth(row.label, font));
|
|
4022
|
+
else if (row.labelNode) {
|
|
4023
|
+
const size = this.measure(row.labelNode, labelCtx, { width: null, height: null });
|
|
4024
|
+
labelSizes.set(row.labelNode, size);
|
|
4025
|
+
labelWidth = Math.max(labelWidth, size.width);
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
labelWidth = Math.ceil(labelWidth);
|
|
4030
|
+
const controlX = labelWidth + MAC_FORM_LABEL_GAP;
|
|
4031
|
+
const controlWidth = Math.max(0, layoutWidth - controlX);
|
|
4032
|
+
const textCtx: Context = { ...labelCtx, font: textFont };
|
|
4033
|
+
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
4034
|
+
const wrapped = wrapText(text, textFont, controlWidth, null, this.measurer);
|
|
4035
|
+
return { layout: { font: textFont, lines: wrapped.lines, alignment: 'leading', truncated: false }, size: { width: wrapped.width, height: textHeight(wrapped.lines.length, textFont) } };
|
|
4036
|
+
};
|
|
4037
|
+
const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
|
|
4038
|
+
if (!view) return null;
|
|
4039
|
+
const proposal: Proposal = { width: controlWidth, height: null };
|
|
4040
|
+
const size = this.measure(view, textCtx, proposal);
|
|
4041
|
+
return { node: view, ctx: textCtx, proposal, rect: { x: controlX, y, width: size.width, height: size.height } };
|
|
4042
|
+
};
|
|
4043
|
+
// Built from a top, then again centered in the room when the rows are shorter than it.
|
|
4044
|
+
const build = (startY: number): { groups: ListGroupPlan[]; labels: FormLabelPlan[]; end: number } => {
|
|
4045
|
+
const groups: ListGroupPlan[] = [];
|
|
4046
|
+
const labels: FormLabelPlan[] = [];
|
|
4047
|
+
let y = startY;
|
|
4048
|
+
for (let g = 0; g < sources.length; g++) {
|
|
4049
|
+
const source = sources[g] as Source;
|
|
4050
|
+
const previous = groups[g - 1];
|
|
4051
|
+
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
4052
|
+
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
4053
|
+
const hasHeader = (headerText !== null && headerText.length > 0) || (source.headerView !== null && this.measure(source.headerView, textCtx, { width: controlWidth, height: null }).height > 0);
|
|
4054
|
+
// A header after rows is 4 under them; right under a footer.
|
|
4055
|
+
if (previous && hasHeader && previous.footer === null && !(previous.footerView !== null && previous.footerView.rect.height > 0)) y += MAC_FORM_HEADER_TOP;
|
|
4056
|
+
const top = y;
|
|
4057
|
+
let header: ListGroupPlan['header'] = null;
|
|
4058
|
+
let footer: ListGroupPlan['footer'] = null;
|
|
4059
|
+
const headerView = supplement(source.headerView, y);
|
|
4060
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + MAC_FORM_HEADER_GAP;
|
|
4061
|
+
if (headerText !== null && headerText.length > 0) {
|
|
4062
|
+
const { layout, size } = makeText(headerText);
|
|
4063
|
+
header = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4064
|
+
y += size.height + MAC_FORM_HEADER_GAP;
|
|
4065
|
+
}
|
|
4066
|
+
const cardTop = y;
|
|
4067
|
+
const rows: ListRowPlan[] = [];
|
|
4068
|
+
source.rows.forEach((row, i) => {
|
|
4069
|
+
if (i > 0) y += MAC_FORM_ROW_GAP;
|
|
4070
|
+
if (row.node.kind === 'lazyspacer') {
|
|
4071
|
+
const rect: Rect = { x: controlX, y, width: controlWidth, height: 0 };
|
|
4072
|
+
rows.push({ node: row.node, proposal: { width: controlWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
// A shifted stack is proposed the whole row less the trailing inset; its place is set when the list is placed.
|
|
4076
|
+
// A shifted stack is proposed the whole row; its place and width are set when the list is placed.
|
|
4077
|
+
const proposal: Proposal = { width: row.guide ? layoutWidth : controlWidth, height: null };
|
|
4078
|
+
const contentSize = this.measure(row.node, rowCtx, proposal);
|
|
4079
|
+
const labelSize = row.labelNode ? labelSizes.get(row.labelNode) ?? ZERO : row.label !== null ? { width: this.measurer.measureWidth(row.label, font), height: font.lineHeight } : ZERO;
|
|
4080
|
+
const rowHeight = Math.max(contentSize.height, labelSize.height);
|
|
4081
|
+
// A text field's row is the field itself (the page draws the Mac's bezel on the row element); the others span the form.
|
|
4082
|
+
const field = unwrapStyled(row.node).kind === 'textfield';
|
|
4083
|
+
rows.push({
|
|
4084
|
+
node: row.node,
|
|
4085
|
+
proposal,
|
|
4086
|
+
contentSize,
|
|
4087
|
+
row: field ? { x: controlX, y, width: controlWidth, height: rowHeight } : { x: 0, y, width: layoutWidth, height: rowHeight },
|
|
4088
|
+
content: { x: row.guide ? 0 : controlX, y: y + (rowHeight - contentSize.height) / 2, width: contentSize.width, height: contentSize.height },
|
|
4089
|
+
badge: null,
|
|
4090
|
+
edit: null,
|
|
4091
|
+
separatorHidden: { top: true, bottom: true },
|
|
4092
|
+
});
|
|
4093
|
+
if (row.label !== null) {
|
|
4094
|
+
labels.push({
|
|
4095
|
+
rect: { x: 0, y: y + (rowHeight - labelSize.height) / 2, width: labelWidth, height: labelSize.height },
|
|
4096
|
+
layout: { font, lines: [row.label], alignment: 'trailing', truncated: false },
|
|
4097
|
+
node: null,
|
|
4098
|
+
ctx: labelCtx,
|
|
4099
|
+
});
|
|
4100
|
+
} else if (row.labelNode) {
|
|
4101
|
+
labels.push({
|
|
4102
|
+
rect: { x: labelWidth - labelSize.width, y: y + (rowHeight - labelSize.height) / 2, width: labelSize.width, height: labelSize.height },
|
|
4103
|
+
layout: null,
|
|
4104
|
+
node: row.labelNode,
|
|
4105
|
+
ctx: labelCtx,
|
|
4106
|
+
});
|
|
4107
|
+
}
|
|
4108
|
+
y += rowHeight;
|
|
4109
|
+
});
|
|
4110
|
+
// The rows' card spans the form: the label column is drawn in it too.
|
|
4111
|
+
const card: Rect = { x: 0, y: cardTop, width: layoutWidth, height: y - cardTop };
|
|
4112
|
+
if (footerText !== null && footerText.length > 0) {
|
|
4113
|
+
y += MAC_FORM_FOOTER_GAP;
|
|
4114
|
+
const { layout, size } = makeText(footerText);
|
|
4115
|
+
footer = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4116
|
+
y += size.height;
|
|
4117
|
+
}
|
|
4118
|
+
let footerView = supplement(source.footerView, y + MAC_FORM_FOOTER_GAP);
|
|
4119
|
+
if (footerView && footerView.rect.height > 0) y += MAC_FORM_FOOTER_GAP + footerView.rect.height;
|
|
4120
|
+
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
4121
|
+
groups.push({ section: source.section, frame: { x: 0, y: top, width: layoutWidth, height: y - top }, header, footer, headerView, footerView, card, rows, outlines: [] });
|
|
4122
|
+
}
|
|
4123
|
+
return { groups, labels, end: y };
|
|
4124
|
+
};
|
|
4125
|
+
let built = build(0);
|
|
4126
|
+
// Centered in the room under the bar when shorter than it (the Mac's columns form does not scroll).
|
|
4127
|
+
const scrollInset = this.scrollInsets.get(node);
|
|
4128
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
4129
|
+
const room = finite(p.height) ? p.height - inset : null;
|
|
4130
|
+
if (room !== null && room > built.end) built = build((room - built.end) / 2);
|
|
4131
|
+
const contentHeight = built.end;
|
|
4132
|
+
return {
|
|
4133
|
+
size: { width, height: p.height === null ? contentHeight : p.height },
|
|
4134
|
+
contentSize: { width: layoutWidth, height: contentHeight },
|
|
4135
|
+
groups: built.groups,
|
|
4136
|
+
labels: built.labels,
|
|
4137
|
+
columns: true,
|
|
4138
|
+
column: { x: controlX, width: controlWidth },
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
|
|
3533
4142
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
3534
4143
|
const plan = this.listPlan(node, ctx, p);
|
|
3535
|
-
const
|
|
4144
|
+
const columns = plan.columns === true;
|
|
4145
|
+
const rowCtx: Context = columns
|
|
4146
|
+
? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
|
|
4147
|
+
: { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
|
|
4148
|
+
const mac = this.env.platform === 'macos';
|
|
4149
|
+
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
3536
4150
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
3537
4151
|
// Under its screen's bar (and the large title that scrolls with it), the
|
|
3538
4152
|
// rows start below them.
|
|
3539
4153
|
const scrollInset = this.scrollInsets.get(node);
|
|
3540
4154
|
const listChrome: Record<string, Rect> = {};
|
|
4155
|
+
const listTexts: Record<string, TextLayout> = {};
|
|
3541
4156
|
if (covered) listChrome['bottomInset'] = covered;
|
|
3542
4157
|
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
3543
4158
|
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
3544
|
-
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
3545
4159
|
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
3546
4160
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
3547
4161
|
y += inset;
|
|
3548
4162
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
4163
|
+
// The Mac idiom's columns form: the label column (chrome `formLabel<i>`
|
|
4164
|
+
// with its text, or a view placed there) and the flag for the page.
|
|
4165
|
+
if (columns) {
|
|
4166
|
+
listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4167
|
+
(plan.labels ?? []).forEach((label, i) => {
|
|
4168
|
+
if (label.layout) {
|
|
4169
|
+
listChrome[`formLabel${i}`] = offset(label.rect);
|
|
4170
|
+
listTexts[`formLabel${i}`] = label.layout;
|
|
4171
|
+
}
|
|
4172
|
+
});
|
|
4173
|
+
}
|
|
4174
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
4175
|
+
if (node.id >= 0 && Object.keys(listTexts).length > 0) this.chromeText.set(node.id, listTexts);
|
|
3549
4176
|
for (const group of plan.groups) {
|
|
3550
4177
|
if (group.section) {
|
|
3551
4178
|
const chrome: Record<string, Rect> = { rows: offset(group.card) };
|
|
@@ -3568,7 +4195,7 @@ export class LayoutPass {
|
|
|
3568
4195
|
if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
|
|
3569
4196
|
}
|
|
3570
4197
|
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));
|
|
4198
|
+
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
4199
|
if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
|
|
3573
4200
|
}
|
|
3574
4201
|
// A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
|
|
@@ -3578,6 +4205,25 @@ export class LayoutPass {
|
|
|
3578
4205
|
}
|
|
3579
4206
|
}
|
|
3580
4207
|
}
|
|
4208
|
+
// An outline's `disclosuregroup` is not placed through `place` (its label
|
|
4209
|
+
// and content are the rows): its frame is the rect its rows span, which
|
|
4210
|
+
// is its box too (`row`, so the page lays its rows out from it), its
|
|
4211
|
+
// content is hidden while collapsed, and what a pass before hid is shown.
|
|
4212
|
+
for (const outline of group.outlines) {
|
|
4213
|
+
const g = outline.group;
|
|
4214
|
+
const previouslyHidden = this.hiddenBy.get(g);
|
|
4215
|
+
if (previouslyHidden) {
|
|
4216
|
+
for (const id of previouslyHidden) {
|
|
4217
|
+
this.hidden.delete(id);
|
|
4218
|
+
this.touched.add(id);
|
|
4219
|
+
}
|
|
4220
|
+
this.hiddenBy.delete(g);
|
|
4221
|
+
}
|
|
4222
|
+
const frame = offset(outline.frame);
|
|
4223
|
+
this.setFrame(g, frame.x, frame.y, frame);
|
|
4224
|
+
this.setChrome(g, { row: frame });
|
|
4225
|
+
if (g.props['expanded'] !== true) for (const child of g.children.slice(1)) this.hide(child, g);
|
|
4226
|
+
}
|
|
3581
4227
|
for (const row of group.rows) {
|
|
3582
4228
|
// Set the row chrome before placing so a navlink can merge its own keys.
|
|
3583
4229
|
const chrome: Record<string, Rect> = { row: offset(row.row) };
|
|
@@ -3598,15 +4244,31 @@ export class LayoutPass {
|
|
|
3598
4244
|
if (row.edit.listRow === row.node) Object.assign(chrome, controls);
|
|
3599
4245
|
else this.setChrome(row.edit.listRow, controls);
|
|
3600
4246
|
}
|
|
3601
|
-
|
|
4247
|
+
let rowX = x + row.content.x;
|
|
3602
4248
|
const rowY = y + row.content.y;
|
|
4249
|
+
// A columns form's shifted stack: placed, then moved so its labeled
|
|
4250
|
+
// control starts at the controls column (the form's alignment guide).
|
|
4251
|
+
// Placed once to find the control's offset in it, then proposed the
|
|
4252
|
+
// width that ends it at the trailing edge, from where the control
|
|
4253
|
+
// lands at the column.
|
|
4254
|
+
const guide = columns ? this.macFormGuide(row.node) : null;
|
|
4255
|
+
let proposal = row.proposal;
|
|
4256
|
+
if (guide && plan.column) {
|
|
4257
|
+
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
4258
|
+
const placed = this.frames.get(unwrapStyled(guide).id);
|
|
4259
|
+
if (placed) {
|
|
4260
|
+
const offset = placed.x - rowX;
|
|
4261
|
+
rowX = x + plan.column.x - offset;
|
|
4262
|
+
proposal = { width: Math.max(0, plan.size.width - (plan.column.x - offset)), height: row.proposal.height };
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
3603
4265
|
// A row that is skipped keeps the chrome it set itself when it was
|
|
3604
4266
|
// placed (a link's chevron and label); only the list's keys are new.
|
|
3605
|
-
const own = this.placedAlready(row.node, rowCtx,
|
|
4267
|
+
const own = this.placedAlready(row.node, rowCtx, proposal, rowX, rowY) && row.node.id >= 0
|
|
3606
4268
|
? this.chrome.get(row.node.id)
|
|
3607
4269
|
: undefined;
|
|
3608
4270
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
3609
|
-
this.place(row.node, rowCtx,
|
|
4271
|
+
this.place(row.node, rowCtx, proposal, rowX, rowY);
|
|
3610
4272
|
if (row.badge) this.badgeBeforeChevron(row.node);
|
|
3611
4273
|
// iOS 26: the separator under a row starts where its first text
|
|
3612
4274
|
// starts (after a Label's icon), else at the row's content.
|
|
@@ -3616,6 +4278,20 @@ export class LayoutPass {
|
|
|
3616
4278
|
const leading = (textX ?? rowX) - (x + row.row.x);
|
|
3617
4279
|
placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
|
|
3618
4280
|
}
|
|
4281
|
+
// 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).
|
|
4282
|
+
const outlineGroup = row.outline?.group;
|
|
4283
|
+
if (outlineGroup && outlineGroup.id >= 0) {
|
|
4284
|
+
const rowRect = offset(row.row);
|
|
4285
|
+
const groupChrome: Record<string, Rect> = { ...this.chrome.get(outlineGroup.id), toggle: rowRect };
|
|
4286
|
+
if (!mac) groupChrome['chevron'] = { x: rowRect.x + rowRect.width - cellPadding - LIST_CHEVRON_WIDTH, y: rowRect.y, width: LIST_CHEVRON_WIDTH, height: rowRect.height };
|
|
4287
|
+
this.setChrome(outlineGroup, groupChrome);
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
// The label column's views (a stepper's, a slider's, a date picker's label) go where the plan put them, after the rows (which do not draw them).
|
|
4292
|
+
if (columns) {
|
|
4293
|
+
for (const label of plan.labels ?? []) {
|
|
4294
|
+
if (label.node) this.place(label.node, label.ctx, { width: null, height: null }, x + label.rect.x, y + label.rect.y);
|
|
3619
4295
|
}
|
|
3620
4296
|
}
|
|
3621
4297
|
}
|
|
@@ -3682,11 +4358,32 @@ export class LayoutPass {
|
|
|
3682
4358
|
// area. `.bottomBar` hidden: the bottom toolbars draw nothing.
|
|
3683
4359
|
const barHidden = node.props['navigationBarHidden'] === true;
|
|
3684
4360
|
const bottomBarHidden = node.props['bottomBarHidden'] === true;
|
|
4361
|
+
const parts = this.navScreenParts(node);
|
|
4362
|
+
// The Mac idiom (MAC_* above): the title bar in place of the bar, unified
|
|
4363
|
+
// (52) when anything but the title is in it, plain (28) otherwise; no
|
|
4364
|
+
// large titles. The status bar portion is the window's title bar, which
|
|
4365
|
+
// the unified bar takes over; the row is the whole bar.
|
|
4366
|
+
const mac = this.env.platform === 'macos';
|
|
4367
|
+
const hasBarItems = parts.toolbars.some((t) => {
|
|
4368
|
+
const placement = str(t.props['placement']);
|
|
4369
|
+
return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
|
|
4370
|
+
});
|
|
4371
|
+
const searchInBar = mac && parts.search !== null && !barHidden;
|
|
4372
|
+
// A Mac tab view's toolbar row (the window's top): the tab's root screen
|
|
4373
|
+
// draws its bar as that row, without a title (the tab picker is there).
|
|
4374
|
+
const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
|
|
4375
|
+
// A Mac sheet's root screen: the sheet's 40 title bar, its text actions in the sheet's bottom bar.
|
|
4376
|
+
const sheetBar = mac && depth === 0 && cctx.macSheet !== undefined ? cctx.macSheet : undefined;
|
|
4377
|
+
const actionToolbars = sheetBar?.actions ? this.macSheetActionToolbars(node) : [];
|
|
4378
|
+
const unified = mac && tabToolbar === undefined && sheetBar === undefined && (depth > 0 || hasBarItems || searchInBar || (cctx.barLeading ?? 0) > 0 || cctx.splitColumn === true);
|
|
4379
|
+
const rowHeight = mac ? (tabToolbar ? tabToolbar.height : sheetBar ? MAC_SHEET_TITLE_BAR : unified ? MAC_TITLE_BAR_UNIFIED : MAC_TITLE_BAR_PLAIN) : NAV_BAR_HEIGHT;
|
|
4380
|
+
const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
4381
|
+
const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
|
|
4382
|
+
const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
|
|
3685
4383
|
// A compact height (a phone on its side) has no large titles, whatever the
|
|
3686
4384
|
// 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);
|
|
4385
|
+
const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
4386
|
+
const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
3690
4387
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3691
4388
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3692
4389
|
const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
|
|
@@ -3712,9 +4409,9 @@ export class LayoutPass {
|
|
|
3712
4409
|
: 0;
|
|
3713
4410
|
// In a tab view (a tab bar covers the bottom) the field goes under the
|
|
3714
4411
|
// title, as iOS 26 places it; elsewhere it floats at the bottom.
|
|
3715
|
-
const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
|
|
4412
|
+
const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
|
|
3716
4413
|
const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
3717
|
-
const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
4414
|
+
const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
3718
4415
|
// The on-screen keyboard (device mode) covers the screen's bottom. A
|
|
3719
4416
|
// bottom search field rises above it, as iOS 26 docks it on the
|
|
3720
4417
|
// keyboard, so what is typed into it can be seen; the bars stay put,
|
|
@@ -3726,20 +4423,26 @@ export class LayoutPass {
|
|
|
3726
4423
|
const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
|
|
3727
4424
|
const searchLift = searchBottom > 0 ? keyboard : 0;
|
|
3728
4425
|
const contentTop = barHeight + largeHeight + searchRow;
|
|
4426
|
+
const actionsBar = sheetBar?.actions ? sheetBar.actions.height : 0;
|
|
3729
4427
|
const content: Rect = {
|
|
3730
4428
|
x,
|
|
3731
4429
|
y: y + contentTop,
|
|
3732
4430
|
width: size.width,
|
|
3733
|
-
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
4431
|
+
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom) - actionsBar),
|
|
3734
4432
|
};
|
|
3735
4433
|
const chrome: Record<string, Rect> = { content };
|
|
3736
4434
|
if (!barHidden) {
|
|
3737
4435
|
chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
|
|
3738
|
-
// The 44 row the bar's items sit in, under the status bar
|
|
3739
|
-
|
|
4436
|
+
// The 44 row the bar's items sit in, under the status bar (on a Mac the
|
|
4437
|
+
// title bar's row, which ends at the bar's bottom).
|
|
4438
|
+
chrome['barRow'] = { x, y: mac ? y + barHeight - rowHeight : y + statusBar, width: size.width, height: rowHeight };
|
|
4439
|
+
// The page styles a unified title bar's title (leading-aligned, larger).
|
|
4440
|
+
if (unified) chrome['unified'] = chrome['barRow'];
|
|
4441
|
+
// The page styles a Mac sheet's title (leading-aligned, 13 bold).
|
|
4442
|
+
if (sheetBar) chrome['sheetBar'] = chrome['barRow'];
|
|
3740
4443
|
}
|
|
3741
4444
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
3742
|
-
if (parts.search) {
|
|
4445
|
+
if (parts.search && !searchInBar) {
|
|
3743
4446
|
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
3744
4447
|
chrome['searchField'] = {
|
|
3745
4448
|
x: x + marginH,
|
|
@@ -3758,12 +4461,19 @@ export class LayoutPass {
|
|
|
3758
4461
|
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
4462
|
}
|
|
3760
4463
|
const title = str(node.props['title']) ?? '';
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
const
|
|
4464
|
+
// `.navigationSubtitle`: a second line under the title in a Mac's unified
|
|
4465
|
+
// title bar (11 regular, secondary); after the title in a plain one.
|
|
4466
|
+
const { platform } = this.env;
|
|
4467
|
+
const subtitle = mac ? (str(node.props['subtitle']) ?? '') : '';
|
|
4468
|
+
const shownTitle = subtitle.length > 0 && !unified && !sheetBar ? `${title} — ${subtitle}` : title;
|
|
4469
|
+
const subtitleFont = makeFont('system', textStyleSize('subheadline', dynamicTypeSize, platform), 400);
|
|
4470
|
+
// 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.
|
|
4471
|
+
const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
|
|
4472
|
+
const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
|
|
4473
|
+
const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize, platform), 400);
|
|
3764
4474
|
|
|
3765
4475
|
// Toolbar items at their ideal size, grouped per toolbar.
|
|
3766
|
-
const rowY = y + statusBar;
|
|
4476
|
+
const rowY = (chrome['barRow'] ?? { y: y + statusBar }).y;
|
|
3767
4477
|
interface Group {
|
|
3768
4478
|
toolbar: LayoutNode;
|
|
3769
4479
|
placement: string;
|
|
@@ -3779,15 +4489,34 @@ export class LayoutPass {
|
|
|
3779
4489
|
? []
|
|
3780
4490
|
: parts.toolbars.filter((t) => {
|
|
3781
4491
|
const placement = placementOf(t);
|
|
3782
|
-
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
|
|
4492
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t) && !actionToolbars.includes(t);
|
|
4493
|
+
});
|
|
4494
|
+
// A Mac sheet's actions: 22 push buttons at the trailing end of the
|
|
4495
|
+
// sheet's bottom bar, the trailing placement's (confirm) last, 8 apart.
|
|
4496
|
+
if (sheetBar?.actions) {
|
|
4497
|
+
const actions = sheetBar.actions;
|
|
4498
|
+
let cursor = actions.x + actions.width - MAC_SHEET_ACTIONS_INSET;
|
|
4499
|
+
const ordered = [...actionToolbars.filter((t) => placementOf(t) === 'trailing'), ...actionToolbars.filter((t) => placementOf(t) === 'leading')];
|
|
4500
|
+
for (const toolbar of ordered) {
|
|
4501
|
+
const itemCtx: Context = { ...this.childContext(toolbar, cctx), navBarItem: false };
|
|
4502
|
+
let first = cursor;
|
|
4503
|
+
let last = cursor;
|
|
4504
|
+
[...toolbar.children].reverse().forEach((item) => {
|
|
4505
|
+
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
4506
|
+
this.place(item, itemCtx, { width: null, height: null }, cursor - itemSize.width, actions.y + (actions.height - itemSize.height) / 2);
|
|
4507
|
+
first = cursor - itemSize.width;
|
|
4508
|
+
cursor = first - MAC_SHEET_ACTIONS_GAP;
|
|
3783
4509
|
});
|
|
4510
|
+
this.setFrame(toolbar, first, actions.y, { width: Math.max(0, last - first), height: actions.height });
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
3784
4513
|
const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
|
|
3785
4514
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3786
4515
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3787
4516
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3788
4517
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
3789
4518
|
if (placement === 'leading' || placement === 'trailing') {
|
|
3790
|
-
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
|
|
4519
|
+
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
|
|
3791
4520
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
3792
4521
|
}
|
|
3793
4522
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
@@ -3811,7 +4540,7 @@ export class LayoutPass {
|
|
|
3811
4540
|
const runWidth = (run: Run): number => {
|
|
3812
4541
|
const slots = run.groups.flatMap((g) => g.slots ?? []);
|
|
3813
4542
|
if (slots.length === 0) return 0;
|
|
3814
|
-
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) *
|
|
4543
|
+
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
|
|
3815
4544
|
return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
|
|
3816
4545
|
};
|
|
3817
4546
|
for (const toolbar of parts.toolbars) {
|
|
@@ -3868,7 +4597,7 @@ export class LayoutPass {
|
|
|
3868
4597
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3869
4598
|
return toolbar.children.map((item) => {
|
|
3870
4599
|
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3871
|
-
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
|
|
4600
|
+
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width, glass, glassPad) };
|
|
3872
4601
|
});
|
|
3873
4602
|
});
|
|
3874
4603
|
// Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
|
|
@@ -3895,26 +4624,38 @@ export class LayoutPass {
|
|
|
3895
4624
|
}
|
|
3896
4625
|
return out;
|
|
3897
4626
|
};
|
|
3898
|
-
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart -
|
|
4627
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - NAV_GROUP_SPACING);
|
|
3899
4628
|
}
|
|
3900
4629
|
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
3901
4630
|
|
|
3902
4631
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
3903
4632
|
// title no longer shows).
|
|
3904
4633
|
const backLabel = '';
|
|
3905
|
-
|
|
4634
|
+
// A Mac window's own title bar (the top of the screen, a title bar in the
|
|
4635
|
+
// safe area) starts with the window buttons; the items follow them.
|
|
4636
|
+
const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
|
|
4637
|
+
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4638
|
+
const glassY = rowY + (rowHeight - glass) / 2;
|
|
3906
4639
|
if (depth > 0 && !barHidden) {
|
|
3907
|
-
chrome['back'] = { x: leadingStart, y:
|
|
4640
|
+
chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
|
|
3908
4641
|
}
|
|
3909
4642
|
|
|
3910
4643
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
3911
4644
|
const back = chrome['back'];
|
|
3912
4645
|
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
3913
|
-
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
4646
|
+
let trailingEdge = x + size.width - (mac ? (tabToolbar ? MAC_TAB_TOOLBAR_MARGIN_TRAILING : MAC_BAR_MARGIN_TRAILING) : NAV_ITEM_MARGIN);
|
|
4647
|
+
// On a Mac the search field is the trailing-most item of the title bar
|
|
4648
|
+
// (chrome `searchBar` tells the page to draw it as the toolbar's field).
|
|
4649
|
+
if (searchInBar) {
|
|
4650
|
+
const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
|
|
4651
|
+
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_SEARCH_FIELD_HEIGHT) / 2, width, height: MAC_SEARCH_FIELD_HEIGHT };
|
|
4652
|
+
chrome['searchBar'] = chrome['searchField'];
|
|
4653
|
+
trailingEdge -= width + NAV_GROUP_SPACING;
|
|
4654
|
+
}
|
|
3914
4655
|
// The More button is the trailing-most item.
|
|
3915
4656
|
if (collapsedSecondary.length > 0) {
|
|
3916
|
-
chrome['more'] = { x: trailingEdge -
|
|
3917
|
-
trailingEdge -=
|
|
4657
|
+
chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
|
|
4658
|
+
trailingEdge -= glass + NAV_GROUP_SPACING;
|
|
3918
4659
|
}
|
|
3919
4660
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
3920
4661
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -3937,7 +4678,7 @@ export class LayoutPass {
|
|
|
3937
4678
|
const placeRun = (run: Run, startX: number): void => {
|
|
3938
4679
|
const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
|
|
3939
4680
|
if (!run.bare) {
|
|
3940
|
-
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y:
|
|
4681
|
+
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
|
|
3941
4682
|
}
|
|
3942
4683
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
3943
4684
|
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
@@ -3947,11 +4688,17 @@ export class LayoutPass {
|
|
|
3947
4688
|
group.toolbar.children.forEach((item, i) => {
|
|
3948
4689
|
const itemSize = group.sizes[i] as Size;
|
|
3949
4690
|
const slot = group.slots?.[i] ?? itemSize.width;
|
|
3950
|
-
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (
|
|
3951
|
-
|
|
4691
|
+
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
|
|
4692
|
+
// A Mac toolbar's text button is a bordered 28 button at its slot
|
|
4693
|
+
// (chrome `bordered`, drawn by the page at that rect); an icon is bare.
|
|
4694
|
+
if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
|
|
4695
|
+
const existing = this.chrome.get(item.id);
|
|
4696
|
+
this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
|
|
4697
|
+
}
|
|
4698
|
+
cursor += slot + runGap;
|
|
3952
4699
|
});
|
|
3953
|
-
const last = group.toolbar.children.length > 0 ? cursor -
|
|
3954
|
-
this.setFrame(group.toolbar, first, rowY, { width: last - first, height:
|
|
4700
|
+
const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
|
|
4701
|
+
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
|
|
3955
4702
|
}
|
|
3956
4703
|
};
|
|
3957
4704
|
for (const run of runs.filter((r) => r.placement === 'leading')) {
|
|
@@ -3970,8 +4717,8 @@ export class LayoutPass {
|
|
|
3970
4717
|
for (const group of groups) {
|
|
3971
4718
|
if (group.placement !== 'principal') continue;
|
|
3972
4719
|
const startX = x + (size.width - group.total) / 2;
|
|
3973
|
-
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height:
|
|
3974
|
-
placeGroup(group, startX, rowY,
|
|
4720
|
+
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: rowHeight });
|
|
4721
|
+
placeGroup(group, startX, rowY, rowHeight, NAV_ITEM_SPACING);
|
|
3975
4722
|
}
|
|
3976
4723
|
// The inline secondary items, right-aligned before the trailing groups,
|
|
3977
4724
|
// each toolbar framed around its own; the collapsed ones are hidden (the
|
|
@@ -3983,12 +4730,12 @@ export class LayoutPass {
|
|
|
3983
4730
|
for (const [i, entry] of inlineSecondary.entries()) {
|
|
3984
4731
|
cursor += secondaryGap(inlineSecondary[i - 1], entry);
|
|
3985
4732
|
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 + (
|
|
4733
|
+
this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (rowHeight - entry.size.height) / 2);
|
|
3987
4734
|
const extent = extents.get(entry.toolbar);
|
|
3988
4735
|
extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
|
|
3989
4736
|
cursor += entry.slot;
|
|
3990
4737
|
}
|
|
3991
|
-
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height:
|
|
4738
|
+
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
|
|
3992
4739
|
trailingEdge -= total + NAV_GROUP_SPACING;
|
|
3993
4740
|
}
|
|
3994
4741
|
for (const toolbar of secondaryToolbars) {
|
|
@@ -4046,7 +4793,28 @@ export class LayoutPass {
|
|
|
4046
4793
|
const titleScrolls = large && scroller !== null;
|
|
4047
4794
|
// A large title that scrolls away collapses into the inline title, which
|
|
4048
4795
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
4049
|
-
if (
|
|
4796
|
+
if (tabToolbar) {
|
|
4797
|
+
// The tab picker stands where the title would be.
|
|
4798
|
+
} else if ((unified || sheetBar) && !principal && !barHidden) {
|
|
4799
|
+
// The unified title bar's title (and a Mac sheet's): leading-aligned
|
|
4800
|
+
// after the back button and the leading items (2 past the window
|
|
4801
|
+
// buttons' room when nothing is before it), cut to the room before
|
|
4802
|
+
// the trailing ones.
|
|
4803
|
+
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
|
|
4804
|
+
const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
|
|
4805
|
+
const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
|
|
4806
|
+
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4807
|
+
if (subtitle.length > 0) {
|
|
4808
|
+
// The title and the subtitle stacked, centered in the row.
|
|
4809
|
+
const block = titleFont.lineHeight + subtitleFont.lineHeight;
|
|
4810
|
+
const top = rowY + (rowHeight - block) / 2;
|
|
4811
|
+
chrome['title'] = { x: titleX, y: top, width: titleWidth, height: titleFont.lineHeight };
|
|
4812
|
+
const subtitleWidth = Math.min(Math.ceil(this.measurer.measureWidth(subtitle, subtitleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4813
|
+
chrome['subtitle'] = { x: titleX, y: top + titleFont.lineHeight, width: subtitleWidth, height: subtitleFont.lineHeight };
|
|
4814
|
+
} else {
|
|
4815
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4816
|
+
}
|
|
4817
|
+
} else if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
4050
4818
|
const leftUsed = Math.max(
|
|
4051
4819
|
back ? back.x + back.width - x : 0,
|
|
4052
4820
|
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
@@ -4061,21 +4829,22 @@ export class LayoutPass {
|
|
|
4061
4829
|
const roomEnd = x + size.width - rightUsed - 8;
|
|
4062
4830
|
const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
|
|
4063
4831
|
const titleWidth = Math.min(
|
|
4064
|
-
Math.ceil(this.measurer.measureWidth(
|
|
4832
|
+
Math.ceil(this.measurer.measureWidth(shownTitle, titleFont) - 0.001),
|
|
4065
4833
|
compactCap,
|
|
4066
4834
|
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
4067
4835
|
);
|
|
4068
4836
|
const centered = x + (size.width - titleWidth) / 2;
|
|
4069
4837
|
const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
4070
|
-
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height:
|
|
4838
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4071
4839
|
}
|
|
4072
4840
|
this.setChrome(node, chrome);
|
|
4073
4841
|
|
|
4074
4842
|
if (node.id >= 0) {
|
|
4075
4843
|
this.fonts.set(node.id, large ? largeTitleFont : titleFont);
|
|
4076
4844
|
const texts: Record<string, TextLayout> = {
|
|
4077
|
-
title: { font: titleFont, lines: [
|
|
4845
|
+
title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
|
|
4078
4846
|
};
|
|
4847
|
+
if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
|
|
4079
4848
|
if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
|
|
4080
4849
|
if (large) {
|
|
4081
4850
|
const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
|
|
@@ -4324,6 +5093,10 @@ export class LayoutPass {
|
|
|
4324
5093
|
*/
|
|
4325
5094
|
private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
4326
5095
|
const { screen } = this.env;
|
|
5096
|
+
if (this.env.platform === 'macos') {
|
|
5097
|
+
this.placeMacTabView(node, cctx, x, y, size);
|
|
5098
|
+
return;
|
|
5099
|
+
}
|
|
4327
5100
|
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
4328
5101
|
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
4329
5102
|
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
@@ -4363,6 +5136,64 @@ export class LayoutPass {
|
|
|
4363
5136
|
});
|
|
4364
5137
|
}
|
|
4365
5138
|
|
|
5139
|
+
/**
|
|
5140
|
+
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a segmented
|
|
5141
|
+
* control centered in a 44 row at the top of the content, below the
|
|
5142
|
+
* window's title bar when the tab view is at the top of the screen; the
|
|
5143
|
+
* content is the rest, and the selected tab fills it (its own `navstack`
|
|
5144
|
+
* reserves no status bar there: the row is above it). Chrome `top` marks
|
|
5145
|
+
* the bar as the top one for the page.
|
|
5146
|
+
*/
|
|
5147
|
+
private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
5148
|
+
const { safeArea } = this.env;
|
|
5149
|
+
// At the top of the window the row is the window's toolbar (38, the
|
|
5150
|
+
// title bar included); lower down, a 38 row at the top of the content.
|
|
5151
|
+
const atTop = y <= 0 && safeArea.top > 0;
|
|
5152
|
+
const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
|
|
5153
|
+
const tabs = node.children;
|
|
5154
|
+
const n = tabs.length;
|
|
5155
|
+
// Each segment is its title (13 regular) and 12 either side.
|
|
5156
|
+
const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
|
|
5157
|
+
const itemWidths = tabs.map((tab) => this.measurer.measureWidth(str(tab.props['title']) ?? '', font) + 2 * MAC_TAB_ITEM_PAD_H);
|
|
5158
|
+
const natural = itemWidths.reduce((sum, w) => sum + w, 0) + 2 * MAC_TAB_ITEM_INSET;
|
|
5159
|
+
const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
|
|
5160
|
+
const scale = natural > 0 ? width / natural : 1;
|
|
5161
|
+
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height - topInset));
|
|
5162
|
+
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) };
|
|
5163
|
+
const toolbar: Rect = { x, y: y + topInset, width: size.width, height: rowHeight };
|
|
5164
|
+
const contentTop = topInset + rowHeight;
|
|
5165
|
+
const content: Rect = { x, y: y + contentTop, width: size.width, height: Math.max(0, size.height - contentTop) };
|
|
5166
|
+
const chrome: Record<string, Rect> = { bar, content, top: bar };
|
|
5167
|
+
let itemX = bar.x + MAC_TAB_ITEM_INSET;
|
|
5168
|
+
for (let i = 0; i < n; i++) {
|
|
5169
|
+
const itemWidth = (itemWidths[i] as number) * scale;
|
|
5170
|
+
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) };
|
|
5171
|
+
itemX += itemWidth;
|
|
5172
|
+
}
|
|
5173
|
+
this.setChrome(node, chrome);
|
|
5174
|
+
const selected = readSelected(node, n);
|
|
5175
|
+
const tabCtx: Context = { ...cctx, bottomEdge: undefined, barAbove: undefined };
|
|
5176
|
+
const contentSize: Size = { width: content.width, height: content.height };
|
|
5177
|
+
tabs.forEach((tab, i) => {
|
|
5178
|
+
if (i !== selected) {
|
|
5179
|
+
this.setFrame(tab, content.x, content.y, contentSize);
|
|
5180
|
+
this.hide(tab, node);
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
const child = this.contentOf(tab);
|
|
5184
|
+
// A navigation stack at the window's top fills the tab: its root
|
|
5185
|
+
// screen draws the toolbar row as its bar (`tabToolbar`), with the
|
|
5186
|
+
// picker over it; anything else starts under the row.
|
|
5187
|
+
if (atTop && child && unwrapStyled(child).kind === 'navstack') {
|
|
5188
|
+
this.setFrame(tab, x, y, size);
|
|
5189
|
+
this.placeTabContent(tab, this.childContext(tab, { ...tabCtx, tabToolbar: toolbar }), x, y, size, 0);
|
|
5190
|
+
return;
|
|
5191
|
+
}
|
|
5192
|
+
this.setFrame(tab, content.x, content.y, contentSize);
|
|
5193
|
+
this.placeTabContent(tab, this.childContext(tab, tabCtx), content.x, content.y, contentSize, 0);
|
|
5194
|
+
});
|
|
5195
|
+
}
|
|
5196
|
+
|
|
4366
5197
|
/**
|
|
4367
5198
|
* The selected tab's content, like a navscreen's: a `navstack` fills the
|
|
4368
5199
|
* whole tab (it reserves the status bar itself and its lists run under the
|
|
@@ -4430,7 +5261,7 @@ export class LayoutPass {
|
|
|
4430
5261
|
const leading = x <= 0 ? safeArea.leading : 0;
|
|
4431
5262
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
4432
5263
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
4433
|
-
const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
5264
|
+
const sidebarWidth = Math.min(this.env.platform === 'macos' ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
4434
5265
|
const visible = node.props['sidebarVisible'] !== false;
|
|
4435
5266
|
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
4436
5267
|
const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
|
|
@@ -4438,22 +5269,29 @@ export class LayoutPass {
|
|
|
4438
5269
|
const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
|
|
4439
5270
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
4440
5271
|
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
5272
|
+
// On a Mac the toggle is a 28 button centered in the unified title bar's
|
|
5273
|
+
// row (which the columns' screens lay out: `placeNavScreen`), after the
|
|
5274
|
+
// window buttons when the split view is at the top of the screen.
|
|
5275
|
+
const mac = this.env.platform === 'macos';
|
|
5276
|
+
const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
5277
|
+
const toggleX = x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
|
|
5278
|
+
const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
|
|
4441
5279
|
const chrome: Record<string, Rect> = {
|
|
4442
5280
|
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
4443
5281
|
separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
|
|
4444
5282
|
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
4445
|
-
toggle: { x:
|
|
5283
|
+
toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
|
|
4446
5284
|
};
|
|
4447
5285
|
this.setChrome(node, chrome);
|
|
4448
|
-
const toggleRoom =
|
|
5286
|
+
const toggleRoom = toggleSize + NAV_GROUP_SPACING;
|
|
4449
5287
|
const [sidebar, detail, ...rest] = node.children;
|
|
4450
5288
|
if (sidebar) {
|
|
4451
5289
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
4452
|
-
this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
5290
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
4453
5291
|
}
|
|
4454
5292
|
if (detail) {
|
|
4455
5293
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
4456
|
-
this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
5294
|
+
this.place(detail, { ...cctx, splitSidebar: false, splitColumn: true, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
|
|
4457
5295
|
}
|
|
4458
5296
|
for (const extra of rest) this.hide(extra, node);
|
|
4459
5297
|
}
|
|
@@ -4577,16 +5415,31 @@ export class LayoutPass {
|
|
|
4577
5415
|
}
|
|
4578
5416
|
|
|
4579
5417
|
private stepperLabelProposal(p: Proposal): Proposal {
|
|
4580
|
-
|
|
5418
|
+
const control = this.stepperMetrics();
|
|
5419
|
+
return { width: shrink(p.width, control.width + control.gap), height: p.height };
|
|
4581
5420
|
}
|
|
4582
5421
|
|
|
4583
5422
|
/** Like a toggle: the label leads, the 94 × 32 control trails; at least 32 high. */
|
|
5423
|
+
/** The Mac idiom: a menu outside a bar and a list row is a pull-down button. */
|
|
5424
|
+
private menuIsPullDown(ctx: Context): boolean {
|
|
5425
|
+
return this.env.platform === 'macos' && !ctx.listRow && !ctx.navBarItem && !ctx.bottomBarItem;
|
|
5426
|
+
}
|
|
5427
|
+
|
|
5428
|
+
/** The stepper's control: iOS's segmented −/+, or the Mac's 13 × 22 arrows. */
|
|
5429
|
+
private stepperMetrics(): { width: number; height: number; gap: number; mac: boolean } {
|
|
5430
|
+
if (this.env.platform === 'macos') return { width: MAC_STEPPER_WIDTH, height: MAC_STEPPER_HEIGHT, gap: MAC_STEPPER_GAP, mac: true };
|
|
5431
|
+
return { width: STEPPER_WIDTH, height: STEPPER_HEIGHT, gap: STEPPER_GAP, mac: false };
|
|
5432
|
+
}
|
|
5433
|
+
|
|
4584
5434
|
private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
4585
5435
|
const label = this.contentOf(node);
|
|
5436
|
+
const control = this.stepperMetrics();
|
|
5437
|
+
// A Mac form's row: the label is in the label column; the control alone.
|
|
5438
|
+
if (ctx.formColumn) return { width: control.width, height: control.height };
|
|
4586
5439
|
const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
|
|
4587
5440
|
return {
|
|
4588
|
-
width: finite(p.width) ? p.width : labelSize.width +
|
|
4589
|
-
height: Math.max(labelSize.height,
|
|
5441
|
+
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
5442
|
+
height: Math.max(labelSize.height, control.height),
|
|
4590
5443
|
};
|
|
4591
5444
|
}
|
|
4592
5445
|
|
|
@@ -4594,24 +5447,32 @@ export class LayoutPass {
|
|
|
4594
5447
|
const cctx = this.childContext(node, ctx);
|
|
4595
5448
|
const label = this.contentOf(node);
|
|
4596
5449
|
const labelProposal = this.stepperLabelProposal(p);
|
|
4597
|
-
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5450
|
+
const labelSize = label && !ctx.formColumn ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
4598
5451
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
const
|
|
4602
|
-
const
|
|
5452
|
+
// A Mac form's row: the label is in the label column (the form places it), the control at the leading edge.
|
|
5453
|
+
if (label && !ctx.formColumn) this.place(label, cctx, labelProposal, x, labelY);
|
|
5454
|
+
const control = this.stepperMetrics();
|
|
5455
|
+
const controlX = ctx.formColumn ? x : x + size.width - control.width;
|
|
5456
|
+
const controlY = y + (size.height - control.height) / 2;
|
|
4603
5457
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
5458
|
+
// iOS: − and + side by side; a Mac: up (increment) over down.
|
|
5459
|
+
const half = control.mac ? control.height / 2 : control.width / 2;
|
|
4604
5460
|
this.setChrome(node, {
|
|
4605
5461
|
...existing,
|
|
4606
5462
|
label: {
|
|
4607
5463
|
x,
|
|
4608
5464
|
y: labelY,
|
|
4609
|
-
width: Math.min(labelSize.width, Math.max(0, size.width -
|
|
5465
|
+
width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
|
|
4610
5466
|
height: labelSize.height,
|
|
4611
5467
|
},
|
|
4612
|
-
control: { x: controlX, y: controlY, width:
|
|
4613
|
-
decrement:
|
|
4614
|
-
|
|
5468
|
+
control: { x: controlX, y: controlY, width: control.width, height: control.height },
|
|
5469
|
+
decrement: control.mac
|
|
5470
|
+
? { x: controlX, y: controlY + half, width: control.width, height: half }
|
|
5471
|
+
: { x: controlX, y: controlY, width: half, height: control.height },
|
|
5472
|
+
increment: control.mac
|
|
5473
|
+
? { x: controlX, y: controlY, width: control.width, height: half }
|
|
5474
|
+
: { x: controlX + half, y: controlY, width: half, height: control.height },
|
|
5475
|
+
...(control.mac ? { arrows: { x: controlX, y: controlY, width: control.width, height: control.height } } : {}),
|
|
4615
5476
|
});
|
|
4616
5477
|
}
|
|
4617
5478
|
|
|
@@ -4622,6 +5483,12 @@ export class LayoutPass {
|
|
|
4622
5483
|
* names the slider for VoiceOver): the page hides it and names the slider
|
|
4623
5484
|
* from it (`aria-labelledby`). Children are named by `labels` in order.
|
|
4624
5485
|
*/
|
|
5486
|
+
/** The slider's row, track and knob: iOS 26's pill knob, or the Mac's round one. */
|
|
5487
|
+
private sliderMetrics(): { height: number; track: number; thumb: number; thumbHeight: number; mac: boolean } {
|
|
5488
|
+
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 };
|
|
5489
|
+
return { height: SLIDER_HEIGHT, track: SLIDER_TRACK_HEIGHT, thumb: SLIDER_THUMB, thumbHeight: SLIDER_THUMB_HEIGHT, mac: false };
|
|
5490
|
+
}
|
|
5491
|
+
|
|
4625
5492
|
private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
|
|
4626
5493
|
return this.plan(node, ctx, p, () => {
|
|
4627
5494
|
const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
|
|
@@ -4638,43 +5505,47 @@ export class LayoutPass {
|
|
|
4638
5505
|
});
|
|
4639
5506
|
const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
|
|
4640
5507
|
const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
|
|
4641
|
-
return { size: { width, height:
|
|
5508
|
+
return { size: { width, height: this.sliderMetrics().height }, label, min, max, minSize, maxSize, cctx };
|
|
4642
5509
|
});
|
|
4643
5510
|
}
|
|
4644
5511
|
|
|
4645
5512
|
private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
4646
5513
|
const plan = this.sliderPlan(node, ctx, p);
|
|
4647
5514
|
const { cctx } = plan;
|
|
4648
|
-
|
|
5515
|
+
// The label is not drawn (a Mac form places it in its label column).
|
|
5516
|
+
if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
|
|
5517
|
+
const m = this.sliderMetrics();
|
|
4649
5518
|
const rowY = y;
|
|
4650
5519
|
let trackX = x;
|
|
4651
5520
|
let trackEnd = x + size.width;
|
|
4652
5521
|
if (plan.min) {
|
|
4653
|
-
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (
|
|
5522
|
+
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
|
|
4654
5523
|
trackX += plan.minSize.width + SLIDER_LABEL_GAP;
|
|
4655
5524
|
}
|
|
4656
5525
|
if (plan.max) {
|
|
4657
|
-
this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (
|
|
5526
|
+
this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (m.height - plan.maxSize.height) / 2);
|
|
4658
5527
|
trackEnd -= plan.maxSize.width + SLIDER_LABEL_GAP;
|
|
4659
5528
|
}
|
|
4660
5529
|
const trackWidth = Math.max(0, trackEnd - trackX);
|
|
4661
|
-
const trackY = rowY + (
|
|
5530
|
+
const trackY = rowY + (m.height - m.track) / 2;
|
|
4662
5531
|
const fraction = sliderFraction(num(node.props['value']) ?? 0, num(node.props['min']) ?? 0, num(node.props['max']) ?? 1);
|
|
4663
5532
|
// The thumb travels inside the track: its center runs from half a thumb
|
|
4664
5533
|
// past the track's start to half a thumb before its end.
|
|
4665
|
-
const travel = Math.max(0, trackWidth -
|
|
4666
|
-
const thumbCenter = trackX +
|
|
5534
|
+
const travel = Math.max(0, trackWidth - m.thumb);
|
|
5535
|
+
const thumbCenter = trackX + m.thumb / 2 + fraction * travel;
|
|
4667
5536
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
4668
5537
|
this.setChrome(node, {
|
|
4669
5538
|
...existing,
|
|
4670
|
-
track: { x: trackX, y: trackY, width: trackWidth, height:
|
|
4671
|
-
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height:
|
|
5539
|
+
track: { x: trackX, y: trackY, width: trackWidth, height: m.track },
|
|
5540
|
+
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: m.track },
|
|
4672
5541
|
thumb: {
|
|
4673
|
-
x: thumbCenter -
|
|
4674
|
-
y: rowY + (
|
|
4675
|
-
width:
|
|
4676
|
-
height:
|
|
5542
|
+
x: thumbCenter - m.thumb / 2,
|
|
5543
|
+
y: rowY + (m.height - m.thumbHeight) / 2,
|
|
5544
|
+
width: m.thumb,
|
|
5545
|
+
height: m.thumbHeight,
|
|
4677
5546
|
},
|
|
5547
|
+
// The page draws the Mac's round knob (styles.css).
|
|
5548
|
+
...(m.mac ? { knob: { x: thumbCenter - m.thumb / 2, y: rowY + (m.height - m.thumbHeight) / 2, width: m.thumb, height: m.thumbHeight } } : {}),
|
|
4678
5549
|
});
|
|
4679
5550
|
}
|
|
4680
5551
|
|
|
@@ -4691,17 +5562,21 @@ export class LayoutPass {
|
|
|
4691
5562
|
const timeZone = str(node.props['timeZone']) ?? undefined;
|
|
4692
5563
|
const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
|
|
4693
5564
|
const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
|
|
5565
|
+
const mac = this.env.platform === 'macos';
|
|
5566
|
+
const capsulePad = mac ? MAC_DATEPICKER_CAPSULE_PAD_H : DATEPICKER_CAPSULE_PAD_H;
|
|
5567
|
+
const capsuleGap = mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP;
|
|
4694
5568
|
const capsule = (text: string | null): number =>
|
|
4695
|
-
text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 *
|
|
5569
|
+
text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * capsulePad;
|
|
4696
5570
|
const dateWidth = capsule(dateText);
|
|
4697
5571
|
const timeWidth = capsule(timeText);
|
|
4698
|
-
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ?
|
|
4699
|
-
|
|
5572
|
+
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
|
|
5573
|
+
// A Mac form's row: the label is in the label column; the fields alone.
|
|
5574
|
+
const label = node.props['labelsHidden'] === true || ctx.formColumn ? null : this.contentOf(node);
|
|
4700
5575
|
const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
|
|
4701
5576
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
4702
|
-
const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
5577
|
+
const width = ctx.formColumn ? capsulesWidth : finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
4703
5578
|
return {
|
|
4704
|
-
size: { width, height: Math.max(labelSize.height, DATEPICKER_HEIGHT) },
|
|
5579
|
+
size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
|
|
4705
5580
|
label,
|
|
4706
5581
|
labelProposal,
|
|
4707
5582
|
labelSize,
|
|
@@ -4715,13 +5590,15 @@ export class LayoutPass {
|
|
|
4715
5590
|
|
|
4716
5591
|
private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
4717
5592
|
const plan = this.datePickerPlan(node, ctx, p);
|
|
4718
|
-
if (!plan.label) {
|
|
4719
|
-
// `labelsHidden`: the label child exists but is not shown.
|
|
5593
|
+
if (!plan.label && (!ctx.formColumn || node.props['labelsHidden'] === true)) {
|
|
5594
|
+
// `labelsHidden`: the label child exists but is not shown (a Mac form places it in its label column).
|
|
4720
5595
|
for (const child of node.children) this.hide(child, node);
|
|
4721
5596
|
}
|
|
4722
5597
|
const labelY = y + (size.height - plan.labelSize.height) / 2;
|
|
4723
5598
|
if (plan.label) this.place(plan.label, plan.cctx, plan.labelProposal, x, labelY);
|
|
4724
|
-
const
|
|
5599
|
+
const mac = this.env.platform === 'macos';
|
|
5600
|
+
const capsuleHeight = mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT;
|
|
5601
|
+
const capsuleY = y + (size.height - capsuleHeight) / 2;
|
|
4725
5602
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
4726
5603
|
const chrome: Record<string, Rect> = {
|
|
4727
5604
|
...existing,
|
|
@@ -4734,12 +5611,14 @@ export class LayoutPass {
|
|
|
4734
5611
|
};
|
|
4735
5612
|
let capsuleX = x + size.width - plan.capsulesWidth;
|
|
4736
5613
|
if (plan.dateWidth > 0) {
|
|
4737
|
-
chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height:
|
|
4738
|
-
capsuleX += plan.dateWidth + DATEPICKER_CAPSULE_GAP;
|
|
5614
|
+
chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: capsuleHeight };
|
|
5615
|
+
capsuleX += plan.dateWidth + (mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP);
|
|
4739
5616
|
}
|
|
4740
5617
|
if (plan.timeWidth > 0) {
|
|
4741
|
-
chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height:
|
|
5618
|
+
chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: capsuleHeight };
|
|
4742
5619
|
}
|
|
5620
|
+
// The page draws the Mac's bordered fields (styles.css).
|
|
5621
|
+
if (mac) chrome['fields'] = { x: x + size.width - plan.capsulesWidth, y: capsuleY, width: plan.capsulesWidth, height: capsuleHeight };
|
|
4743
5622
|
this.setChrome(node, chrome);
|
|
4744
5623
|
}
|
|
4745
5624
|
|
|
@@ -4762,13 +5641,15 @@ export class LayoutPass {
|
|
|
4762
5641
|
const chrome: Record<string, Rect> = {};
|
|
4763
5642
|
const texts: Record<string, TextLayout> = {};
|
|
4764
5643
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
5644
|
+
// The alert's metrics: iOS's card, or the Mac's panel (`data-sb-panel` for the page).
|
|
5645
|
+
const m = this.env.platform === 'macos' ? MAC_ALERT_METRICS : ALERT_METRICS;
|
|
4765
5646
|
// `textfield` children (`.alert { TextField(...); Button(...) }`) go in
|
|
4766
5647
|
// the card between the message and the buttons, in their order.
|
|
4767
5648
|
const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
|
|
4768
5649
|
const buttons = node.children.filter((child) => !fields.includes(child));
|
|
4769
5650
|
const placeFields = (top: number, left: number, width: number): void => {
|
|
4770
5651
|
fields.forEach((field, i) => {
|
|
4771
|
-
const rect: Rect = { x: left, y: top + i * (
|
|
5652
|
+
const rect: Rect = { x: left, y: top + i * (m.fieldHeight + m.fieldSpacing), width, height: m.fieldHeight };
|
|
4772
5653
|
chrome[`field${i}`] = rect;
|
|
4773
5654
|
const proposal: Proposal = { width: rect.width, height: rect.height };
|
|
4774
5655
|
const fieldSize = this.measure(field, cctx, proposal);
|
|
@@ -4776,7 +5657,7 @@ export class LayoutPass {
|
|
|
4776
5657
|
this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
|
|
4777
5658
|
});
|
|
4778
5659
|
};
|
|
4779
|
-
const fieldsHeight = fields.length > 0 ? fields.length *
|
|
5660
|
+
const fieldsHeight = fields.length > 0 ? fields.length * m.fieldHeight + (fields.length - 1) * m.fieldSpacing : 0;
|
|
4780
5661
|
|
|
4781
5662
|
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
4782
5663
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
@@ -4788,62 +5669,63 @@ export class LayoutPass {
|
|
|
4788
5669
|
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
4789
5670
|
|
|
4790
5671
|
if (style === 'alert') {
|
|
4791
|
-
const cardWidth = Math.min(
|
|
4792
|
-
const textWidth = cardWidth - 2 *
|
|
5672
|
+
const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
|
|
5673
|
+
const textWidth = cardWidth - 2 * m.padH;
|
|
4793
5674
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
4794
5675
|
const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
|
|
4795
5676
|
// Heights first (the card is centered), then the rects.
|
|
4796
|
-
let h =
|
|
5677
|
+
let h = m.padTop;
|
|
4797
5678
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
4798
5679
|
let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
4799
5680
|
if (title.length > 0) {
|
|
4800
|
-
const b = block(title, titleFont, textWidth,
|
|
5681
|
+
const b = block(title, titleFont, textWidth, m.textAlignment);
|
|
4801
5682
|
titleBlock = { ...b, top: h };
|
|
4802
5683
|
h += b.height;
|
|
4803
5684
|
}
|
|
4804
5685
|
if (message.length > 0) {
|
|
4805
|
-
if (titleBlock) h +=
|
|
4806
|
-
const b = block(message, messageFont, textWidth,
|
|
5686
|
+
if (titleBlock) h += m.titleGap;
|
|
5687
|
+
const b = block(message, messageFont, textWidth, m.textAlignment);
|
|
4807
5688
|
messageBlock = { ...b, top: h };
|
|
4808
5689
|
h += b.height;
|
|
4809
5690
|
}
|
|
4810
5691
|
let fieldsTop: number | null = null;
|
|
4811
5692
|
if (fields.length > 0) {
|
|
4812
|
-
if (titleBlock || messageBlock) h +=
|
|
5693
|
+
if (titleBlock || messageBlock) h += m.fieldsGap;
|
|
4813
5694
|
fieldsTop = h;
|
|
4814
5695
|
h += fieldsHeight;
|
|
4815
5696
|
}
|
|
4816
|
-
h +=
|
|
5697
|
+
h += m.buttonsGap;
|
|
4817
5698
|
const headerHeight = h;
|
|
4818
5699
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
4819
|
-
h += rows *
|
|
5700
|
+
h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
|
|
4820
5701
|
// Centered in the safe area, as iOS does.
|
|
4821
5702
|
const { safeArea: inset } = this.env;
|
|
4822
5703
|
const top = y + (y <= 0 ? inset.top : 0);
|
|
4823
5704
|
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
4824
5705
|
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
4825
5706
|
chrome['card'] = card;
|
|
5707
|
+
if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
|
|
4826
5708
|
if (titleBlock) {
|
|
4827
|
-
chrome['title'] = { x: card.x +
|
|
5709
|
+
chrome['title'] = { x: card.x + m.padH, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
4828
5710
|
texts['title'] = titleBlock.layout;
|
|
4829
5711
|
}
|
|
4830
5712
|
if (messageBlock) {
|
|
4831
|
-
chrome['message'] = { x: card.x +
|
|
5713
|
+
chrome['message'] = { x: card.x + m.padH, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
|
|
4832
5714
|
texts['message'] = messageBlock.layout;
|
|
4833
5715
|
}
|
|
4834
5716
|
const buttonsTop = card.y + headerHeight;
|
|
4835
|
-
const buttonsX = card.x +
|
|
4836
|
-
const buttonsWidth = Math.max(0, cardWidth - 2 *
|
|
5717
|
+
const buttonsX = card.x + m.buttonInset;
|
|
5718
|
+
const buttonsWidth = Math.max(0, cardWidth - 2 * m.buttonInset);
|
|
4837
5719
|
if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
|
|
4838
5720
|
if (buttons.length === 0) {
|
|
4839
|
-
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height:
|
|
5721
|
+
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: m.buttonHeight };
|
|
4840
5722
|
} else if (buttons.length === 2) {
|
|
4841
5723
|
// A `cancel` button goes first (leading), as iOS orders them.
|
|
4842
5724
|
const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
|
|
4843
|
-
const half = (buttonsWidth -
|
|
5725
|
+
const half = (buttonsWidth - m.rowSpacing) / 2;
|
|
4844
5726
|
ordered.forEach((button, slot) => {
|
|
4845
5727
|
const i = buttons.indexOf(button as LayoutNode);
|
|
4846
|
-
const rect: Rect = { x: buttonsX + slot * (half +
|
|
5728
|
+
const rect: Rect = { x: buttonsX + slot * (half + m.rowSpacing), y: buttonsTop, width: half, height: m.buttonHeight };
|
|
4847
5729
|
chrome[`button${i}`] = rect;
|
|
4848
5730
|
this.placeAlertButton(button as LayoutNode, cctx, rect);
|
|
4849
5731
|
});
|
|
@@ -4851,9 +5733,9 @@ export class LayoutPass {
|
|
|
4851
5733
|
buttons.forEach((button, i) => {
|
|
4852
5734
|
const rect: Rect = {
|
|
4853
5735
|
x: buttonsX,
|
|
4854
|
-
y: buttonsTop + i * (
|
|
5736
|
+
y: buttonsTop + i * (m.buttonHeight + m.buttonSpacing),
|
|
4855
5737
|
width: buttonsWidth,
|
|
4856
|
-
height:
|
|
5738
|
+
height: m.buttonHeight,
|
|
4857
5739
|
};
|
|
4858
5740
|
chrome[`button${i}`] = rect;
|
|
4859
5741
|
this.placeAlertButton(button, cctx, rect);
|
|
@@ -4905,6 +5787,7 @@ export class LayoutPass {
|
|
|
4905
5787
|
}
|
|
4906
5788
|
const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
|
|
4907
5789
|
chrome['card'] = card;
|
|
5790
|
+
if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
|
|
4908
5791
|
if (titleBlock) {
|
|
4909
5792
|
chrome['title'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
4910
5793
|
texts['title'] = titleBlock.layout;
|
|
@@ -4994,6 +5877,17 @@ export class LayoutPass {
|
|
|
4994
5877
|
const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
|
|
4995
5878
|
const kind = unwrapStyled(child).kind;
|
|
4996
5879
|
if (kind === 'navstack') {
|
|
5880
|
+
// A Mac sheet: its stack draws the sheet's title bar and puts its text
|
|
5881
|
+
// actions in the bottom bar (chrome `actions`, which the page draws).
|
|
5882
|
+
if (this.env.platform === 'macos' && this.sheetIsForm(node)) {
|
|
5883
|
+
const screen = this.macSheetScreen(node);
|
|
5884
|
+
const actions: Rect | null = screen && this.macSheetActionToolbars(screen).length > 0
|
|
5885
|
+
? { x, y: y + size.height - MAC_SHEET_ACTIONS_BAR, width: size.width, height: MAC_SHEET_ACTIONS_BAR }
|
|
5886
|
+
: null;
|
|
5887
|
+
if (actions) this.setChrome(node, { ...chrome, actions });
|
|
5888
|
+
this.place(child, { ...cctx, barInset: 0, macSheet: { actions } }, { width: size.width, height: size.height }, x, y);
|
|
5889
|
+
return;
|
|
5890
|
+
}
|
|
4997
5891
|
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
4998
5892
|
return;
|
|
4999
5893
|
}
|