@swiftbrowser/web 0.26.3 → 0.26.5
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/package.json +1 -1
- package/src/deviceFrame.ts +2 -2
- package/src/layout/engine.ts +663 -188
- package/src/layoutDom.ts +40 -2
- package/src/protocol.ts +12 -0
- package/src/renderer.ts +13 -2
- package/src/styles.css +490 -150
- package/src/symbols.ts +3 -0
package/src/layout/engine.ts
CHANGED
|
@@ -106,6 +106,8 @@ export interface Context {
|
|
|
106
106
|
* its icon, as the Mac app draws them.
|
|
107
107
|
*/
|
|
108
108
|
listCell?: boolean;
|
|
109
|
+
/** A row of a Mac's grouped form (macOS 26): a toggle is a switch at the trailing end, a slider's and a text field's label lead their control, a link has its chevron. */
|
|
110
|
+
groupedRow?: boolean;
|
|
109
111
|
/**
|
|
110
112
|
* The Mac idiom: a tab view's toolbar row at the top of the window. The
|
|
111
113
|
* screens of a tab's navigation stack draw their bar as that row: no
|
|
@@ -120,6 +122,12 @@ export interface Context {
|
|
|
120
122
|
* bar, `actions`, when the sheet has one.
|
|
121
123
|
*/
|
|
122
124
|
macSheet?: { actions: Rect | null };
|
|
125
|
+
/**
|
|
126
|
+
* The Mac idiom: a row of a form in the columns style. A control whose
|
|
127
|
+
* label went to the label column (a stepper's, a date picker's, a
|
|
128
|
+
* picker's) draws without it, at the row's leading edge.
|
|
129
|
+
*/
|
|
130
|
+
formColumn?: boolean;
|
|
123
131
|
/**
|
|
124
132
|
* Phase 8: the y (screen coordinates) where content clears the bars above
|
|
125
133
|
* it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
|
|
@@ -314,35 +322,45 @@ const NAV_GROUP_SPACING = 12;
|
|
|
314
322
|
const NAV_RUN_GAP = 12;
|
|
315
323
|
const NAV_RUN_PAD = 1;
|
|
316
324
|
// The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
|
|
317
|
-
// `macos`): the window's title bar stands in for the
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
325
|
+
// `macos`), measured on macOS 26: the window's title bar stands in for the
|
|
326
|
+
// navigation bar, with no background or rule of its own (the content's
|
|
327
|
+
// color runs up under it). A 52 toolbar holds the back button, the bar items
|
|
328
|
+
// and the title, leading-aligned; a window with nothing in its bar but the
|
|
329
|
+
// title has the compact 32 one, the title leading-aligned after the window
|
|
330
|
+
// buttons. There are no large titles. The items are Liquid Glass, as on iOS
|
|
331
|
+
// 26 but smaller: 36 tall, centered in the bar; an icon a 36 slot,
|
|
332
|
+
// adjacent items in one capsule (the back button in the leading one), a
|
|
333
|
+
// text item a capsule of its own at its width and 10 either side;
|
|
334
|
+
// capsules 8 apart and 8 from the window's edge. On the screen's own window
|
|
323
335
|
// (the top of the screen, when the safe area carries a title bar) the three
|
|
324
|
-
// window buttons take the first
|
|
325
|
-
//
|
|
326
|
-
// harness, stage 3).
|
|
336
|
+
// window buttons take the first 96 of the toolbar; the title is 12 after
|
|
337
|
+
// whatever is before it.
|
|
327
338
|
const MAC_TITLE_BAR_UNIFIED = 52;
|
|
328
|
-
const MAC_TITLE_BAR_PLAIN =
|
|
329
|
-
const MAC_BAR_ITEM_SIZE =
|
|
330
|
-
const MAC_BAR_ITEM_PAD_H =
|
|
331
|
-
const MAC_BAR_RUN_GAP =
|
|
332
|
-
/**
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
const MAC_TITLE_BAR_PLAIN = 32;
|
|
340
|
+
const MAC_BAR_ITEM_SIZE = 36;
|
|
341
|
+
const MAC_BAR_ITEM_PAD_H = 10;
|
|
342
|
+
const MAC_BAR_RUN_GAP = 0;
|
|
343
|
+
/** A capsule of several slots: 3 past its end slots. */
|
|
344
|
+
const MAC_BAR_RUN_PAD = 3;
|
|
345
|
+
/** Between capsules, and between the last one and the window's edge. */
|
|
346
|
+
const MAC_BAR_GROUP_GAP = 8;
|
|
347
|
+
/** The window buttons' room: the first item's slot starts 96 in; a title is 12 after the last thing before it. */
|
|
348
|
+
const MAC_WINDOW_BUTTONS_ROOM = 96;
|
|
349
|
+
const MAC_TITLE_GAP = 12;
|
|
350
|
+
/** A compact title bar's title: 84 in, after the window buttons (9 in, 14 across, 9 apart) and 15. */
|
|
351
|
+
const MAC_COMPACT_TITLE_X = 84;
|
|
352
|
+
/** A Mac bar's trailing items end 8 before the window's edge. */
|
|
353
|
+
const MAC_BAR_MARGIN_TRAILING = 8;
|
|
354
|
+
// A Mac tab view's bar (macOS 26) is a glass capsule centered in the
|
|
355
|
+
// window's 52 toolbar: 36 tall, 8 from the top, its tabs equal segments (the
|
|
356
|
+
// widest title and 11 either side) inside a 4 inset, the selected one a 24
|
|
357
|
+
// gray pill (6 from the capsule's top and bottom).
|
|
358
|
+
const MAC_TAB_ROW_HEIGHT = 52;
|
|
359
|
+
const MAC_TAB_BAR_HEIGHT = 36;
|
|
360
|
+
/** A tab picker's segment: the widest title (13 regular) and 11 either side. */
|
|
361
|
+
const MAC_TAB_ITEM_PAD_H = 11;
|
|
362
|
+
const MAC_TAB_ITEM_INSET = 4;
|
|
363
|
+
const MAC_TAB_ITEM_HEIGHT = 24;
|
|
346
364
|
const MAC_TAB_BAR_MARGIN_H = 20;
|
|
347
365
|
// Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
|
|
348
366
|
// either side of its label, and every `automatic` button outside a list row
|
|
@@ -350,23 +368,28 @@ const MAC_TAB_BAR_MARGIN_H = 20;
|
|
|
350
368
|
// toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
|
|
351
369
|
// pop-up button is 22 tall (24 in a row), its value in a bordered button
|
|
352
370
|
// with 8 either side.
|
|
371
|
+
// macOS 26: a push button is a capsule, 24 tall with 13 either side of its
|
|
372
|
+
// label (a large one 28, an extra large one 36), on a translucent gray (styles.css).
|
|
353
373
|
const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
354
|
-
regular: { padV:
|
|
355
|
-
mini: { padV: 1, padH:
|
|
356
|
-
small: { padV: 2, padH:
|
|
357
|
-
large: { padV:
|
|
358
|
-
extraLarge: { padV:
|
|
374
|
+
regular: { padV: 4, padH: 13, minHeight: 24 },
|
|
375
|
+
mini: { padV: 1, padH: 7, minHeight: 16, textStyle: 'caption' },
|
|
376
|
+
small: { padV: 2, padH: 10, minHeight: 20, textStyle: 'subheadline' },
|
|
377
|
+
large: { padV: 6, padH: 16, minHeight: 28 },
|
|
378
|
+
extraLarge: { padV: 10, padH: 20, minHeight: 36 },
|
|
359
379
|
};
|
|
360
380
|
const MAC_CHECKBOX_SIZE = 14;
|
|
361
381
|
const MAC_CHECKBOX_GAP = 6;
|
|
362
|
-
const MAC_PICKER_MENU_HEIGHT =
|
|
382
|
+
const MAC_PICKER_MENU_HEIGHT = 24;
|
|
363
383
|
// A Mac's segmented control is 22 tall with 13-point regular text, its
|
|
364
384
|
// label (which iOS drops) 10 before it.
|
|
365
|
-
const MAC_SEGMENTED_HEIGHT =
|
|
385
|
+
const MAC_SEGMENTED_HEIGHT = 24;
|
|
386
|
+
/** A segment's room beside its label (macOS 26: the widest label's, every segment). */
|
|
387
|
+
const MAC_SEGMENT_PAD = 18;
|
|
366
388
|
const MAC_SEGMENTED_FONT_SIZE = 13;
|
|
367
389
|
const MAC_SEGMENTED_LABEL_GAP = 10;
|
|
368
390
|
const MAC_PICKER_MENU_ROW_HEIGHT = 24;
|
|
369
|
-
|
|
391
|
+
/** A pop-up's room beyond its widest option: 13 before it, the 8 chevrons 13 after it and 9 from the end. */
|
|
392
|
+
const MAC_PICKER_POPUP_EXTRA = 43;
|
|
370
393
|
// Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
|
|
371
394
|
// rows full width with 16 beside their content, at least 24 tall (28 in a
|
|
372
395
|
// sidebar) with 4 (6) above and below their content, a bordered button's 3;
|
|
@@ -376,15 +399,26 @@ const MAC_PICKER_POPUP_PAD = 8;
|
|
|
376
399
|
// footer (11) sits 8 under the last row, with a rule 5 under it. A
|
|
377
400
|
// sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
|
|
378
401
|
const MAC_LIST_ROW_MIN_HEIGHT = 24;
|
|
379
|
-
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT =
|
|
402
|
+
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 32;
|
|
380
403
|
const MAC_LIST_ROW_VPAD = 4;
|
|
381
404
|
const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
|
|
382
405
|
const MAC_LIST_BUTTON_ROW_VPAD = 3;
|
|
383
406
|
const MAC_LIST_CELL_PADDING = 16;
|
|
407
|
+
/** A sidebar's rows (macOS 26): their content 12 in from the selection's edges, a Label's title 10 after its icon, one line. */
|
|
408
|
+
const MAC_LIST_SIDEBAR_CELL_PADDING = 12;
|
|
409
|
+
/** A sidebar's sections: 13 apart, a header 2 under the top (4 under rows), 4 in from the selection's edge and 4 over its rows, no rule. */
|
|
410
|
+
const MAC_LIST_SIDEBAR_SECTION_GAP = 13;
|
|
411
|
+
const MAC_LIST_SIDEBAR_HEADER_TOP = 2;
|
|
412
|
+
const MAC_LIST_SIDEBAR_HEADER_GAP = 4;
|
|
413
|
+
const MAC_LIST_SIDEBAR_HEADER_INSET = 4;
|
|
414
|
+
const MAC_LIST_SIDEBAR_BADGE_FONT_SIZE = 11;
|
|
415
|
+
const MAC_LIST_SIDEBAR_LABEL_GAP = 10;
|
|
384
416
|
/** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
|
|
385
417
|
const MAC_LIST_LABEL_GAP = 6;
|
|
386
418
|
const MAC_LIST_INSET_SIDEBAR = 10;
|
|
387
|
-
|
|
419
|
+
/** A Mac list's first row starts 10 under the top (the Showcase on macOS 26); a header at the top sits 4 higher (Fidelity's). */
|
|
420
|
+
const MAC_LIST_TOP = 10;
|
|
421
|
+
const MAC_LIST_FIRST_HEADER_LIFT = 4;
|
|
388
422
|
const MAC_LIST_BOTTOM = 8;
|
|
389
423
|
const MAC_LIST_SECTION_GAP = 22;
|
|
390
424
|
const MAC_LIST_HEADER_TOP = 4;
|
|
@@ -392,22 +426,77 @@ const MAC_LIST_HEADER_TOP = 4;
|
|
|
392
426
|
const MAC_LIST_HEADER_GAP = 9;
|
|
393
427
|
const MAC_LIST_FIRST_ROWS_GAP = 10;
|
|
394
428
|
const MAC_LIST_FOOTER_GAP = 8;
|
|
429
|
+
// A Mac's Form (measured in the Mac app): the columns style, with no
|
|
430
|
+
// insets of its own and centered vertically in the room it gets. The labels
|
|
431
|
+
// (13) right-aligned in a column as wide as the widest, from the leading
|
|
432
|
+
// edge; the controls in a column 10 after it that runs to the trailing
|
|
433
|
+
// edge; a section header and a footer (13 regular, the label color) in the
|
|
434
|
+
// controls column; 8 under a header, rows 7 apart, sections touching, a
|
|
435
|
+
// header 4 under the rows before it (right under a footer), a footer 2
|
|
436
|
+
// under the last row.
|
|
437
|
+
const MAC_FORM_LABEL_GAP = 10;
|
|
438
|
+
const MAC_FORM_HEADER_GAP = 8;
|
|
439
|
+
const MAC_FORM_HEADER_TOP = 4;
|
|
440
|
+
const MAC_FORM_ROW_GAP = 7;
|
|
441
|
+
const MAC_FORM_FOOTER_GAP = 2;
|
|
442
|
+
// A Mac's grouped form (`.formStyle(.grouped)`, macOS 26): rounded cards
|
|
443
|
+
// (11) at most 704 wide, centered, at least 20 from the sides; 36 rows (a
|
|
444
|
+
// button's 44) with their content 10 in, a 1 separator between rows 10 in
|
|
445
|
+
// from either side; a header (13 semibold) 10 over its card, 20 below the
|
|
446
|
+
// top; sections 30 apart; a footer 10 under its card.
|
|
447
|
+
const MAC_GROUPED_MAX_WIDTH = 704;
|
|
448
|
+
const MAC_GROUPED_MARGIN = 20;
|
|
449
|
+
const MAC_GROUPED_CELL_PADDING = 10;
|
|
450
|
+
const MAC_GROUPED_ROW_HEIGHT = 36;
|
|
451
|
+
const MAC_GROUPED_ROW_VPAD = 7;
|
|
452
|
+
const MAC_GROUPED_BUTTON_ROW_VPAD = 11;
|
|
453
|
+
const MAC_GROUPED_SEPARATOR = 1;
|
|
454
|
+
const MAC_GROUPED_TOP = 20;
|
|
455
|
+
const MAC_GROUPED_HEADER_GAP = 10;
|
|
456
|
+
const MAC_GROUPED_SECTION_GAP = 30;
|
|
457
|
+
const MAC_GROUPED_FOOTER_GAP = 10;
|
|
458
|
+
/** In a grouped row: the switch, 36 × 18; a slider's track the trailing 55% of the row; a link's chevron 6 wide, 2 in from the content's edge. */
|
|
459
|
+
const MAC_GROUPED_SWITCH_WIDTH = 36;
|
|
460
|
+
const MAC_GROUPED_SWITCH_HEIGHT = 18;
|
|
461
|
+
const MAC_GROUPED_SLIDER_FRACTION = 0.55;
|
|
462
|
+
const MAC_GROUPED_CHEVRON_WIDTH = 8;
|
|
463
|
+
const MAC_GROUPED_LABEL_GAP = 10;
|
|
464
|
+
/** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
|
|
465
|
+
const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
|
|
395
466
|
/** Under a Mac footer: 5 to its rule, the rule. */
|
|
396
467
|
const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
468
|
+
// A Mac's split view (macOS 26): the sidebar is a floating panel, 140 wide,
|
|
469
|
+
// 8 from the window's leading, top and bottom edges, rounded 22, on the
|
|
470
|
+
// toolbar's glass fill with its own top 44 the window's color (the window
|
|
471
|
+
// buttons and, 6 from its trailing edge, the sidebar toggle, a bare 36
|
|
472
|
+
// slot); the detail column starts at its trailing edge. Its title is not
|
|
473
|
+
// shown. A list in it is a sidebar: 32 rows inset 10 in the panel, the
|
|
474
|
+
// selected one a gray rounded fill; a search field over it, 28 tall, 9 under
|
|
475
|
+
// the bar and 10 above the rows. A screen not at the window's leading edge
|
|
476
|
+
// starts its toolbar 10 in.
|
|
477
|
+
const MAC_NAVSPLIT_SIDEBAR_WIDTH = 140;
|
|
478
|
+
const MAC_NAVSPLIT_SIDEBAR_INSET = 8;
|
|
479
|
+
const MAC_NAVSPLIT_TOGGLE_INSET = 6;
|
|
480
|
+
const MAC_SIDEBAR_SEARCH_ABOVE = 9;
|
|
481
|
+
const MAC_SIDEBAR_SEARCH_HEIGHT = 28;
|
|
482
|
+
const MAC_SIDEBAR_SEARCH_BELOW = 10;
|
|
483
|
+
const MAC_SIDEBAR_SEARCH_MARGIN_H = 10;
|
|
484
|
+
const MAC_BAR_LEADING_MARGIN = 10;
|
|
485
|
+
// A Mac's search field is in the toolbar, at its trailing end (macOS 26): a
|
|
486
|
+
// 36 glass capsule like the bar's items, 325 wide (less when the bar is
|
|
487
|
+
// narrow), 8 from the edge; elsewhere a field is 22 tall. A sheet is a
|
|
400
488
|
// window-modal panel that drops from the title bar, 480 × 400 at most,
|
|
401
489
|
// centered, with no grabber.
|
|
402
|
-
const MAC_SEARCH_FIELD_WIDTH =
|
|
490
|
+
const MAC_SEARCH_FIELD_WIDTH = 325;
|
|
403
491
|
const MAC_SEARCH_FIELD_HEIGHT = 22;
|
|
404
|
-
// A Mac's sheet (measured in the Mac app): a 470 panel centered in
|
|
405
|
-
// window, its
|
|
406
|
-
// ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
407
|
-
// confirm) as
|
|
408
|
-
//
|
|
492
|
+
// A Mac's sheet (measured in the Mac app, macOS 26): a 470 panel centered in
|
|
493
|
+
// the window, its 48 title bar (the title 13 bold, 48 in) over the content at
|
|
494
|
+
// its ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
495
|
+
// confirm) as push buttons at its trailing end, 16 in and 8 apart; at least
|
|
496
|
+
// 40 clear of the window's edges.
|
|
409
497
|
const MAC_SHEET_WIDTH = 470;
|
|
410
|
-
const MAC_SHEET_TITLE_BAR =
|
|
498
|
+
const MAC_SHEET_TITLE_BAR = 48;
|
|
499
|
+
const MAC_SHEET_TITLE_X = 48;
|
|
411
500
|
const MAC_SHEET_ACTIONS_BAR = 60;
|
|
412
501
|
const MAC_SHEET_ACTIONS_INSET = 16;
|
|
413
502
|
const MAC_SHEET_ACTIONS_GAP = 8;
|
|
@@ -654,25 +743,25 @@ const ALERT_METRICS: AlertMetrics = {
|
|
|
654
743
|
fieldsGap: ALERT_FIELDS_GAP,
|
|
655
744
|
textAlignment: 'leading',
|
|
656
745
|
};
|
|
657
|
-
// Measured in the Mac app:
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
// side by side 8 apart.
|
|
746
|
+
// Measured in the Mac app (macOS 26): no app icon; the title (13 bold) 20
|
|
747
|
+
// under the top and 22 in, the message (13 regular) 10 under it, both
|
|
748
|
+
// leading-aligned; 28-tall capsule buttons 16 under the message, 16 in and
|
|
749
|
+
// 16 over the bottom, two side by side 8 apart (stacked 4 apart).
|
|
661
750
|
const MAC_ALERT_METRICS: AlertMetrics = {
|
|
662
751
|
width: 260,
|
|
663
|
-
padTop:
|
|
664
|
-
padH:
|
|
752
|
+
padTop: 20,
|
|
753
|
+
padH: 22,
|
|
665
754
|
buttonInset: 16,
|
|
666
|
-
buttonsGap:
|
|
755
|
+
buttonsGap: 16,
|
|
667
756
|
padBottom: 16,
|
|
668
|
-
buttonSpacing:
|
|
757
|
+
buttonSpacing: 4,
|
|
669
758
|
rowSpacing: 8,
|
|
670
|
-
titleGap:
|
|
759
|
+
titleGap: 10,
|
|
671
760
|
buttonHeight: 28,
|
|
672
761
|
fieldHeight: 22,
|
|
673
762
|
fieldSpacing: 8,
|
|
674
763
|
fieldsGap: 12,
|
|
675
|
-
textAlignment: '
|
|
764
|
+
textAlignment: 'leading',
|
|
676
765
|
};
|
|
677
766
|
/** Gap between an action sheet's title and its message. */
|
|
678
767
|
const DIALOG_TITLE_GAP = 4;
|
|
@@ -1449,10 +1538,24 @@ interface ListGroupPlan {
|
|
|
1449
1538
|
outlines: { group: LayoutNode; frame: Rect }[];
|
|
1450
1539
|
}
|
|
1451
1540
|
|
|
1541
|
+
/** A Mac form's label column entry: text the page draws (`layout`), or a view (`node`) placed at the rect. */
|
|
1542
|
+
interface FormLabelPlan {
|
|
1543
|
+
/** Relative to the list origin at scroll 0; a view's rect is its own size, at the column's trailing edge. */
|
|
1544
|
+
rect: Rect;
|
|
1545
|
+
layout: TextLayout | null;
|
|
1546
|
+
node: LayoutNode | null;
|
|
1547
|
+
ctx: Context;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1452
1550
|
interface ListPlan {
|
|
1453
1551
|
size: Size;
|
|
1454
1552
|
contentSize: Size;
|
|
1455
1553
|
groups: ListGroupPlan[];
|
|
1554
|
+
/** The Mac idiom's columns form: its label column's entries. */
|
|
1555
|
+
labels?: FormLabelPlan[];
|
|
1556
|
+
columns?: boolean;
|
|
1557
|
+
/** The columns form's controls column: where it starts (relative to the list origin) and its width. */
|
|
1558
|
+
column?: { x: number; width: number };
|
|
1456
1559
|
}
|
|
1457
1560
|
|
|
1458
1561
|
interface ScrollPlan {
|
|
@@ -1906,7 +2009,7 @@ export class LayoutPass {
|
|
|
1906
2009
|
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1907
2010
|
}
|
|
1908
2011
|
case 'toolbar': {
|
|
1909
|
-
const placement =
|
|
2012
|
+
const placement = this.barPlacement(node);
|
|
1910
2013
|
const navBarItem = placement === 'leading' || placement === 'trailing';
|
|
1911
2014
|
const bottomBarItem = placement === 'bottom';
|
|
1912
2015
|
if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem && ctx.bottomBarItem === bottomBarItem) return ctx;
|
|
@@ -2350,8 +2453,10 @@ export class LayoutPass {
|
|
|
2350
2453
|
* checkbox before the label, or the small switch in a list row (a grouped
|
|
2351
2454
|
* form's), at the trailing edge.
|
|
2352
2455
|
*/
|
|
2353
|
-
private switchMetrics(
|
|
2456
|
+
private switchMetrics(ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
|
|
2354
2457
|
if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
|
|
2458
|
+
// A grouped form's toggle is a switch at the row's trailing end (macOS 26).
|
|
2459
|
+
if (ctx.groupedRow) return { width: MAC_GROUPED_SWITCH_WIDTH, height: MAC_GROUPED_SWITCH_HEIGHT, gap: MAC_GROUPED_LABEL_GAP, checkbox: false };
|
|
2355
2460
|
// A Mac's toggle is a checkbox before its label, in a list row too.
|
|
2356
2461
|
return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
|
|
2357
2462
|
}
|
|
@@ -2448,6 +2553,17 @@ export class LayoutPass {
|
|
|
2448
2553
|
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
2449
2554
|
}
|
|
2450
2555
|
|
|
2556
|
+
/**
|
|
2557
|
+
* Where a `toolbar` is drawn: its placement, except that a Mac has no
|
|
2558
|
+
* bottom bar (SwiftUI's `.bottomBar` is iOS's): its items join the
|
|
2559
|
+
* toolbar's trailing ones, in order, as the native renderer places them
|
|
2560
|
+
* (`.automatic` there).
|
|
2561
|
+
*/
|
|
2562
|
+
private barPlacement(toolbar: LayoutNode): string | null {
|
|
2563
|
+
const placement = str(toolbar.props['placement']);
|
|
2564
|
+
return placement === 'bottom' && this.env.platform === 'macos' ? 'trailing' : placement;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2451
2567
|
/** The toolbars of a Mac sheet's root screen that go in its bottom bar: those whose items are all text buttons (a cancel or confirm action). */
|
|
2452
2568
|
private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
|
|
2453
2569
|
return this.navScreenParts(screen).toolbars.filter((toolbar) => {
|
|
@@ -2548,12 +2664,23 @@ export class LayoutPass {
|
|
|
2548
2664
|
const mac = this.env.platform === 'macos';
|
|
2549
2665
|
const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2550
2666
|
let natural = 0;
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2667
|
+
let widest = 0;
|
|
2668
|
+
for (const option of options) {
|
|
2669
|
+
const optionWidth = this.measurer.measureWidth(option, font);
|
|
2670
|
+
natural += optionWidth + PICKER_SEGMENT_PAD;
|
|
2671
|
+
widest = Math.max(widest, optionWidth);
|
|
2672
|
+
}
|
|
2673
|
+
// A Mac's segments are equal, the widest label and 18 (macOS 26).
|
|
2674
|
+
if (mac) natural = options.length * Math.ceil(widest + MAC_SEGMENT_PAD);
|
|
2675
|
+
// A Mac shows the picker's label before the control (in a form's row it is in the label column).
|
|
2676
|
+
const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
|
|
2554
2677
|
const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
|
|
2678
|
+
// On a Mac it is as wide as its segments; elsewhere it fills the width offered.
|
|
2679
|
+
const width = mac
|
|
2680
|
+
? Math.min(natural + labelWidth, finite(p.width) ? p.width : Infinity)
|
|
2681
|
+
: finite(p.width) ? p.width : natural + labelWidth;
|
|
2555
2682
|
return {
|
|
2556
|
-
size: { width
|
|
2683
|
+
size: { width, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
|
|
2557
2684
|
style: 'segmented',
|
|
2558
2685
|
font,
|
|
2559
2686
|
options,
|
|
@@ -2564,17 +2691,21 @@ export class LayoutPass {
|
|
|
2564
2691
|
};
|
|
2565
2692
|
}
|
|
2566
2693
|
const font = ctx.font;
|
|
2567
|
-
|
|
2694
|
+
// In a Mac form's row the label is in the label column and the pop-up spans the controls column.
|
|
2695
|
+
const labelWidth = ctx.formColumn ? 0 : this.measurer.measureWidth(str(node.props['label']) ?? '', font);
|
|
2568
2696
|
const value = options[selected] ?? '';
|
|
2569
2697
|
const mac = this.env.platform === 'macos';
|
|
2570
2698
|
// The Mac's pop-up button holds the value and the chevrons in a bordered button.
|
|
2571
|
-
const popup = mac && !ctx.listRow;
|
|
2572
|
-
|
|
2699
|
+
const popup = mac && (!ctx.listRow || ctx.formColumn === true);
|
|
2700
|
+
// macOS 26: a pop-up is as wide as its widest option, 13 before the label and the chevrons 9 from its end.
|
|
2701
|
+
const popupWidth = (): number => Math.ceil(Math.max(...options.map((option) => this.measurer.measureWidth(option, font)), 0) + MAC_PICKER_POPUP_EXTRA);
|
|
2702
|
+
const valueWidth = popup ? popupWidth() : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
|
|
2573
2703
|
const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
|
|
2574
2704
|
return {
|
|
2575
2705
|
size: {
|
|
2576
|
-
|
|
2577
|
-
|
|
2706
|
+
// In a Mac columns form's row: the pop-up alone, at its width.
|
|
2707
|
+
width: ctx.formColumn ? Math.min(valueWidth, finite(p.width) ? p.width : Infinity) : finite(p.width) ? p.width : natural,
|
|
2708
|
+
height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2578
2709
|
},
|
|
2579
2710
|
style: 'menu',
|
|
2580
2711
|
font,
|
|
@@ -2635,7 +2766,8 @@ export class LayoutPass {
|
|
|
2635
2766
|
const room = Math.max(0, size.width - PICKER_MENU_GAP);
|
|
2636
2767
|
let labelWidth = plan.labelWidth;
|
|
2637
2768
|
let valueWidth = plan.valueWidth;
|
|
2638
|
-
|
|
2769
|
+
// In a Mac form's controls column the pop-up spans the column (its label is in the label column).
|
|
2770
|
+
if (!ctx.formColumn && labelWidth + valueWidth > room) {
|
|
2639
2771
|
if (labelWidth <= valueWidth) {
|
|
2640
2772
|
labelWidth = Math.min(labelWidth, room / 2);
|
|
2641
2773
|
valueWidth = room - labelWidth;
|
|
@@ -2648,7 +2780,7 @@ export class LayoutPass {
|
|
|
2648
2780
|
chrome['label'] = { x, y, width: labelWidth, height: size.height };
|
|
2649
2781
|
chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
|
|
2650
2782
|
// The page draws the Mac's pop-up button around the value (the engine padded it).
|
|
2651
|
-
if (this.env.platform === 'macos' && !ctx.listRow) chrome['popup'] = chrome['value'];
|
|
2783
|
+
if (this.env.platform === 'macos' && (!ctx.listRow || ctx.formColumn === true)) chrome['popup'] = chrome['value'];
|
|
2652
2784
|
else delete chrome['popup'];
|
|
2653
2785
|
}
|
|
2654
2786
|
this.setChrome(node, chrome);
|
|
@@ -2692,7 +2824,7 @@ export class LayoutPass {
|
|
|
2692
2824
|
// A disclosure group's content sits 8 under its label, as SwiftUI's does.
|
|
2693
2825
|
const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
|
|
2694
2826
|
// A Mac list row's Label (no icon column there): the title 6 after the icon.
|
|
2695
|
-
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
|
|
2827
|
+
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [cctx.sidebarList ? MAC_LIST_SIDEBAR_LABEL_GAP : MAC_LIST_LABEL_GAP];
|
|
2696
2828
|
if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
|
|
2697
2829
|
const gaps: number[] = [];
|
|
2698
2830
|
for (let i = 0; i < n - 1; i++) {
|
|
@@ -3159,8 +3291,15 @@ export class LayoutPass {
|
|
|
3159
3291
|
// List
|
|
3160
3292
|
// -------------------------------------------------------------------------
|
|
3161
3293
|
|
|
3294
|
+
/** A list's style: its own, but on a Mac an automatic list in a split view's sidebar is a sidebar, as SwiftUI's is. */
|
|
3295
|
+
private listStyleOf(node: LayoutNode, ctx: Context): string {
|
|
3296
|
+
const style = str(node.props['style']) ?? 'insetGrouped';
|
|
3297
|
+
return this.env.platform === 'macos' && ctx.splitSidebar && style === 'insetGrouped' ? 'sidebar' : style;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3162
3300
|
private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
3163
3301
|
return this.plan(node, ctx, p, () => {
|
|
3302
|
+
if (this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] !== 'grouped') return this.macFormPlan(node, ctx, p);
|
|
3164
3303
|
const { screen } = this.env;
|
|
3165
3304
|
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
3166
3305
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
@@ -3168,39 +3307,51 @@ export class LayoutPass {
|
|
|
3168
3307
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
3169
3308
|
// the space between sections. `sidebar` (iPadOS) is laid out like
|
|
3170
3309
|
// `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
|
|
3171
|
-
const listStyle =
|
|
3310
|
+
const listStyle = this.listStyleOf(node, ctx);
|
|
3172
3311
|
// In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
|
|
3173
3312
|
// (cards, chevrons, 52pt rows) and only its sections' folding remains.
|
|
3174
3313
|
const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
|
|
3175
|
-
// `.environment(\.defaultMinListRowHeight, _)
|
|
3314
|
+
// `.environment(\.defaultMinListRowHeight, _)` (the list's `minRowHeight`); 52 by default (44 in a sidebar, 24 on a Mac).
|
|
3176
3315
|
// The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
|
|
3177
3316
|
// 11-point bold headers, a sidebar's rows inset 10 and a grouped
|
|
3178
3317
|
// form's cards 20.
|
|
3179
3318
|
const mac = this.env.platform === 'macos';
|
|
3319
|
+
const macGrouped = mac && node.props['form'] === true && node.props['formStyle'] === 'grouped';
|
|
3180
3320
|
const minRowHeight =
|
|
3181
3321
|
num(node.props['minRowHeight']) ??
|
|
3322
|
+
(macGrouped ? MAC_GROUPED_ROW_HEIGHT : null) ??
|
|
3182
3323
|
(sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
3183
3324
|
// A Mac's lists have no cards: the rows run the whole width (a
|
|
3184
3325
|
// sidebar's inset 10).
|
|
3185
3326
|
const inset = mac
|
|
3186
|
-
?
|
|
3187
|
-
?
|
|
3188
|
-
:
|
|
3327
|
+
? macGrouped
|
|
3328
|
+
? Math.max(MAC_GROUPED_MARGIN, (layoutWidth - MAC_GROUPED_MAX_WIDTH) / 2)
|
|
3329
|
+
: listStyle === 'sidebar'
|
|
3330
|
+
? MAC_LIST_INSET_SIDEBAR
|
|
3331
|
+
// SwiftUI's inset list style: the list's own 16, which
|
|
3332
|
+
// `listRowInsets` does not touch (its rows have no padding of
|
|
3333
|
+
// their own); a row's separator runs to the trailing edge of it.
|
|
3334
|
+
: MAC_LIST_CELL_PADDING
|
|
3189
3335
|
: listStyle === 'insetGrouped' || listStyle === 'sidebar'
|
|
3190
3336
|
? LIST_INSET
|
|
3191
3337
|
: 0;
|
|
3192
3338
|
const plain = listStyle === 'plain';
|
|
3193
|
-
const cellPadding = mac
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
const
|
|
3197
|
-
const
|
|
3339
|
+
const cellPadding = mac
|
|
3340
|
+
? macGrouped ? MAC_GROUPED_CELL_PADDING : listStyle === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : 0
|
|
3341
|
+
: LIST_CELL_PADDING;
|
|
3342
|
+
const macSidebar = mac && listStyle === 'sidebar';
|
|
3343
|
+
const listTop = plain || macSidebar ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_TOP : LIST_TOP;
|
|
3344
|
+
const listBottom = plain ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
|
|
3345
|
+
const sectionGap = plain ? 0 : macGrouped ? MAC_GROUPED_SECTION_GAP : macSidebar ? MAC_LIST_SIDEBAR_SECTION_GAP : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
|
|
3346
|
+
const headerGap = macGrouped ? MAC_GROUPED_HEADER_GAP : macSidebar ? MAC_LIST_SIDEBAR_HEADER_GAP : mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
|
|
3198
3347
|
const cardX = inset;
|
|
3199
3348
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
3200
3349
|
const contentX = inset + cellPadding;
|
|
3201
3350
|
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
|
|
3202
3351
|
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
3203
|
-
const headerFont =
|
|
3352
|
+
const headerFont = macGrouped
|
|
3353
|
+
? makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 600)
|
|
3354
|
+
: mac
|
|
3204
3355
|
? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
|
|
3205
3356
|
: makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
|
|
3206
3357
|
const footerFont = makeFont(
|
|
@@ -3209,7 +3360,15 @@ export class LayoutPass {
|
|
|
3209
3360
|
400,
|
|
3210
3361
|
);
|
|
3211
3362
|
// A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
|
|
3212
|
-
const rowCtx: Context = {
|
|
3363
|
+
const rowCtx: Context = {
|
|
3364
|
+
...this.childContext(node, ctx),
|
|
3365
|
+
listRow: true,
|
|
3366
|
+
labelColumn: !mac,
|
|
3367
|
+
listCell: mac,
|
|
3368
|
+
sidebarList: listStyle === 'sidebar',
|
|
3369
|
+
...(mac && listStyle === 'sidebar' ? { lineLimit: 1 } : {}),
|
|
3370
|
+
...(macGrouped ? { groupedRow: true } : {}),
|
|
3371
|
+
};
|
|
3213
3372
|
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
3214
3373
|
// leading control (6 in from the card's edge) and its content shifts
|
|
3215
3374
|
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
@@ -3246,6 +3405,8 @@ export class LayoutPass {
|
|
|
3246
3405
|
};
|
|
3247
3406
|
|
|
3248
3407
|
const groups: ListGroupPlan[] = [];
|
|
3408
|
+
// A Mac grouped form's text fields: their titles lead their rows (the page draws them as the columns form's labels).
|
|
3409
|
+
const fieldLabels: FormLabelPlan[] = [];
|
|
3249
3410
|
let y = listTop;
|
|
3250
3411
|
const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
|
|
3251
3412
|
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
@@ -3261,6 +3422,8 @@ export class LayoutPass {
|
|
|
3261
3422
|
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
3262
3423
|
y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
3263
3424
|
}
|
|
3425
|
+
// A Mac list's first header sits 4 above where its first row would.
|
|
3426
|
+
if (mac && !macGrouped && !macSidebar && !previous && (source.headerView !== null || (source.section !== null && (str(source.section.props['header']) ?? '').length > 0))) y -= MAC_LIST_FIRST_HEADER_LIFT;
|
|
3264
3427
|
const top = y;
|
|
3265
3428
|
let header: ListGroupPlan['header'] = null;
|
|
3266
3429
|
let footer: ListGroupPlan['footer'] = null;
|
|
@@ -3273,10 +3436,12 @@ export class LayoutPass {
|
|
|
3273
3436
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
3274
3437
|
if (headerText !== null && headerText.length > 0) {
|
|
3275
3438
|
const { layout, size } = makeText(headerText, headerFont);
|
|
3276
|
-
if (mac && previous) y += MAC_LIST_HEADER_TOP;
|
|
3439
|
+
if (mac && !macGrouped && previous) y += MAC_LIST_HEADER_TOP;
|
|
3440
|
+
else if (macSidebar) y += MAC_LIST_SIDEBAR_HEADER_TOP;
|
|
3277
3441
|
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
3278
|
-
|
|
3279
|
-
|
|
3442
|
+
const headerX = macSidebar ? inset + MAC_LIST_SIDEBAR_HEADER_INSET : contentX;
|
|
3443
|
+
header = { rect: { x: headerX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
3444
|
+
y = headerY + size.height + headerGap + (mac && !macSidebar && !macGrouped && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
|
|
3280
3445
|
}
|
|
3281
3446
|
const cardTop = y;
|
|
3282
3447
|
const rows: ListRowPlan[] = [];
|
|
@@ -3294,7 +3459,9 @@ export class LayoutPass {
|
|
|
3294
3459
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
3295
3460
|
// width (plus a gap) at the trailing edge.
|
|
3296
3461
|
const hints = rowHintsOf(rowNode);
|
|
3297
|
-
const defaultVPad =
|
|
3462
|
+
const defaultVPad = macGrouped
|
|
3463
|
+
? unwrapStyled(rowNode).kind === 'button' ? MAC_GROUPED_BUTTON_ROW_VPAD : MAC_GROUPED_ROW_VPAD
|
|
3464
|
+
: listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
|
|
3298
3465
|
// An outline's rows start LIST_OUTLINE_INDENT further in per level.
|
|
3299
3466
|
const indent = rowSource.depth * LIST_OUTLINE_INDENT;
|
|
3300
3467
|
const insets: EdgeInsets = hints.insets
|
|
@@ -3307,7 +3474,9 @@ export class LayoutPass {
|
|
|
3307
3474
|
};
|
|
3308
3475
|
// A group's label row keeps the chevron's width at its trailing edge, as a link's does (a Mac's rows have no chevron).
|
|
3309
3476
|
const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
|
|
3310
|
-
|
|
3477
|
+
// A Mac sidebar's badge is the small (11) text (macOS 26).
|
|
3478
|
+
const badgeFont = macSidebar ? makeFont('system', MAC_LIST_SIDEBAR_BADGE_FONT_SIZE, 400) : rowCtx.font;
|
|
3479
|
+
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, badgeFont) : 0;
|
|
3311
3480
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
3312
3481
|
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
3313
3482
|
// leading control and its content shifts right; a movable one a
|
|
@@ -3317,8 +3486,12 @@ export class LayoutPass {
|
|
|
3317
3486
|
const hasGrip = listRow !== null && listRow.props['movable'] === true;
|
|
3318
3487
|
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
3319
3488
|
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
3320
|
-
const
|
|
3321
|
-
const
|
|
3489
|
+
const fieldNode = macGrouped ? unwrapStyled(rowNode) : null;
|
|
3490
|
+
const fieldLabel = fieldNode && fieldNode.kind === 'textfield' ? str(fieldNode.props['placeholder']) || null : null;
|
|
3491
|
+
const fieldLabelWidth = fieldLabel ? this.measurer.measureWidth(fieldLabel, rowCtx.font) : 0;
|
|
3492
|
+
const fieldShift = fieldLabel ? Math.ceil(fieldLabelWidth) + MAC_GROUPED_LABEL_GAP : 0;
|
|
3493
|
+
const rowContentX = cardX + insets.leading + editShift + fieldShift;
|
|
3494
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve - fieldShift);
|
|
3322
3495
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
3323
3496
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
3324
3497
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -3338,7 +3511,7 @@ export class LayoutPass {
|
|
|
3338
3511
|
hints.badge !== null
|
|
3339
3512
|
? {
|
|
3340
3513
|
rect: { x: cardX + cardWidth - insets.trailing - badgeWidth, y, width: badgeWidth, height: rowHeight },
|
|
3341
|
-
layout: { font:
|
|
3514
|
+
layout: { font: badgeFont, lines: [hints.badge], alignment: 'trailing', truncated: false },
|
|
3342
3515
|
}
|
|
3343
3516
|
: null,
|
|
3344
3517
|
edit: listRow
|
|
@@ -3353,7 +3526,17 @@ export class LayoutPass {
|
|
|
3353
3526
|
separatorHidden: hints.separatorHidden,
|
|
3354
3527
|
outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
|
|
3355
3528
|
});
|
|
3529
|
+
if (fieldLabel) {
|
|
3530
|
+
fieldLabels.push({
|
|
3531
|
+
rect: { x: cardX + insets.leading, y: y + (rowHeight - rowCtx.font.lineHeight) / 2, width: Math.ceil(fieldLabelWidth), height: rowCtx.font.lineHeight },
|
|
3532
|
+
layout: { font: rowCtx.font, lines: [fieldLabel], alignment: 'leading', truncated: false },
|
|
3533
|
+
node: null,
|
|
3534
|
+
ctx: rowCtx,
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3356
3537
|
y += rowHeight;
|
|
3538
|
+
// A Mac grouped form's separators are a point between its rows.
|
|
3539
|
+
if (macGrouped && rowSource !== source.rows[source.rows.length - 1]) y += MAC_GROUPED_SEPARATOR;
|
|
3357
3540
|
}
|
|
3358
3541
|
// Each group's rect: its label's row, then the rows under it that sit deeper, up to the next at its level or above.
|
|
3359
3542
|
const outlines: ListGroupPlan['outlines'] = [];
|
|
@@ -3368,10 +3551,10 @@ export class LayoutPass {
|
|
|
3368
3551
|
});
|
|
3369
3552
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
3370
3553
|
if (footerText !== null && footerText.length > 0) {
|
|
3371
|
-
y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3554
|
+
y += macGrouped ? MAC_GROUPED_FOOTER_GAP : mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3372
3555
|
const { layout, size } = makeText(footerText, footerFont);
|
|
3373
3556
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
3374
|
-
y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3557
|
+
y += size.height + (mac && !macGrouped ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3375
3558
|
}
|
|
3376
3559
|
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
3377
3560
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
@@ -3393,7 +3576,7 @@ export class LayoutPass {
|
|
|
3393
3576
|
width,
|
|
3394
3577
|
height: p.height === null ? contentHeight : p.height,
|
|
3395
3578
|
};
|
|
3396
|
-
return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups };
|
|
3579
|
+
return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups, ...(fieldLabels.length > 0 ? { labels: fieldLabels } : {}) };
|
|
3397
3580
|
});
|
|
3398
3581
|
}
|
|
3399
3582
|
|
|
@@ -3647,6 +3830,9 @@ export class LayoutPass {
|
|
|
3647
3830
|
width: LIST_CHEVRON_WIDTH,
|
|
3648
3831
|
height: size.height,
|
|
3649
3832
|
};
|
|
3833
|
+
} else if (ctx.groupedRow) {
|
|
3834
|
+
// A grouped form's link has the small chevron at its trailing end (macOS 26).
|
|
3835
|
+
chrome['chevron'] = { x: x + size.width - MAC_GROUPED_CHEVRON_WIDTH, y, width: MAC_GROUPED_CHEVRON_WIDTH, height: size.height };
|
|
3650
3836
|
}
|
|
3651
3837
|
this.setChrome(node, chrome);
|
|
3652
3838
|
}
|
|
@@ -3915,24 +4101,238 @@ export class LayoutPass {
|
|
|
3915
4101
|
return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
|
|
3916
4102
|
}
|
|
3917
4103
|
|
|
4104
|
+
/** The labeled control a form row's stack is shifted to (the form's alignment guide), if it has one. */
|
|
4105
|
+
private macFormGuide(row: LayoutNode): LayoutNode | null {
|
|
4106
|
+
const inner = unwrapStyled(row);
|
|
4107
|
+
if (inner.kind !== 'hstack') return null;
|
|
4108
|
+
return inner.children.find((child) => MAC_FORM_LABELED_KINDS.includes(unwrapStyled(child).kind)) ?? null;
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
/**
|
|
4112
|
+
* The Mac idiom's `Form` (MAC_FORM_* above, docs/notes/macos.md): the
|
|
4113
|
+
* columns style. A row whose control carries a label (a text field's
|
|
4114
|
+
* title, a picker's, a stepper's, a date picker's, a slider's) puts it
|
|
4115
|
+
* in the label column, right-aligned, and the control in the controls
|
|
4116
|
+
* column at the control's own height; a toggle, a button, text and other
|
|
4117
|
+
* rows go in the controls column whole. A stack row with such a control
|
|
4118
|
+
* in it is shifted so the control starts at the controls column, what is
|
|
4119
|
+
* before it reaching into the label column, as the form's alignment guide
|
|
4120
|
+
* does on a Mac. Headers and footers are 13-point text in the controls
|
|
4121
|
+
* column. No cards, no separators.
|
|
4122
|
+
*/
|
|
4123
|
+
private macFormPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
4124
|
+
const { screen } = this.env;
|
|
4125
|
+
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
4126
|
+
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
4127
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false };
|
|
4128
|
+
const font = rowCtx.font;
|
|
4129
|
+
const textFont = makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 400);
|
|
4130
|
+
type Row = { node: LayoutNode; label: string | null; labelNode: LayoutNode | null; guide: LayoutNode | null };
|
|
4131
|
+
type Source = { section: LayoutNode | null; rows: Row[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
4132
|
+
// The rows, each with what goes in the label column.
|
|
4133
|
+
const classify = (row: LayoutNode): Row => {
|
|
4134
|
+
const inner = unwrapStyled(row);
|
|
4135
|
+
switch (inner.kind) {
|
|
4136
|
+
case 'textfield':
|
|
4137
|
+
return { node: row, label: str(inner.props['placeholder']) || null, labelNode: null, guide: null };
|
|
4138
|
+
case 'picker':
|
|
4139
|
+
return { node: row, label: str(inner.props['label']) || null, labelNode: null, guide: null };
|
|
4140
|
+
case 'stepper':
|
|
4141
|
+
case 'datepicker':
|
|
4142
|
+
return { node: row, label: null, labelNode: inner.props['labelsHidden'] === true ? null : this.contentOf(inner), guide: null };
|
|
4143
|
+
case 'slider': {
|
|
4144
|
+
const names = Array.isArray(inner.props['labels']) ? inner.props['labels'].map(str) : [];
|
|
4145
|
+
const at = names.indexOf('label');
|
|
4146
|
+
return { node: row, label: null, labelNode: at >= 0 ? (inner.children[at] ?? null) : null, guide: null };
|
|
4147
|
+
}
|
|
4148
|
+
default:
|
|
4149
|
+
return { node: row, label: null, labelNode: null, guide: this.macFormGuide(row) };
|
|
4150
|
+
}
|
|
4151
|
+
};
|
|
4152
|
+
const sources: Source[] = [];
|
|
4153
|
+
for (const child of node.children) {
|
|
4154
|
+
if (child.kind === 'section') {
|
|
4155
|
+
const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter').map(classify);
|
|
4156
|
+
sources.push({ section: child, rows, headerView: child.children.find((row) => row.kind === 'sectionheader') ?? null, footerView: child.children.find((row) => row.kind === 'sectionfooter') ?? null });
|
|
4157
|
+
} else {
|
|
4158
|
+
const last = sources[sources.length - 1];
|
|
4159
|
+
if (last && last.section === null) last.rows.push(classify(child));
|
|
4160
|
+
else sources.push({ section: null, rows: [classify(child)], headerView: null, footerView: null });
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
// The label column: as wide as the widest label (a view label at its ideal size).
|
|
4164
|
+
const labelCtx: Context = { ...this.childContext(node, ctx), stackAxis: null, listRow: false, listCell: true };
|
|
4165
|
+
let labelWidth = 0;
|
|
4166
|
+
const labelSizes = new Map<LayoutNode, Size>();
|
|
4167
|
+
for (const source of sources) {
|
|
4168
|
+
for (const row of source.rows) {
|
|
4169
|
+
if (row.label !== null) labelWidth = Math.max(labelWidth, this.measurer.measureWidth(row.label, font));
|
|
4170
|
+
else if (row.labelNode) {
|
|
4171
|
+
const size = this.measure(row.labelNode, labelCtx, { width: null, height: null });
|
|
4172
|
+
labelSizes.set(row.labelNode, size);
|
|
4173
|
+
labelWidth = Math.max(labelWidth, size.width);
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
labelWidth = Math.ceil(labelWidth);
|
|
4178
|
+
const controlX = labelWidth + MAC_FORM_LABEL_GAP;
|
|
4179
|
+
const controlWidth = Math.max(0, layoutWidth - controlX);
|
|
4180
|
+
const textCtx: Context = { ...labelCtx, font: textFont };
|
|
4181
|
+
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
4182
|
+
const wrapped = wrapText(text, textFont, controlWidth, null, this.measurer);
|
|
4183
|
+
return { layout: { font: textFont, lines: wrapped.lines, alignment: 'leading', truncated: false }, size: { width: wrapped.width, height: textHeight(wrapped.lines.length, textFont) } };
|
|
4184
|
+
};
|
|
4185
|
+
const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
|
|
4186
|
+
if (!view) return null;
|
|
4187
|
+
const proposal: Proposal = { width: controlWidth, height: null };
|
|
4188
|
+
const size = this.measure(view, textCtx, proposal);
|
|
4189
|
+
return { node: view, ctx: textCtx, proposal, rect: { x: controlX, y, width: size.width, height: size.height } };
|
|
4190
|
+
};
|
|
4191
|
+
// Built from a top, then again centered in the room when the rows are shorter than it.
|
|
4192
|
+
const build = (startY: number): { groups: ListGroupPlan[]; labels: FormLabelPlan[]; end: number } => {
|
|
4193
|
+
const groups: ListGroupPlan[] = [];
|
|
4194
|
+
const labels: FormLabelPlan[] = [];
|
|
4195
|
+
let y = startY;
|
|
4196
|
+
for (let g = 0; g < sources.length; g++) {
|
|
4197
|
+
const source = sources[g] as Source;
|
|
4198
|
+
const previous = groups[g - 1];
|
|
4199
|
+
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
4200
|
+
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
4201
|
+
const hasHeader = (headerText !== null && headerText.length > 0) || (source.headerView !== null && this.measure(source.headerView, textCtx, { width: controlWidth, height: null }).height > 0);
|
|
4202
|
+
// A header after rows is 4 under them; right under a footer.
|
|
4203
|
+
if (previous && hasHeader && previous.footer === null && !(previous.footerView !== null && previous.footerView.rect.height > 0)) y += MAC_FORM_HEADER_TOP;
|
|
4204
|
+
const top = y;
|
|
4205
|
+
let header: ListGroupPlan['header'] = null;
|
|
4206
|
+
let footer: ListGroupPlan['footer'] = null;
|
|
4207
|
+
const headerView = supplement(source.headerView, y);
|
|
4208
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + MAC_FORM_HEADER_GAP;
|
|
4209
|
+
if (headerText !== null && headerText.length > 0) {
|
|
4210
|
+
const { layout, size } = makeText(headerText);
|
|
4211
|
+
header = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4212
|
+
y += size.height + MAC_FORM_HEADER_GAP;
|
|
4213
|
+
}
|
|
4214
|
+
const cardTop = y;
|
|
4215
|
+
const rows: ListRowPlan[] = [];
|
|
4216
|
+
source.rows.forEach((row, i) => {
|
|
4217
|
+
if (i > 0) y += MAC_FORM_ROW_GAP;
|
|
4218
|
+
if (row.node.kind === 'lazyspacer') {
|
|
4219
|
+
const rect: Rect = { x: controlX, y, width: controlWidth, height: 0 };
|
|
4220
|
+
rows.push({ node: row.node, proposal: { width: controlWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
4221
|
+
return;
|
|
4222
|
+
}
|
|
4223
|
+
// A shifted stack is proposed the whole row less the trailing inset; its place is set when the list is placed.
|
|
4224
|
+
// A shifted stack is proposed the whole row; its place and width are set when the list is placed.
|
|
4225
|
+
const proposal: Proposal = { width: row.guide ? layoutWidth : controlWidth, height: null };
|
|
4226
|
+
const contentSize = this.measure(row.node, rowCtx, proposal);
|
|
4227
|
+
const labelSize = row.labelNode ? labelSizes.get(row.labelNode) ?? ZERO : row.label !== null ? { width: this.measurer.measureWidth(row.label, font), height: font.lineHeight } : ZERO;
|
|
4228
|
+
const rowHeight = Math.max(contentSize.height, labelSize.height);
|
|
4229
|
+
// 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.
|
|
4230
|
+
const field = unwrapStyled(row.node).kind === 'textfield';
|
|
4231
|
+
rows.push({
|
|
4232
|
+
node: row.node,
|
|
4233
|
+
proposal,
|
|
4234
|
+
contentSize,
|
|
4235
|
+
row: field ? { x: controlX, y, width: controlWidth, height: rowHeight } : { x: 0, y, width: layoutWidth, height: rowHeight },
|
|
4236
|
+
content: { x: row.guide ? 0 : controlX, y: y + (rowHeight - contentSize.height) / 2, width: contentSize.width, height: contentSize.height },
|
|
4237
|
+
badge: null,
|
|
4238
|
+
edit: null,
|
|
4239
|
+
separatorHidden: { top: true, bottom: true },
|
|
4240
|
+
});
|
|
4241
|
+
if (row.label !== null) {
|
|
4242
|
+
labels.push({
|
|
4243
|
+
rect: { x: 0, y: y + (rowHeight - labelSize.height) / 2, width: labelWidth, height: labelSize.height },
|
|
4244
|
+
layout: { font, lines: [row.label], alignment: 'trailing', truncated: false },
|
|
4245
|
+
node: null,
|
|
4246
|
+
ctx: labelCtx,
|
|
4247
|
+
});
|
|
4248
|
+
} else if (row.labelNode) {
|
|
4249
|
+
labels.push({
|
|
4250
|
+
rect: { x: labelWidth - labelSize.width, y: y + (rowHeight - labelSize.height) / 2, width: labelSize.width, height: labelSize.height },
|
|
4251
|
+
layout: null,
|
|
4252
|
+
node: row.labelNode,
|
|
4253
|
+
ctx: labelCtx,
|
|
4254
|
+
});
|
|
4255
|
+
}
|
|
4256
|
+
y += rowHeight;
|
|
4257
|
+
});
|
|
4258
|
+
// The rows' card spans the form: the label column is drawn in it too.
|
|
4259
|
+
const card: Rect = { x: 0, y: cardTop, width: layoutWidth, height: y - cardTop };
|
|
4260
|
+
if (footerText !== null && footerText.length > 0) {
|
|
4261
|
+
y += MAC_FORM_FOOTER_GAP;
|
|
4262
|
+
const { layout, size } = makeText(footerText);
|
|
4263
|
+
footer = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4264
|
+
y += size.height;
|
|
4265
|
+
}
|
|
4266
|
+
let footerView = supplement(source.footerView, y + MAC_FORM_FOOTER_GAP);
|
|
4267
|
+
if (footerView && footerView.rect.height > 0) y += MAC_FORM_FOOTER_GAP + footerView.rect.height;
|
|
4268
|
+
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
4269
|
+
groups.push({ section: source.section, frame: { x: 0, y: top, width: layoutWidth, height: y - top }, header, footer, headerView, footerView, card, rows, outlines: [] });
|
|
4270
|
+
}
|
|
4271
|
+
return { groups, labels, end: y };
|
|
4272
|
+
};
|
|
4273
|
+
let built = build(0);
|
|
4274
|
+
// Centered in the room under the bar when shorter than it (the Mac's columns form does not scroll).
|
|
4275
|
+
const scrollInset = this.scrollInsets.get(node);
|
|
4276
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
4277
|
+
const room = finite(p.height) ? p.height - inset : null;
|
|
4278
|
+
if (room !== null && room > built.end) built = build((room - built.end) / 2);
|
|
4279
|
+
const contentHeight = built.end;
|
|
4280
|
+
return {
|
|
4281
|
+
size: { width, height: p.height === null ? contentHeight : p.height },
|
|
4282
|
+
contentSize: { width: layoutWidth, height: contentHeight },
|
|
4283
|
+
groups: built.groups,
|
|
4284
|
+
labels: built.labels,
|
|
4285
|
+
columns: true,
|
|
4286
|
+
column: { x: controlX, width: controlWidth },
|
|
4287
|
+
};
|
|
4288
|
+
}
|
|
4289
|
+
|
|
3918
4290
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
3919
4291
|
const plan = this.listPlan(node, ctx, p);
|
|
3920
|
-
const
|
|
4292
|
+
const columns = plan.columns === true;
|
|
4293
|
+
const rowCtx: Context = columns
|
|
4294
|
+
? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
|
|
4295
|
+
: {
|
|
4296
|
+
...this.childContext(node, ctx),
|
|
4297
|
+
listRow: true,
|
|
4298
|
+
labelColumn: this.env.platform !== 'macos',
|
|
4299
|
+
listCell: this.env.platform === 'macos',
|
|
4300
|
+
sidebarList: this.listStyleOf(node, ctx) === 'sidebar',
|
|
4301
|
+
...(this.env.platform === 'macos' && this.listStyleOf(node, ctx) === 'sidebar' ? { lineLimit: 1 } : {}),
|
|
4302
|
+
...(this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] === 'grouped' ? { groupedRow: true } : {}),
|
|
4303
|
+
};
|
|
3921
4304
|
const mac = this.env.platform === 'macos';
|
|
3922
|
-
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
4305
|
+
const cellPadding = mac ? (this.listStyleOf(node, ctx) === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : MAC_LIST_CELL_PADDING) : LIST_CELL_PADDING;
|
|
3923
4306
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
3924
4307
|
// Under its screen's bar (and the large title that scrolls with it), the
|
|
3925
4308
|
// rows start below them.
|
|
3926
4309
|
const scrollInset = this.scrollInsets.get(node);
|
|
3927
4310
|
const listChrome: Record<string, Rect> = {};
|
|
4311
|
+
const listTexts: Record<string, TextLayout> = {};
|
|
3928
4312
|
if (covered) listChrome['bottomInset'] = covered;
|
|
4313
|
+
// The page draws a sidebar's look (`data-sb-list-look`) for a list the engine laid out as one.
|
|
4314
|
+
if (this.listStyleOf(node, ctx) === 'sidebar') listChrome['sidebarLook'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4315
|
+
// A Mac's grouped form: the page draws its cards.
|
|
4316
|
+
if (this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] === 'grouped') listChrome['grouped'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
3929
4317
|
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
3930
4318
|
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
3931
|
-
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
3932
4319
|
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
3933
4320
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
3934
4321
|
y += inset;
|
|
3935
4322
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
4323
|
+
// The Mac idiom's columns form: the label column (chrome `formLabel<i>`
|
|
4324
|
+
// with its text, or a view placed there) and the flag for the page.
|
|
4325
|
+
if (columns) listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4326
|
+
{
|
|
4327
|
+
(plan.labels ?? []).forEach((label, i) => {
|
|
4328
|
+
if (label.layout) {
|
|
4329
|
+
listChrome[`formLabel${i}`] = offset(label.rect);
|
|
4330
|
+
listTexts[`formLabel${i}`] = label.layout;
|
|
4331
|
+
}
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
4335
|
+
if (node.id >= 0 && Object.keys(listTexts).length > 0) this.chromeText.set(node.id, listTexts);
|
|
3936
4336
|
for (const group of plan.groups) {
|
|
3937
4337
|
if (group.section) {
|
|
3938
4338
|
const chrome: Record<string, Rect> = { rows: offset(group.card) };
|
|
@@ -4004,15 +4404,31 @@ export class LayoutPass {
|
|
|
4004
4404
|
if (row.edit.listRow === row.node) Object.assign(chrome, controls);
|
|
4005
4405
|
else this.setChrome(row.edit.listRow, controls);
|
|
4006
4406
|
}
|
|
4007
|
-
|
|
4407
|
+
let rowX = x + row.content.x;
|
|
4008
4408
|
const rowY = y + row.content.y;
|
|
4409
|
+
// A columns form's shifted stack: placed, then moved so its labeled
|
|
4410
|
+
// control starts at the controls column (the form's alignment guide).
|
|
4411
|
+
// Placed once to find the control's offset in it, then proposed the
|
|
4412
|
+
// width that ends it at the trailing edge, from where the control
|
|
4413
|
+
// lands at the column.
|
|
4414
|
+
const guide = columns ? this.macFormGuide(row.node) : null;
|
|
4415
|
+
let proposal = row.proposal;
|
|
4416
|
+
if (guide && plan.column) {
|
|
4417
|
+
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
4418
|
+
const placed = this.frames.get(unwrapStyled(guide).id);
|
|
4419
|
+
if (placed) {
|
|
4420
|
+
const offset = placed.x - rowX;
|
|
4421
|
+
rowX = x + plan.column.x - offset;
|
|
4422
|
+
proposal = { width: Math.max(0, plan.size.width - (plan.column.x - offset)), height: row.proposal.height };
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4009
4425
|
// A row that is skipped keeps the chrome it set itself when it was
|
|
4010
4426
|
// placed (a link's chevron and label); only the list's keys are new.
|
|
4011
|
-
const own = this.placedAlready(row.node, rowCtx,
|
|
4427
|
+
const own = this.placedAlready(row.node, rowCtx, proposal, rowX, rowY) && row.node.id >= 0
|
|
4012
4428
|
? this.chrome.get(row.node.id)
|
|
4013
4429
|
: undefined;
|
|
4014
4430
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
4015
|
-
this.place(row.node, rowCtx,
|
|
4431
|
+
this.place(row.node, rowCtx, proposal, rowX, rowY);
|
|
4016
4432
|
if (row.badge) this.badgeBeforeChevron(row.node);
|
|
4017
4433
|
// iOS 26: the separator under a row starts where its first text
|
|
4018
4434
|
// starts (after a Label's icon), else at the row's content.
|
|
@@ -4032,6 +4448,12 @@ export class LayoutPass {
|
|
|
4032
4448
|
}
|
|
4033
4449
|
}
|
|
4034
4450
|
}
|
|
4451
|
+
// 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).
|
|
4452
|
+
if (columns) {
|
|
4453
|
+
for (const label of plan.labels ?? []) {
|
|
4454
|
+
if (label.node) this.place(label.node, label.ctx, { width: null, height: null }, x + label.rect.x, y + label.rect.y);
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4035
4457
|
}
|
|
4036
4458
|
|
|
4037
4459
|
/** The x of the first text (in tree order) placed under `node`, if any. */
|
|
@@ -4103,10 +4525,12 @@ export class LayoutPass {
|
|
|
4103
4525
|
// the unified bar takes over; the row is the whole bar.
|
|
4104
4526
|
const mac = this.env.platform === 'macos';
|
|
4105
4527
|
const hasBarItems = parts.toolbars.some((t) => {
|
|
4106
|
-
const placement =
|
|
4528
|
+
const placement = this.barPlacement(t);
|
|
4107
4529
|
return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
|
|
4108
4530
|
});
|
|
4109
|
-
|
|
4531
|
+
// On a Mac the field is the toolbar's, or over a sidebar's list.
|
|
4532
|
+
const searchInSidebar = mac && parts.search !== null && !barHidden && cctx.splitSidebar;
|
|
4533
|
+
const searchInBar = mac && parts.search !== null && !barHidden && !searchInSidebar;
|
|
4110
4534
|
// A Mac tab view's toolbar row (the window's top): the tab's root screen
|
|
4111
4535
|
// draws its bar as that row, without a title (the tab picker is there).
|
|
4112
4536
|
const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
|
|
@@ -4118,13 +4542,14 @@ export class LayoutPass {
|
|
|
4118
4542
|
const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
4119
4543
|
const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
|
|
4120
4544
|
const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
|
|
4545
|
+
const groupGap = mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING;
|
|
4121
4546
|
// A compact height (a phone on its side) has no large titles, whatever the
|
|
4122
4547
|
// display mode, as UIKit: the title is the bar's inline one.
|
|
4123
4548
|
const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
4124
4549
|
const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
4125
4550
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
4126
4551
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
4127
|
-
const placementOf = (t: LayoutNode): string | null =>
|
|
4552
|
+
const placementOf = (t: LayoutNode): string | null => this.barPlacement(t);
|
|
4128
4553
|
const inHiddenBar = (t: LayoutNode): boolean => {
|
|
4129
4554
|
const placement = placementOf(t);
|
|
4130
4555
|
if (placement === 'keyboard') return false;
|
|
@@ -4147,8 +4572,8 @@ export class LayoutPass {
|
|
|
4147
4572
|
: 0;
|
|
4148
4573
|
// In a tab view (a tab bar covers the bottom) the field goes under the
|
|
4149
4574
|
// title, as iOS 26 places it; elsewhere it floats at the bottom.
|
|
4150
|
-
const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
|
|
4151
|
-
const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
4575
|
+
const searchOnTop = (!mac && parts.search !== null && cctx.bottomBar > 0) || searchInSidebar;
|
|
4576
|
+
const searchRow = searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE + MAC_SIDEBAR_SEARCH_HEIGHT + MAC_SIDEBAR_SEARCH_BELOW : searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
4152
4577
|
const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
4153
4578
|
// The on-screen keyboard (device mode) covers the screen's bottom. A
|
|
4154
4579
|
// bottom search field rises above it, as iOS 26 docks it on the
|
|
@@ -4181,12 +4606,12 @@ export class LayoutPass {
|
|
|
4181
4606
|
}
|
|
4182
4607
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
4183
4608
|
if (parts.search && !searchInBar) {
|
|
4184
|
-
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
4609
|
+
const marginH = searchInSidebar ? MAC_SIDEBAR_SEARCH_MARGIN_H : searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
4185
4610
|
chrome['searchField'] = {
|
|
4186
4611
|
x: x + marginH,
|
|
4187
|
-
y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchLift - searchBottom,
|
|
4612
|
+
y: searchOnTop ? y + barHeight + largeHeight + (searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE : SEARCH_TOP_ABOVE) : y + size.height - searchLift - searchBottom,
|
|
4188
4613
|
width: Math.max(0, size.width - 2 * marginH),
|
|
4189
|
-
height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
|
|
4614
|
+
height: searchInSidebar ? MAC_SIDEBAR_SEARCH_HEIGHT : searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
|
|
4190
4615
|
};
|
|
4191
4616
|
// The page draws a field under the title as the bar's (a gray capsule)
|
|
4192
4617
|
// and moves it with the content when that scrolls under the bar.
|
|
@@ -4199,7 +4624,12 @@ export class LayoutPass {
|
|
|
4199
4624
|
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 };
|
|
4200
4625
|
}
|
|
4201
4626
|
const title = str(node.props['title']) ?? '';
|
|
4627
|
+
// `.navigationSubtitle`: a second line under the title in a Mac's unified
|
|
4628
|
+
// title bar (11 regular, secondary); after the title in a plain one.
|
|
4202
4629
|
const { platform } = this.env;
|
|
4630
|
+
const subtitle = mac ? (str(node.props['subtitle']) ?? '') : '';
|
|
4631
|
+
const shownTitle = subtitle.length > 0 && !unified && !sheetBar ? `${title} — ${subtitle}` : title;
|
|
4632
|
+
const subtitleFont = makeFont('system', textStyleSize('subheadline', dynamicTypeSize, platform), 400);
|
|
4203
4633
|
// A unified title bar's title is the window title (15 semibold on macOS 14); a plain bar's and iOS's is the headline.
|
|
4204
4634
|
const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
|
|
4205
4635
|
const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
|
|
@@ -4247,7 +4677,7 @@ export class LayoutPass {
|
|
|
4247
4677
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
4248
4678
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
4249
4679
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
4250
|
-
const placement =
|
|
4680
|
+
const placement = placementOf(toolbar) ?? 'trailing';
|
|
4251
4681
|
if (placement === 'leading' || placement === 'trailing') {
|
|
4252
4682
|
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
|
|
4253
4683
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
@@ -4274,10 +4704,10 @@ export class LayoutPass {
|
|
|
4274
4704
|
const slots = run.groups.flatMap((g) => g.slots ?? []);
|
|
4275
4705
|
if (slots.length === 0) return 0;
|
|
4276
4706
|
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
|
|
4277
|
-
return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
|
|
4707
|
+
return slots.length > 1 ? inner + 2 * (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : inner;
|
|
4278
4708
|
};
|
|
4279
4709
|
for (const toolbar of parts.toolbars) {
|
|
4280
|
-
const placement =
|
|
4710
|
+
const placement = placementOf(toolbar);
|
|
4281
4711
|
if (placement !== 'leading' && placement !== 'trailing') continue;
|
|
4282
4712
|
if (isSpacer(toolbar)) {
|
|
4283
4713
|
delete open[placement];
|
|
@@ -4292,6 +4722,12 @@ export class LayoutPass {
|
|
|
4292
4722
|
runs.push({ placement, groups: [group], width: 0, segmented: true });
|
|
4293
4723
|
continue;
|
|
4294
4724
|
}
|
|
4725
|
+
// A Mac's text item is a capsule of its own (macOS 26), neither joining nor joined.
|
|
4726
|
+
if (mac && toolbar.children.some((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button')) {
|
|
4727
|
+
delete open[placement];
|
|
4728
|
+
runs.push({ placement, groups: [group], width: 0 });
|
|
4729
|
+
continue;
|
|
4730
|
+
}
|
|
4295
4731
|
// An item that hides the bar's shared glass stands alone, at its own width.
|
|
4296
4732
|
if (str(toolbar.props['sharedBackground']) === 'hidden') {
|
|
4297
4733
|
delete open[placement];
|
|
@@ -4309,7 +4745,7 @@ export class LayoutPass {
|
|
|
4309
4745
|
}
|
|
4310
4746
|
for (const run of runs) run.width = runWidth(run);
|
|
4311
4747
|
const runsWidth = (placement: string): number =>
|
|
4312
|
-
runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width +
|
|
4748
|
+
runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + groupGap, 0);
|
|
4313
4749
|
const leadingWidth = runsWidth('leading');
|
|
4314
4750
|
const trailingWidth = runsWidth('trailing');
|
|
4315
4751
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
@@ -4335,7 +4771,7 @@ export class LayoutPass {
|
|
|
4335
4771
|
});
|
|
4336
4772
|
// Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
|
|
4337
4773
|
const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
|
|
4338
|
-
previous && previous.toolbar !== entry.toolbar ?
|
|
4774
|
+
previous && previous.toolbar !== entry.toolbar ? groupGap : 0;
|
|
4339
4775
|
const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
|
|
4340
4776
|
entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
|
|
4341
4777
|
let inlineSecondary: SecondaryItem[] = [];
|
|
@@ -4344,7 +4780,7 @@ export class LayoutPass {
|
|
|
4344
4780
|
// bar) and the trailing groups; the More button takes a slot of it
|
|
4345
4781
|
// once anything collapses.
|
|
4346
4782
|
const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
|
|
4347
|
-
const roomStart = x + (size.width + titleWidth) / 2 +
|
|
4783
|
+
const roomStart = x + (size.width + titleWidth) / 2 + groupGap;
|
|
4348
4784
|
const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
|
|
4349
4785
|
const fitting = (room: number): SecondaryItem[] => {
|
|
4350
4786
|
const out: SecondaryItem[] = [];
|
|
@@ -4357,7 +4793,7 @@ export class LayoutPass {
|
|
|
4357
4793
|
}
|
|
4358
4794
|
return out;
|
|
4359
4795
|
};
|
|
4360
|
-
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass -
|
|
4796
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - groupGap);
|
|
4361
4797
|
}
|
|
4362
4798
|
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
4363
4799
|
|
|
@@ -4367,7 +4803,7 @@ export class LayoutPass {
|
|
|
4367
4803
|
// A Mac window's own title bar (the top of the screen, a title bar in the
|
|
4368
4804
|
// safe area) starts with the window buttons; the items follow them.
|
|
4369
4805
|
const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
|
|
4370
|
-
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4806
|
+
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : mac ? MAC_BAR_LEADING_MARGIN : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4371
4807
|
const glassY = rowY + (rowHeight - glass) / 2;
|
|
4372
4808
|
if (depth > 0 && !barHidden) {
|
|
4373
4809
|
chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
|
|
@@ -4375,20 +4811,21 @@ export class LayoutPass {
|
|
|
4375
4811
|
|
|
4376
4812
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
4377
4813
|
const back = chrome['back'];
|
|
4378
|
-
|
|
4379
|
-
let
|
|
4814
|
+
// On a Mac the back button is the first slot of the leading capsule (macOS 26).
|
|
4815
|
+
let leadingCursor = back ? back.x + back.width + (mac ? 0 : groupGap) : leadingStart;
|
|
4816
|
+
let trailingEdge = x + size.width - (mac ? MAC_BAR_MARGIN_TRAILING : NAV_ITEM_MARGIN);
|
|
4380
4817
|
// On a Mac the search field is the trailing-most item of the title bar
|
|
4381
4818
|
// (chrome `searchBar` tells the page to draw it as the toolbar's field).
|
|
4382
4819
|
if (searchInBar) {
|
|
4383
4820
|
const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
|
|
4384
|
-
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight -
|
|
4821
|
+
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_BAR_ITEM_SIZE) / 2, width, height: MAC_BAR_ITEM_SIZE };
|
|
4385
4822
|
chrome['searchBar'] = chrome['searchField'];
|
|
4386
|
-
trailingEdge -= width +
|
|
4823
|
+
trailingEdge -= width + groupGap;
|
|
4387
4824
|
}
|
|
4388
4825
|
// The More button is the trailing-most item.
|
|
4389
4826
|
if (collapsedSecondary.length > 0) {
|
|
4390
4827
|
chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
|
|
4391
|
-
trailingEdge -= glass +
|
|
4828
|
+
trailingEdge -= glass + groupGap;
|
|
4392
4829
|
}
|
|
4393
4830
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
4394
4831
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -4414,7 +4851,7 @@ export class LayoutPass {
|
|
|
4414
4851
|
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
|
|
4415
4852
|
}
|
|
4416
4853
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
4417
|
-
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
4854
|
+
let cursor = startX + (count > 1 ? (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : 0);
|
|
4418
4855
|
for (const group of run.groups) {
|
|
4419
4856
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
4420
4857
|
const first = cursor;
|
|
@@ -4422,31 +4859,34 @@ export class LayoutPass {
|
|
|
4422
4859
|
const itemSize = group.sizes[i] as Size;
|
|
4423
4860
|
const slot = group.slots?.[i] ?? itemSize.width;
|
|
4424
4861
|
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
|
|
4425
|
-
// A Mac toolbar's text button is a bordered 28 button at its slot
|
|
4426
|
-
// (chrome `bordered`, drawn by the page at that rect); an icon is bare.
|
|
4427
|
-
if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
|
|
4428
|
-
const existing = this.chrome.get(item.id);
|
|
4429
|
-
this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
|
|
4430
|
-
}
|
|
4431
4862
|
cursor += slot + runGap;
|
|
4432
4863
|
});
|
|
4433
4864
|
const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
|
|
4434
4865
|
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
|
|
4435
4866
|
}
|
|
4436
4867
|
};
|
|
4437
|
-
|
|
4438
|
-
|
|
4868
|
+
const leadingRuns = runs.filter((r) => r.placement === 'leading' && r.width > 0);
|
|
4869
|
+
for (const run of leadingRuns) {
|
|
4439
4870
|
placeRun(run, leadingCursor);
|
|
4440
|
-
leadingCursor += run.width +
|
|
4871
|
+
leadingCursor += run.width + groupGap;
|
|
4872
|
+
}
|
|
4873
|
+
// A Mac's back button: its capsule is the first leading one's, widened to
|
|
4874
|
+
// take it in, or one of its own.
|
|
4875
|
+
if (mac && back) {
|
|
4876
|
+
const first = leadingRuns[0];
|
|
4877
|
+
const merged = first && !first.bare && !first.segmented ? chrome['leadingGlass0'] : undefined;
|
|
4878
|
+
if (merged) chrome['leadingGlass0'] = { ...merged, x: back.x, width: merged.x + merged.width - back.x };
|
|
4879
|
+
else chrome[`leadingGlass${glassCount.leading++}`] = { ...back };
|
|
4880
|
+
if (leadingRuns.length === 0) leadingCursor = back.x + back.width + groupGap;
|
|
4441
4881
|
}
|
|
4442
4882
|
const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
|
|
4443
|
-
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth -
|
|
4883
|
+
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - groupGap : 0);
|
|
4444
4884
|
const trailingStart = trailingCursor;
|
|
4445
4885
|
for (const run of trailingRuns) {
|
|
4446
4886
|
placeRun(run, trailingCursor);
|
|
4447
|
-
trailingCursor += run.width +
|
|
4887
|
+
trailingCursor += run.width + groupGap;
|
|
4448
4888
|
}
|
|
4449
|
-
if (trailingRuns.length > 0) trailingEdge = trailingStart -
|
|
4889
|
+
if (trailingRuns.length > 0) trailingEdge = trailingStart - groupGap;
|
|
4450
4890
|
for (const group of groups) {
|
|
4451
4891
|
if (group.placement !== 'principal') continue;
|
|
4452
4892
|
const startX = x + (size.width - group.total) / 2;
|
|
@@ -4469,7 +4909,7 @@ export class LayoutPass {
|
|
|
4469
4909
|
cursor += entry.slot;
|
|
4470
4910
|
}
|
|
4471
4911
|
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
|
|
4472
|
-
trailingEdge -= total +
|
|
4912
|
+
trailingEdge -= total + groupGap;
|
|
4473
4913
|
}
|
|
4474
4914
|
for (const toolbar of secondaryToolbars) {
|
|
4475
4915
|
const items = toolbar.children;
|
|
@@ -4528,24 +4968,38 @@ export class LayoutPass {
|
|
|
4528
4968
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
4529
4969
|
if (tabToolbar) {
|
|
4530
4970
|
// The tab picker stands where the title would be.
|
|
4971
|
+
} else if (mac && cctx.splitSidebar) {
|
|
4972
|
+
// A Mac's sidebar shows no title.
|
|
4531
4973
|
} else if ((unified || sheetBar) && !principal && !barHidden) {
|
|
4532
4974
|
// The unified title bar's title (and a Mac sheet's): leading-aligned
|
|
4533
4975
|
// after the back button and the leading items (2 past the window
|
|
4534
4976
|
// buttons' room when nothing is before it), cut to the room before
|
|
4535
4977
|
// the trailing ones.
|
|
4536
4978
|
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
|
|
4537
|
-
|
|
4538
|
-
const
|
|
4979
|
+
// A Mac toolbar's title is 12 after the window buttons' room or after the leading capsules.
|
|
4980
|
+
const titleX = unified
|
|
4981
|
+
? (back || leadingWidth > 0 ? leadingCursor - groupGap : leadingStart) + MAC_TITLE_GAP
|
|
4982
|
+
: Math.max(x + MAC_SHEET_TITLE_X, leadingCursor);
|
|
4983
|
+
const roomEnd = trailingUsed ? trailingEdge + groupGap - 8 : x + size.width - NAV_ITEM_MARGIN;
|
|
4539
4984
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4540
|
-
|
|
4985
|
+
if (subtitle.length > 0) {
|
|
4986
|
+
// The title and the subtitle stacked, centered in the row.
|
|
4987
|
+
const block = titleFont.lineHeight + subtitleFont.lineHeight;
|
|
4988
|
+
const top = rowY + (rowHeight - block) / 2;
|
|
4989
|
+
chrome['title'] = { x: titleX, y: top, width: titleWidth, height: titleFont.lineHeight };
|
|
4990
|
+
const subtitleWidth = Math.min(Math.ceil(this.measurer.measureWidth(subtitle, subtitleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4991
|
+
chrome['subtitle'] = { x: titleX, y: top + titleFont.lineHeight, width: subtitleWidth, height: subtitleFont.lineHeight };
|
|
4992
|
+
} else {
|
|
4993
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4994
|
+
}
|
|
4541
4995
|
} else if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
4542
4996
|
const leftUsed = Math.max(
|
|
4543
4997
|
back ? back.x + back.width - x : 0,
|
|
4544
|
-
leadingWidth > 0 ? leadingCursor -
|
|
4545
|
-
cctx.barLeading ? leadingStart -
|
|
4998
|
+
leadingWidth > 0 ? leadingCursor - groupGap - x : 0,
|
|
4999
|
+
cctx.barLeading ? leadingStart - groupGap - x : 0,
|
|
4546
5000
|
);
|
|
4547
5001
|
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
|
|
4548
|
-
const rightUsed = trailingUsed ? x + size.width - (trailingEdge +
|
|
5002
|
+
const rightUsed = trailingUsed ? x + size.width - (trailingEdge + groupGap) : 0;
|
|
4549
5003
|
// As UIKit places it: centered in the bar when it fits there; else slid
|
|
4550
5004
|
// off-center into the room the items leave (between them, 8 clear of
|
|
4551
5005
|
// each side), and truncated only when it does not fit even there.
|
|
@@ -4553,12 +5007,14 @@ export class LayoutPass {
|
|
|
4553
5007
|
const roomEnd = x + size.width - rightUsed - 8;
|
|
4554
5008
|
const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
|
|
4555
5009
|
const titleWidth = Math.min(
|
|
4556
|
-
Math.ceil(this.measurer.measureWidth(
|
|
5010
|
+
Math.ceil(this.measurer.measureWidth(shownTitle, titleFont) - 0.001),
|
|
4557
5011
|
compactCap,
|
|
4558
5012
|
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
4559
5013
|
);
|
|
4560
5014
|
const centered = x + (size.width - titleWidth) / 2;
|
|
4561
|
-
|
|
5015
|
+
// A Mac's compact title bar: the title leading-aligned after the window buttons (macOS 26).
|
|
5016
|
+
const leading = mac ? x + (windowButtons ? MAC_COMPACT_TITLE_X : NAV_ITEM_MARGIN) : null;
|
|
5017
|
+
const titleX = leading !== null ? Math.max(roomStart, Math.min(leading, roomEnd - titleWidth)) : Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
4562
5018
|
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4563
5019
|
}
|
|
4564
5020
|
this.setChrome(node, chrome);
|
|
@@ -4566,8 +5022,9 @@ export class LayoutPass {
|
|
|
4566
5022
|
if (node.id >= 0) {
|
|
4567
5023
|
this.fonts.set(node.id, large ? largeTitleFont : titleFont);
|
|
4568
5024
|
const texts: Record<string, TextLayout> = {
|
|
4569
|
-
title: { font: titleFont, lines: [
|
|
5025
|
+
title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar || mac ? 'leading' : 'center', truncated: false },
|
|
4570
5026
|
};
|
|
5027
|
+
if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
|
|
4571
5028
|
if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
|
|
4572
5029
|
if (large) {
|
|
4573
5030
|
const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
|
|
@@ -4860,38 +5317,34 @@ export class LayoutPass {
|
|
|
4860
5317
|
}
|
|
4861
5318
|
|
|
4862
5319
|
/**
|
|
4863
|
-
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a
|
|
4864
|
-
*
|
|
4865
|
-
*
|
|
4866
|
-
* content is the rest, and the selected tab fills it (its
|
|
4867
|
-
* reserves no status bar there: the row is above it).
|
|
4868
|
-
* the bar as the top one for the page.
|
|
5320
|
+
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a glass capsule
|
|
5321
|
+
* of equal segments centered in a 52 row at the top: the window's toolbar
|
|
5322
|
+
* when the tab view is at the top of the screen, else a row at the top of
|
|
5323
|
+
* the content. The content is the rest, and the selected tab fills it (its
|
|
5324
|
+
* own `navstack` reserves no status bar there: the row is above it).
|
|
5325
|
+
* Chrome `top` marks the bar as the top one for the page.
|
|
4869
5326
|
*/
|
|
4870
5327
|
private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
4871
5328
|
const { safeArea } = this.env;
|
|
4872
|
-
// At the top of the window the row is the window's toolbar (38, the
|
|
4873
|
-
// title bar included); lower down, a 38 row at the top of the content.
|
|
4874
5329
|
const atTop = y <= 0 && safeArea.top > 0;
|
|
4875
|
-
const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
|
|
4876
5330
|
const tabs = node.children;
|
|
4877
5331
|
const n = tabs.length;
|
|
4878
|
-
// Each segment is
|
|
5332
|
+
// Each segment is the widest title (13 regular) and 11 either side.
|
|
4879
5333
|
const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
|
|
4880
|
-
const
|
|
4881
|
-
const
|
|
5334
|
+
const widest = tabs.reduce((w, tab) => Math.max(w, this.measurer.measureWidth(str(tab.props['title']) ?? '', font)), 0);
|
|
5335
|
+
const itemWidth = widest + 2 * MAC_TAB_ITEM_PAD_H;
|
|
5336
|
+
const natural = n * itemWidth + 2 * MAC_TAB_ITEM_INSET;
|
|
4882
5337
|
const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
|
|
4883
|
-
const
|
|
4884
|
-
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height
|
|
4885
|
-
const
|
|
4886
|
-
const
|
|
4887
|
-
const
|
|
4888
|
-
const content: Rect = { x, y: y +
|
|
5338
|
+
const segment = n > 0 ? (width - 2 * MAC_TAB_ITEM_INSET) / n : 0;
|
|
5339
|
+
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height));
|
|
5340
|
+
const barHeight = Math.min(MAC_TAB_BAR_HEIGHT, rowHeight);
|
|
5341
|
+
const bar: Rect = snapRect({ x: x + (size.width - width) / 2, y: y + (rowHeight - barHeight) / 2, width, height: barHeight });
|
|
5342
|
+
const toolbar: Rect = { x, y, width: size.width, height: rowHeight };
|
|
5343
|
+
const content: Rect = { x, y: y + rowHeight, width: size.width, height: Math.max(0, size.height - rowHeight) };
|
|
4889
5344
|
const chrome: Record<string, Rect> = { bar, content, top: bar };
|
|
4890
|
-
|
|
5345
|
+
const itemHeight = Math.min(MAC_TAB_ITEM_HEIGHT, barHeight);
|
|
4891
5346
|
for (let i = 0; i < n; i++) {
|
|
4892
|
-
|
|
4893
|
-
chrome[`item${i}`] = { x: itemX, y: bar.y + MAC_TAB_ITEM_INSET, width: itemWidth, height: Math.max(0, bar.height - 2 * MAC_TAB_ITEM_INSET) };
|
|
4894
|
-
itemX += itemWidth;
|
|
5347
|
+
chrome[`item${i}`] = snapRect({ x: bar.x + MAC_TAB_ITEM_INSET + i * segment, y: bar.y + (barHeight - itemHeight) / 2, width: segment, height: itemHeight });
|
|
4895
5348
|
}
|
|
4896
5349
|
this.setChrome(node, chrome);
|
|
4897
5350
|
const selected = readSelected(node, n);
|
|
@@ -4984,33 +5437,41 @@ export class LayoutPass {
|
|
|
4984
5437
|
const leading = x <= 0 ? safeArea.leading : 0;
|
|
4985
5438
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
4986
5439
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
4987
|
-
const
|
|
5440
|
+
const mac = this.env.platform === 'macos';
|
|
5441
|
+
const sidebarWidth = Math.min(mac ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
4988
5442
|
const visible = node.props['sidebarVisible'] !== false;
|
|
5443
|
+
// A Mac's sidebar is a panel inset from the window's edges, with no separator.
|
|
5444
|
+
const panelInset = mac ? MAC_NAVSPLIT_SIDEBAR_INSET : 0;
|
|
5445
|
+
const separatorWidth = mac ? 0 : NAVSPLIT_SEPARATOR_WIDTH;
|
|
4989
5446
|
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
4990
|
-
const sidebarX = visible ? x + leading : x - sidebarWidth -
|
|
5447
|
+
const sidebarX = visible ? x + leading + panelInset : x - sidebarWidth - separatorWidth;
|
|
4991
5448
|
const separatorX = sidebarX + sidebarWidth;
|
|
4992
|
-
const detailX = visible ? separatorX +
|
|
5449
|
+
const detailX = visible ? separatorX + separatorWidth : x + leading;
|
|
4993
5450
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
4994
5451
|
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
4995
|
-
// On a Mac the toggle is a
|
|
4996
|
-
//
|
|
4997
|
-
//
|
|
4998
|
-
const mac = this.env.platform === 'macos';
|
|
5452
|
+
// On a Mac the toggle is a bare 36 slot centered in the toolbar's row: at
|
|
5453
|
+
// the sidebar panel's trailing end, or after the window buttons while
|
|
5454
|
+
// the sidebar is hidden.
|
|
4999
5455
|
const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
5000
|
-
const toggleX =
|
|
5456
|
+
const toggleX = mac && visible
|
|
5457
|
+
? sidebarX + sidebarWidth - MAC_NAVSPLIT_TOGGLE_INSET - toggleSize
|
|
5458
|
+
: x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
|
|
5001
5459
|
const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
|
|
5002
5460
|
const chrome: Record<string, Rect> = {
|
|
5003
5461
|
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
5004
|
-
separator: { x: separatorX, y, width:
|
|
5462
|
+
separator: { x: separatorX, y, width: separatorWidth, height: size.height },
|
|
5005
5463
|
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
5006
5464
|
toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
|
|
5007
5465
|
};
|
|
5466
|
+
// The Mac's sidebar panel (the page draws it), inset from the window's edges.
|
|
5467
|
+
if (mac) chrome['panel'] = { x: sidebarX, y: y + panelInset, width: sidebarWidth, height: Math.max(0, size.height - 2 * panelInset) };
|
|
5008
5468
|
this.setChrome(node, chrome);
|
|
5009
|
-
const toggleRoom = toggleSize + NAV_GROUP_SPACING;
|
|
5469
|
+
const toggleRoom = toggleSize + (mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING);
|
|
5010
5470
|
const [sidebar, detail, ...rest] = node.children;
|
|
5011
5471
|
if (sidebar) {
|
|
5012
5472
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
5013
|
-
|
|
5473
|
+
// On a Mac the toggle is at the sidebar's trailing end: its screens' items start where they would.
|
|
5474
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible && !mac ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
5014
5475
|
}
|
|
5015
5476
|
if (detail) {
|
|
5016
5477
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
@@ -5157,6 +5618,8 @@ export class LayoutPass {
|
|
|
5157
5618
|
private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
5158
5619
|
const label = this.contentOf(node);
|
|
5159
5620
|
const control = this.stepperMetrics();
|
|
5621
|
+
// A Mac form's row: the label is in the label column; the control alone.
|
|
5622
|
+
if (ctx.formColumn) return { width: control.width, height: control.height };
|
|
5160
5623
|
const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
|
|
5161
5624
|
return {
|
|
5162
5625
|
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
@@ -5168,11 +5631,12 @@ export class LayoutPass {
|
|
|
5168
5631
|
const cctx = this.childContext(node, ctx);
|
|
5169
5632
|
const label = this.contentOf(node);
|
|
5170
5633
|
const labelProposal = this.stepperLabelProposal(p);
|
|
5171
|
-
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5634
|
+
const labelSize = label && !ctx.formColumn ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5172
5635
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
5173
|
-
|
|
5636
|
+
// A Mac form's row: the label is in the label column (the form places it), the control at the leading edge.
|
|
5637
|
+
if (label && !ctx.formColumn) this.place(label, cctx, labelProposal, x, labelY);
|
|
5174
5638
|
const control = this.stepperMetrics();
|
|
5175
|
-
const controlX = x + size.width - control.width;
|
|
5639
|
+
const controlX = ctx.formColumn ? x : x + size.width - control.width;
|
|
5176
5640
|
const controlY = y + (size.height - control.height) / 2;
|
|
5177
5641
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
5178
5642
|
// iOS: − and + side by side; a Mac: up (increment) over down.
|
|
@@ -5232,11 +5696,20 @@ export class LayoutPass {
|
|
|
5232
5696
|
private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
5233
5697
|
const plan = this.sliderPlan(node, ctx, p);
|
|
5234
5698
|
const { cctx } = plan;
|
|
5235
|
-
if (plan.label) this.hide(plan.label, node);
|
|
5236
5699
|
const m = this.sliderMetrics();
|
|
5237
5700
|
const rowY = y;
|
|
5238
5701
|
let trackX = x;
|
|
5239
5702
|
let trackEnd = x + size.width;
|
|
5703
|
+
// In a Mac grouped form the label leads the row and the slider takes its
|
|
5704
|
+
// trailing 55%; elsewhere the label is not drawn (a Mac columns form
|
|
5705
|
+
// places it in its label column).
|
|
5706
|
+
if (plan.label && ctx.groupedRow) {
|
|
5707
|
+
const labelSize = this.measure(plan.label, cctx, NIL_PROPOSAL);
|
|
5708
|
+
this.place(plan.label, cctx, NIL_PROPOSAL, x, rowY + (m.height - labelSize.height) / 2);
|
|
5709
|
+
trackX = x + size.width * (1 - MAC_GROUPED_SLIDER_FRACTION);
|
|
5710
|
+
} else if (plan.label && !ctx.formColumn) {
|
|
5711
|
+
this.hide(plan.label, node);
|
|
5712
|
+
}
|
|
5240
5713
|
if (plan.min) {
|
|
5241
5714
|
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
|
|
5242
5715
|
trackX += plan.minSize.width + SLIDER_LABEL_GAP;
|
|
@@ -5289,10 +5762,11 @@ export class LayoutPass {
|
|
|
5289
5762
|
const dateWidth = capsule(dateText);
|
|
5290
5763
|
const timeWidth = capsule(timeText);
|
|
5291
5764
|
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
|
|
5292
|
-
|
|
5765
|
+
// A Mac form's row: the label is in the label column; the fields alone.
|
|
5766
|
+
const label = node.props['labelsHidden'] === true || ctx.formColumn ? null : this.contentOf(node);
|
|
5293
5767
|
const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
|
|
5294
5768
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5295
|
-
const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
5769
|
+
const width = ctx.formColumn ? capsulesWidth : finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
5296
5770
|
return {
|
|
5297
5771
|
size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
|
|
5298
5772
|
label,
|
|
@@ -5308,8 +5782,8 @@ export class LayoutPass {
|
|
|
5308
5782
|
|
|
5309
5783
|
private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
5310
5784
|
const plan = this.datePickerPlan(node, ctx, p);
|
|
5311
|
-
if (!plan.label) {
|
|
5312
|
-
// `labelsHidden`: the label child exists but is not shown.
|
|
5785
|
+
if (!plan.label && (!ctx.formColumn || node.props['labelsHidden'] === true)) {
|
|
5786
|
+
// `labelsHidden`: the label child exists but is not shown (a Mac form places it in its label column).
|
|
5313
5787
|
for (const child of node.children) this.hide(child, node);
|
|
5314
5788
|
}
|
|
5315
5789
|
const labelY = y + (size.height - plan.labelSize.height) / 2;
|
|
@@ -5390,7 +5864,8 @@ export class LayoutPass {
|
|
|
5390
5864
|
const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
|
|
5391
5865
|
const textWidth = cardWidth - 2 * m.padH;
|
|
5392
5866
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
5393
|
-
|
|
5867
|
+
// The Mac's message is the body text (13), as large as the title.
|
|
5868
|
+
const messageFont = resolveFont({ textStyle: m === MAC_ALERT_METRICS ? 'body' : 'subheadline' }, ctx.font, this.env);
|
|
5394
5869
|
// Heights first (the card is centered), then the rects.
|
|
5395
5870
|
let h = m.padTop;
|
|
5396
5871
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
@@ -5416,9 +5891,9 @@ export class LayoutPass {
|
|
|
5416
5891
|
const headerHeight = h;
|
|
5417
5892
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
5418
5893
|
h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
|
|
5419
|
-
// Centered in the safe area, as iOS does.
|
|
5894
|
+
// Centered in the safe area, as iOS does; a Mac's panel in the window.
|
|
5420
5895
|
const { safeArea: inset } = this.env;
|
|
5421
|
-
const top = y + (y <= 0 ? inset.top : 0);
|
|
5896
|
+
const top = y + (y <= 0 && m !== MAC_ALERT_METRICS ? inset.top : 0);
|
|
5422
5897
|
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
5423
5898
|
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
5424
5899
|
chrome['card'] = card;
|