@swiftbrowser/web 0.26.4 → 0.26.6
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 +407 -173
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -2
- package/src/protocol.ts +8 -0
- package/src/renderer.ts +85 -1
- package/src/styles.css +414 -146
- 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
|
|
@@ -320,35 +322,45 @@ const NAV_GROUP_SPACING = 12;
|
|
|
320
322
|
const NAV_RUN_GAP = 12;
|
|
321
323
|
const NAV_RUN_PAD = 1;
|
|
322
324
|
// The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
|
|
323
|
-
// `macos`): the window's title bar stands in for the
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
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
|
|
329
335
|
// (the top of the screen, when the safe area carries a title bar) the three
|
|
330
|
-
// window buttons take the first
|
|
331
|
-
//
|
|
332
|
-
// harness, stage 3).
|
|
336
|
+
// window buttons take the first 96 of the toolbar; the title is 12 after
|
|
337
|
+
// whatever is before it.
|
|
333
338
|
const MAC_TITLE_BAR_UNIFIED = 52;
|
|
334
|
-
const MAC_TITLE_BAR_PLAIN =
|
|
335
|
-
const MAC_BAR_ITEM_SIZE =
|
|
336
|
-
const MAC_BAR_ITEM_PAD_H =
|
|
337
|
-
const MAC_BAR_RUN_GAP =
|
|
338
|
-
/**
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
364
|
const MAC_TAB_BAR_MARGIN_H = 20;
|
|
353
365
|
// Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
|
|
354
366
|
// either side of its label, and every `automatic` button outside a list row
|
|
@@ -356,23 +368,28 @@ const MAC_TAB_BAR_MARGIN_H = 20;
|
|
|
356
368
|
// toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
|
|
357
369
|
// pop-up button is 22 tall (24 in a row), its value in a bordered button
|
|
358
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).
|
|
359
373
|
const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
360
|
-
regular: { padV:
|
|
361
|
-
mini: { padV: 1, padH:
|
|
362
|
-
small: { padV: 2, padH:
|
|
363
|
-
large: { padV:
|
|
364
|
-
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 },
|
|
365
379
|
};
|
|
366
380
|
const MAC_CHECKBOX_SIZE = 14;
|
|
367
381
|
const MAC_CHECKBOX_GAP = 6;
|
|
368
|
-
const MAC_PICKER_MENU_HEIGHT =
|
|
382
|
+
const MAC_PICKER_MENU_HEIGHT = 24;
|
|
369
383
|
// A Mac's segmented control is 22 tall with 13-point regular text, its
|
|
370
384
|
// label (which iOS drops) 10 before it.
|
|
371
|
-
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;
|
|
372
388
|
const MAC_SEGMENTED_FONT_SIZE = 13;
|
|
373
389
|
const MAC_SEGMENTED_LABEL_GAP = 10;
|
|
374
390
|
const MAC_PICKER_MENU_ROW_HEIGHT = 24;
|
|
375
|
-
|
|
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;
|
|
376
393
|
// Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
|
|
377
394
|
// rows full width with 16 beside their content, at least 24 tall (28 in a
|
|
378
395
|
// sidebar) with 4 (6) above and below their content, a bordered button's 3;
|
|
@@ -382,15 +399,26 @@ const MAC_PICKER_POPUP_PAD = 8;
|
|
|
382
399
|
// footer (11) sits 8 under the last row, with a rule 5 under it. A
|
|
383
400
|
// sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
|
|
384
401
|
const MAC_LIST_ROW_MIN_HEIGHT = 24;
|
|
385
|
-
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT =
|
|
402
|
+
const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 32;
|
|
386
403
|
const MAC_LIST_ROW_VPAD = 4;
|
|
387
404
|
const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
|
|
388
405
|
const MAC_LIST_BUTTON_ROW_VPAD = 3;
|
|
389
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;
|
|
390
416
|
/** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
|
|
391
417
|
const MAC_LIST_LABEL_GAP = 6;
|
|
392
418
|
const MAC_LIST_INSET_SIDEBAR = 10;
|
|
393
|
-
|
|
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;
|
|
394
422
|
const MAC_LIST_BOTTOM = 8;
|
|
395
423
|
const MAC_LIST_SECTION_GAP = 22;
|
|
396
424
|
const MAC_LIST_HEADER_TOP = 4;
|
|
@@ -411,24 +439,66 @@ const MAC_FORM_HEADER_GAP = 8;
|
|
|
411
439
|
const MAC_FORM_HEADER_TOP = 4;
|
|
412
440
|
const MAC_FORM_ROW_GAP = 7;
|
|
413
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;
|
|
414
464
|
/** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
|
|
415
465
|
const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
|
|
416
466
|
/** Under a Mac footer: 5 to its rule, the rule. */
|
|
417
467
|
const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
//
|
|
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
|
+
/** The width of the strip astride the sidebar panel's trailing edge that drags the sidebar's width. */
|
|
479
|
+
const MAC_NAVSPLIT_DIVIDER_GRIP = 8;
|
|
480
|
+
const MAC_NAVSPLIT_SIDEBAR_INSET = 8;
|
|
481
|
+
const MAC_NAVSPLIT_TOGGLE_INSET = 6;
|
|
482
|
+
const MAC_SIDEBAR_SEARCH_ABOVE = 9;
|
|
483
|
+
const MAC_SIDEBAR_SEARCH_HEIGHT = 28;
|
|
484
|
+
const MAC_SIDEBAR_SEARCH_BELOW = 10;
|
|
485
|
+
const MAC_SIDEBAR_SEARCH_MARGIN_H = 10;
|
|
486
|
+
const MAC_BAR_LEADING_MARGIN = 10;
|
|
487
|
+
// A Mac's search field is in the toolbar, at its trailing end (macOS 26): a
|
|
488
|
+
// 36 glass capsule like the bar's items, 325 wide (less when the bar is
|
|
489
|
+
// narrow), 8 from the edge; elsewhere a field is 22 tall. A sheet is a
|
|
421
490
|
// window-modal panel that drops from the title bar, 480 × 400 at most,
|
|
422
491
|
// centered, with no grabber.
|
|
423
|
-
const MAC_SEARCH_FIELD_WIDTH =
|
|
492
|
+
const MAC_SEARCH_FIELD_WIDTH = 325;
|
|
424
493
|
const MAC_SEARCH_FIELD_HEIGHT = 22;
|
|
425
|
-
// A Mac's sheet (measured in the Mac app): a 470 panel centered in
|
|
426
|
-
// window, its
|
|
427
|
-
// ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
428
|
-
// confirm) as
|
|
429
|
-
//
|
|
494
|
+
// A Mac's sheet (measured in the Mac app, macOS 26): a 470 panel centered in
|
|
495
|
+
// the window, its 48 title bar (the title 13 bold, 48 in) over the content at
|
|
496
|
+
// its ideal height, and a 60 bar at the bottom with the text actions (cancel,
|
|
497
|
+
// confirm) as push buttons at its trailing end, 16 in and 8 apart; at least
|
|
498
|
+
// 40 clear of the window's edges.
|
|
430
499
|
const MAC_SHEET_WIDTH = 470;
|
|
431
|
-
const MAC_SHEET_TITLE_BAR =
|
|
500
|
+
const MAC_SHEET_TITLE_BAR = 48;
|
|
501
|
+
const MAC_SHEET_TITLE_X = 48;
|
|
432
502
|
const MAC_SHEET_ACTIONS_BAR = 60;
|
|
433
503
|
const MAC_SHEET_ACTIONS_INSET = 16;
|
|
434
504
|
const MAC_SHEET_ACTIONS_GAP = 8;
|
|
@@ -675,25 +745,25 @@ const ALERT_METRICS: AlertMetrics = {
|
|
|
675
745
|
fieldsGap: ALERT_FIELDS_GAP,
|
|
676
746
|
textAlignment: 'leading',
|
|
677
747
|
};
|
|
678
|
-
// Measured in the Mac app:
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
// side by side 8 apart.
|
|
748
|
+
// Measured in the Mac app (macOS 26): no app icon; the title (13 bold) 20
|
|
749
|
+
// under the top and 22 in, the message (13 regular) 10 under it, both
|
|
750
|
+
// leading-aligned; 28-tall capsule buttons 16 under the message, 16 in and
|
|
751
|
+
// 16 over the bottom, two side by side 8 apart (stacked 4 apart).
|
|
682
752
|
const MAC_ALERT_METRICS: AlertMetrics = {
|
|
683
753
|
width: 260,
|
|
684
|
-
padTop:
|
|
685
|
-
padH:
|
|
754
|
+
padTop: 20,
|
|
755
|
+
padH: 22,
|
|
686
756
|
buttonInset: 16,
|
|
687
|
-
buttonsGap:
|
|
757
|
+
buttonsGap: 16,
|
|
688
758
|
padBottom: 16,
|
|
689
|
-
buttonSpacing:
|
|
759
|
+
buttonSpacing: 4,
|
|
690
760
|
rowSpacing: 8,
|
|
691
|
-
titleGap:
|
|
761
|
+
titleGap: 10,
|
|
692
762
|
buttonHeight: 28,
|
|
693
763
|
fieldHeight: 22,
|
|
694
764
|
fieldSpacing: 8,
|
|
695
765
|
fieldsGap: 12,
|
|
696
|
-
textAlignment: '
|
|
766
|
+
textAlignment: 'leading',
|
|
697
767
|
};
|
|
698
768
|
/** Gap between an action sheet's title and its message. */
|
|
699
769
|
const DIALOG_TITLE_GAP = 4;
|
|
@@ -963,6 +1033,32 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
963
1033
|
return undefined;
|
|
964
1034
|
}
|
|
965
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* A split view column's `.navigationSplitViewColumnWidth(min:ideal:max:)`:
|
|
1038
|
+
* the `splitColumnWidth` hint (`{ min?, ideal, max? }`) on the column's
|
|
1039
|
+
* content, read through its `styled` wrappers, its `navstack` and the root
|
|
1040
|
+
* screen of that stack (an app puts the modifier on the sidebar's list).
|
|
1041
|
+
*/
|
|
1042
|
+
function splitColumnWidthOf(column: LayoutNode): { min?: number; ideal: number; max?: number } | null {
|
|
1043
|
+
let current: LayoutNode | undefined = column;
|
|
1044
|
+
while (current) {
|
|
1045
|
+
const hint = cellStyle(current, 'splitColumnWidth');
|
|
1046
|
+
if (hint && typeof hint === 'object') {
|
|
1047
|
+
const spec = hint as Record<string, unknown>;
|
|
1048
|
+
const ideal = num(spec['ideal']);
|
|
1049
|
+
if (ideal === null) return null;
|
|
1050
|
+
const min = num(spec['min']);
|
|
1051
|
+
const max = num(spec['max']);
|
|
1052
|
+
return { ...(min !== null ? { min } : {}), ideal, ...(max !== null ? { max } : {}) };
|
|
1053
|
+
}
|
|
1054
|
+
const inner = unwrapStyled(current);
|
|
1055
|
+
if (inner.kind === 'navstack') current = inner.children[0] as LayoutNode | undefined;
|
|
1056
|
+
else if (inner.kind === 'navscreen') current = inner.children.find((child) => child.kind !== 'toolbar' && child.kind !== 'searchfield');
|
|
1057
|
+
else return null;
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
966
1062
|
/** The default vertical padding above and below a list row's content. */
|
|
967
1063
|
function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
|
|
968
1064
|
if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
|
|
@@ -1941,7 +2037,7 @@ export class LayoutPass {
|
|
|
1941
2037
|
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1942
2038
|
}
|
|
1943
2039
|
case 'toolbar': {
|
|
1944
|
-
const placement =
|
|
2040
|
+
const placement = this.barPlacement(node);
|
|
1945
2041
|
const navBarItem = placement === 'leading' || placement === 'trailing';
|
|
1946
2042
|
const bottomBarItem = placement === 'bottom';
|
|
1947
2043
|
if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem && ctx.bottomBarItem === bottomBarItem) return ctx;
|
|
@@ -2385,8 +2481,10 @@ export class LayoutPass {
|
|
|
2385
2481
|
* checkbox before the label, or the small switch in a list row (a grouped
|
|
2386
2482
|
* form's), at the trailing edge.
|
|
2387
2483
|
*/
|
|
2388
|
-
private switchMetrics(
|
|
2484
|
+
private switchMetrics(ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
|
|
2389
2485
|
if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
|
|
2486
|
+
// A grouped form's toggle is a switch at the row's trailing end (macOS 26).
|
|
2487
|
+
if (ctx.groupedRow) return { width: MAC_GROUPED_SWITCH_WIDTH, height: MAC_GROUPED_SWITCH_HEIGHT, gap: MAC_GROUPED_LABEL_GAP, checkbox: false };
|
|
2390
2488
|
// A Mac's toggle is a checkbox before its label, in a list row too.
|
|
2391
2489
|
return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
|
|
2392
2490
|
}
|
|
@@ -2483,6 +2581,17 @@ export class LayoutPass {
|
|
|
2483
2581
|
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
2484
2582
|
}
|
|
2485
2583
|
|
|
2584
|
+
/**
|
|
2585
|
+
* Where a `toolbar` is drawn: its placement, except that a Mac has no
|
|
2586
|
+
* bottom bar (SwiftUI's `.bottomBar` is iOS's): its items join the
|
|
2587
|
+
* toolbar's trailing ones, in order, as the native renderer places them
|
|
2588
|
+
* (`.automatic` there).
|
|
2589
|
+
*/
|
|
2590
|
+
private barPlacement(toolbar: LayoutNode): string | null {
|
|
2591
|
+
const placement = str(toolbar.props['placement']);
|
|
2592
|
+
return placement === 'bottom' && this.env.platform === 'macos' ? 'trailing' : placement;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2486
2595
|
/** The toolbars of a Mac sheet's root screen that go in its bottom bar: those whose items are all text buttons (a cancel or confirm action). */
|
|
2487
2596
|
private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
|
|
2488
2597
|
return this.navScreenParts(screen).toolbars.filter((toolbar) => {
|
|
@@ -2583,12 +2692,23 @@ export class LayoutPass {
|
|
|
2583
2692
|
const mac = this.env.platform === 'macos';
|
|
2584
2693
|
const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2585
2694
|
let natural = 0;
|
|
2586
|
-
|
|
2695
|
+
let widest = 0;
|
|
2696
|
+
for (const option of options) {
|
|
2697
|
+
const optionWidth = this.measurer.measureWidth(option, font);
|
|
2698
|
+
natural += optionWidth + PICKER_SEGMENT_PAD;
|
|
2699
|
+
widest = Math.max(widest, optionWidth);
|
|
2700
|
+
}
|
|
2701
|
+
// A Mac's segments are equal, the widest label and 18 (macOS 26).
|
|
2702
|
+
if (mac) natural = options.length * Math.ceil(widest + MAC_SEGMENT_PAD);
|
|
2587
2703
|
// A Mac shows the picker's label before the control (in a form's row it is in the label column).
|
|
2588
2704
|
const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
|
|
2589
2705
|
const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
|
|
2706
|
+
// On a Mac it is as wide as its segments; elsewhere it fills the width offered.
|
|
2707
|
+
const width = mac
|
|
2708
|
+
? Math.min(natural + labelWidth, finite(p.width) ? p.width : Infinity)
|
|
2709
|
+
: finite(p.width) ? p.width : natural + labelWidth;
|
|
2590
2710
|
return {
|
|
2591
|
-
size: { width
|
|
2711
|
+
size: { width, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
|
|
2592
2712
|
style: 'segmented',
|
|
2593
2713
|
font,
|
|
2594
2714
|
options,
|
|
@@ -2605,11 +2725,14 @@ export class LayoutPass {
|
|
|
2605
2725
|
const mac = this.env.platform === 'macos';
|
|
2606
2726
|
// The Mac's pop-up button holds the value and the chevrons in a bordered button.
|
|
2607
2727
|
const popup = mac && (!ctx.listRow || ctx.formColumn === true);
|
|
2608
|
-
|
|
2728
|
+
// macOS 26: a pop-up is as wide as its widest option, 13 before the label and the chevrons 9 from its end.
|
|
2729
|
+
const popupWidth = (): number => Math.ceil(Math.max(...options.map((option) => this.measurer.measureWidth(option, font)), 0) + MAC_PICKER_POPUP_EXTRA);
|
|
2730
|
+
const valueWidth = popup ? popupWidth() : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
|
|
2609
2731
|
const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
|
|
2610
2732
|
return {
|
|
2611
2733
|
size: {
|
|
2612
|
-
|
|
2734
|
+
// In a Mac columns form's row: the pop-up alone, at its width.
|
|
2735
|
+
width: ctx.formColumn ? Math.min(valueWidth, finite(p.width) ? p.width : Infinity) : finite(p.width) ? p.width : natural,
|
|
2613
2736
|
height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2614
2737
|
},
|
|
2615
2738
|
style: 'menu',
|
|
@@ -2729,7 +2852,7 @@ export class LayoutPass {
|
|
|
2729
2852
|
// A disclosure group's content sits 8 under its label, as SwiftUI's does.
|
|
2730
2853
|
const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
|
|
2731
2854
|
// A Mac list row's Label (no icon column there): the title 6 after the icon.
|
|
2732
|
-
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
|
|
2855
|
+
if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [cctx.sidebarList ? MAC_LIST_SIDEBAR_LABEL_GAP : MAC_LIST_LABEL_GAP];
|
|
2733
2856
|
if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
|
|
2734
2857
|
const gaps: number[] = [];
|
|
2735
2858
|
for (let i = 0; i < n - 1; i++) {
|
|
@@ -3196,9 +3319,15 @@ export class LayoutPass {
|
|
|
3196
3319
|
// List
|
|
3197
3320
|
// -------------------------------------------------------------------------
|
|
3198
3321
|
|
|
3322
|
+
/** 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. */
|
|
3323
|
+
private listStyleOf(node: LayoutNode, ctx: Context): string {
|
|
3324
|
+
const style = str(node.props['style']) ?? 'insetGrouped';
|
|
3325
|
+
return this.env.platform === 'macos' && ctx.splitSidebar && style === 'insetGrouped' ? 'sidebar' : style;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3199
3328
|
private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
3200
3329
|
return this.plan(node, ctx, p, () => {
|
|
3201
|
-
if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
|
|
3330
|
+
if (this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] !== 'grouped') return this.macFormPlan(node, ctx, p);
|
|
3202
3331
|
const { screen } = this.env;
|
|
3203
3332
|
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
3204
3333
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
@@ -3206,39 +3335,51 @@ export class LayoutPass {
|
|
|
3206
3335
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
3207
3336
|
// the space between sections. `sidebar` (iPadOS) is laid out like
|
|
3208
3337
|
// `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
|
|
3209
|
-
const listStyle =
|
|
3338
|
+
const listStyle = this.listStyleOf(node, ctx);
|
|
3210
3339
|
// In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
|
|
3211
3340
|
// (cards, chevrons, 52pt rows) and only its sections' folding remains.
|
|
3212
3341
|
const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
|
|
3213
|
-
// `.environment(\.defaultMinListRowHeight, _)
|
|
3342
|
+
// `.environment(\.defaultMinListRowHeight, _)` (the list's `minRowHeight`); 52 by default (44 in a sidebar, 24 on a Mac).
|
|
3214
3343
|
// The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
|
|
3215
3344
|
// 11-point bold headers, a sidebar's rows inset 10 and a grouped
|
|
3216
3345
|
// form's cards 20.
|
|
3217
3346
|
const mac = this.env.platform === 'macos';
|
|
3347
|
+
const macGrouped = mac && node.props['form'] === true && node.props['formStyle'] === 'grouped';
|
|
3218
3348
|
const minRowHeight =
|
|
3219
3349
|
num(node.props['minRowHeight']) ??
|
|
3350
|
+
(macGrouped ? MAC_GROUPED_ROW_HEIGHT : null) ??
|
|
3220
3351
|
(sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
3221
3352
|
// A Mac's lists have no cards: the rows run the whole width (a
|
|
3222
3353
|
// sidebar's inset 10).
|
|
3223
3354
|
const inset = mac
|
|
3224
|
-
?
|
|
3225
|
-
?
|
|
3226
|
-
:
|
|
3355
|
+
? macGrouped
|
|
3356
|
+
? Math.max(MAC_GROUPED_MARGIN, (layoutWidth - MAC_GROUPED_MAX_WIDTH) / 2)
|
|
3357
|
+
: listStyle === 'sidebar'
|
|
3358
|
+
? MAC_LIST_INSET_SIDEBAR
|
|
3359
|
+
// SwiftUI's inset list style: the list's own 16, which
|
|
3360
|
+
// `listRowInsets` does not touch (its rows have no padding of
|
|
3361
|
+
// their own); a row's separator runs to the trailing edge of it.
|
|
3362
|
+
: MAC_LIST_CELL_PADDING
|
|
3227
3363
|
: listStyle === 'insetGrouped' || listStyle === 'sidebar'
|
|
3228
3364
|
? LIST_INSET
|
|
3229
3365
|
: 0;
|
|
3230
3366
|
const plain = listStyle === 'plain';
|
|
3231
|
-
const cellPadding = mac
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
const
|
|
3235
|
-
const
|
|
3367
|
+
const cellPadding = mac
|
|
3368
|
+
? macGrouped ? MAC_GROUPED_CELL_PADDING : listStyle === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : 0
|
|
3369
|
+
: LIST_CELL_PADDING;
|
|
3370
|
+
const macSidebar = mac && listStyle === 'sidebar';
|
|
3371
|
+
const listTop = plain || macSidebar ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_TOP : LIST_TOP;
|
|
3372
|
+
const listBottom = plain ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
|
|
3373
|
+
const sectionGap = plain ? 0 : macGrouped ? MAC_GROUPED_SECTION_GAP : macSidebar ? MAC_LIST_SIDEBAR_SECTION_GAP : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
|
|
3374
|
+
const headerGap = macGrouped ? MAC_GROUPED_HEADER_GAP : macSidebar ? MAC_LIST_SIDEBAR_HEADER_GAP : mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
|
|
3236
3375
|
const cardX = inset;
|
|
3237
3376
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
3238
3377
|
const contentX = inset + cellPadding;
|
|
3239
3378
|
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
|
|
3240
3379
|
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
3241
|
-
const headerFont =
|
|
3380
|
+
const headerFont = macGrouped
|
|
3381
|
+
? makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 600)
|
|
3382
|
+
: mac
|
|
3242
3383
|
? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
|
|
3243
3384
|
: makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
|
|
3244
3385
|
const footerFont = makeFont(
|
|
@@ -3247,7 +3388,15 @@ export class LayoutPass {
|
|
|
3247
3388
|
400,
|
|
3248
3389
|
);
|
|
3249
3390
|
// A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
|
|
3250
|
-
const rowCtx: Context = {
|
|
3391
|
+
const rowCtx: Context = {
|
|
3392
|
+
...this.childContext(node, ctx),
|
|
3393
|
+
listRow: true,
|
|
3394
|
+
labelColumn: !mac,
|
|
3395
|
+
listCell: mac,
|
|
3396
|
+
sidebarList: listStyle === 'sidebar',
|
|
3397
|
+
...(mac && listStyle === 'sidebar' ? { lineLimit: 1 } : {}),
|
|
3398
|
+
...(macGrouped ? { groupedRow: true } : {}),
|
|
3399
|
+
};
|
|
3251
3400
|
// Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
|
|
3252
3401
|
// leading control (6 in from the card's edge) and its content shifts
|
|
3253
3402
|
// right by 40; a movable one a trailing grip, which narrows it by 40.
|
|
@@ -3284,6 +3433,8 @@ export class LayoutPass {
|
|
|
3284
3433
|
};
|
|
3285
3434
|
|
|
3286
3435
|
const groups: ListGroupPlan[] = [];
|
|
3436
|
+
// A Mac grouped form's text fields: their titles lead their rows (the page draws them as the columns form's labels).
|
|
3437
|
+
const fieldLabels: FormLabelPlan[] = [];
|
|
3287
3438
|
let y = listTop;
|
|
3288
3439
|
const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
|
|
3289
3440
|
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
@@ -3299,6 +3450,8 @@ export class LayoutPass {
|
|
|
3299
3450
|
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
3300
3451
|
y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
3301
3452
|
}
|
|
3453
|
+
// A Mac list's first header sits 4 above where its first row would.
|
|
3454
|
+
if (mac && !macGrouped && !macSidebar && !previous && (source.headerView !== null || (source.section !== null && (str(source.section.props['header']) ?? '').length > 0))) y -= MAC_LIST_FIRST_HEADER_LIFT;
|
|
3302
3455
|
const top = y;
|
|
3303
3456
|
let header: ListGroupPlan['header'] = null;
|
|
3304
3457
|
let footer: ListGroupPlan['footer'] = null;
|
|
@@ -3311,10 +3464,12 @@ export class LayoutPass {
|
|
|
3311
3464
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
3312
3465
|
if (headerText !== null && headerText.length > 0) {
|
|
3313
3466
|
const { layout, size } = makeText(headerText, headerFont);
|
|
3314
|
-
if (mac && previous) y += MAC_LIST_HEADER_TOP;
|
|
3467
|
+
if (mac && !macGrouped && previous) y += MAC_LIST_HEADER_TOP;
|
|
3468
|
+
else if (macSidebar) y += MAC_LIST_SIDEBAR_HEADER_TOP;
|
|
3315
3469
|
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
3316
|
-
|
|
3317
|
-
|
|
3470
|
+
const headerX = macSidebar ? inset + MAC_LIST_SIDEBAR_HEADER_INSET : contentX;
|
|
3471
|
+
header = { rect: { x: headerX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
3472
|
+
y = headerY + size.height + headerGap + (mac && !macSidebar && !macGrouped && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
|
|
3318
3473
|
}
|
|
3319
3474
|
const cardTop = y;
|
|
3320
3475
|
const rows: ListRowPlan[] = [];
|
|
@@ -3332,7 +3487,9 @@ export class LayoutPass {
|
|
|
3332
3487
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
3333
3488
|
// width (plus a gap) at the trailing edge.
|
|
3334
3489
|
const hints = rowHintsOf(rowNode);
|
|
3335
|
-
const defaultVPad =
|
|
3490
|
+
const defaultVPad = macGrouped
|
|
3491
|
+
? unwrapStyled(rowNode).kind === 'button' ? MAC_GROUPED_BUTTON_ROW_VPAD : MAC_GROUPED_ROW_VPAD
|
|
3492
|
+
: listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
|
|
3336
3493
|
// An outline's rows start LIST_OUTLINE_INDENT further in per level.
|
|
3337
3494
|
const indent = rowSource.depth * LIST_OUTLINE_INDENT;
|
|
3338
3495
|
const insets: EdgeInsets = hints.insets
|
|
@@ -3345,7 +3502,9 @@ export class LayoutPass {
|
|
|
3345
3502
|
};
|
|
3346
3503
|
// A group's label row keeps the chevron's width at its trailing edge, as a link's does (a Mac's rows have no chevron).
|
|
3347
3504
|
const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
|
|
3348
|
-
|
|
3505
|
+
// A Mac sidebar's badge is the small (11) text (macOS 26).
|
|
3506
|
+
const badgeFont = macSidebar ? makeFont('system', MAC_LIST_SIDEBAR_BADGE_FONT_SIZE, 400) : rowCtx.font;
|
|
3507
|
+
const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, badgeFont) : 0;
|
|
3349
3508
|
const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
|
|
3350
3509
|
// Phase 8 edit mode: a selectable or deletable `listrow` gains a
|
|
3351
3510
|
// leading control and its content shifts right; a movable one a
|
|
@@ -3355,8 +3514,12 @@ export class LayoutPass {
|
|
|
3355
3514
|
const hasGrip = listRow !== null && listRow.props['movable'] === true;
|
|
3356
3515
|
const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
3357
3516
|
const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
|
|
3358
|
-
const
|
|
3359
|
-
const
|
|
3517
|
+
const fieldNode = macGrouped ? unwrapStyled(rowNode) : null;
|
|
3518
|
+
const fieldLabel = fieldNode && fieldNode.kind === 'textfield' ? str(fieldNode.props['placeholder']) || null : null;
|
|
3519
|
+
const fieldLabelWidth = fieldLabel ? this.measurer.measureWidth(fieldLabel, rowCtx.font) : 0;
|
|
3520
|
+
const fieldShift = fieldLabel ? Math.ceil(fieldLabelWidth) + MAC_GROUPED_LABEL_GAP : 0;
|
|
3521
|
+
const rowContentX = cardX + insets.leading + editShift + fieldShift;
|
|
3522
|
+
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve - fieldShift);
|
|
3360
3523
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
3361
3524
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
3362
3525
|
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
@@ -3376,7 +3539,7 @@ export class LayoutPass {
|
|
|
3376
3539
|
hints.badge !== null
|
|
3377
3540
|
? {
|
|
3378
3541
|
rect: { x: cardX + cardWidth - insets.trailing - badgeWidth, y, width: badgeWidth, height: rowHeight },
|
|
3379
|
-
layout: { font:
|
|
3542
|
+
layout: { font: badgeFont, lines: [hints.badge], alignment: 'trailing', truncated: false },
|
|
3380
3543
|
}
|
|
3381
3544
|
: null,
|
|
3382
3545
|
edit: listRow
|
|
@@ -3391,7 +3554,17 @@ export class LayoutPass {
|
|
|
3391
3554
|
separatorHidden: hints.separatorHidden,
|
|
3392
3555
|
outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
|
|
3393
3556
|
});
|
|
3557
|
+
if (fieldLabel) {
|
|
3558
|
+
fieldLabels.push({
|
|
3559
|
+
rect: { x: cardX + insets.leading, y: y + (rowHeight - rowCtx.font.lineHeight) / 2, width: Math.ceil(fieldLabelWidth), height: rowCtx.font.lineHeight },
|
|
3560
|
+
layout: { font: rowCtx.font, lines: [fieldLabel], alignment: 'leading', truncated: false },
|
|
3561
|
+
node: null,
|
|
3562
|
+
ctx: rowCtx,
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3394
3565
|
y += rowHeight;
|
|
3566
|
+
// A Mac grouped form's separators are a point between its rows.
|
|
3567
|
+
if (macGrouped && rowSource !== source.rows[source.rows.length - 1]) y += MAC_GROUPED_SEPARATOR;
|
|
3395
3568
|
}
|
|
3396
3569
|
// Each group's rect: its label's row, then the rows under it that sit deeper, up to the next at its level or above.
|
|
3397
3570
|
const outlines: ListGroupPlan['outlines'] = [];
|
|
@@ -3406,10 +3579,10 @@ export class LayoutPass {
|
|
|
3406
3579
|
});
|
|
3407
3580
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
3408
3581
|
if (footerText !== null && footerText.length > 0) {
|
|
3409
|
-
y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3582
|
+
y += macGrouped ? MAC_GROUPED_FOOTER_GAP : mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
|
|
3410
3583
|
const { layout, size } = makeText(footerText, footerFont);
|
|
3411
3584
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
3412
|
-
y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3585
|
+
y += size.height + (mac && !macGrouped ? MAC_LIST_FOOTER_BOTTOM : 0);
|
|
3413
3586
|
}
|
|
3414
3587
|
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
3415
3588
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
@@ -3431,7 +3604,7 @@ export class LayoutPass {
|
|
|
3431
3604
|
width,
|
|
3432
3605
|
height: p.height === null ? contentHeight : p.height,
|
|
3433
3606
|
};
|
|
3434
|
-
return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups };
|
|
3607
|
+
return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups, ...(fieldLabels.length > 0 ? { labels: fieldLabels } : {}) };
|
|
3435
3608
|
});
|
|
3436
3609
|
}
|
|
3437
3610
|
|
|
@@ -3685,6 +3858,9 @@ export class LayoutPass {
|
|
|
3685
3858
|
width: LIST_CHEVRON_WIDTH,
|
|
3686
3859
|
height: size.height,
|
|
3687
3860
|
};
|
|
3861
|
+
} else if (ctx.groupedRow) {
|
|
3862
|
+
// A grouped form's link has the small chevron at its trailing end (macOS 26).
|
|
3863
|
+
chrome['chevron'] = { x: x + size.width - MAC_GROUPED_CHEVRON_WIDTH, y, width: MAC_GROUPED_CHEVRON_WIDTH, height: size.height };
|
|
3688
3864
|
}
|
|
3689
3865
|
this.setChrome(node, chrome);
|
|
3690
3866
|
}
|
|
@@ -4144,9 +4320,17 @@ export class LayoutPass {
|
|
|
4144
4320
|
const columns = plan.columns === true;
|
|
4145
4321
|
const rowCtx: Context = columns
|
|
4146
4322
|
? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
|
|
4147
|
-
: {
|
|
4323
|
+
: {
|
|
4324
|
+
...this.childContext(node, ctx),
|
|
4325
|
+
listRow: true,
|
|
4326
|
+
labelColumn: this.env.platform !== 'macos',
|
|
4327
|
+
listCell: this.env.platform === 'macos',
|
|
4328
|
+
sidebarList: this.listStyleOf(node, ctx) === 'sidebar',
|
|
4329
|
+
...(this.env.platform === 'macos' && this.listStyleOf(node, ctx) === 'sidebar' ? { lineLimit: 1 } : {}),
|
|
4330
|
+
...(this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] === 'grouped' ? { groupedRow: true } : {}),
|
|
4331
|
+
};
|
|
4148
4332
|
const mac = this.env.platform === 'macos';
|
|
4149
|
-
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
4333
|
+
const cellPadding = mac ? (this.listStyleOf(node, ctx) === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : MAC_LIST_CELL_PADDING) : LIST_CELL_PADDING;
|
|
4150
4334
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
4151
4335
|
// Under its screen's bar (and the large title that scrolls with it), the
|
|
4152
4336
|
// rows start below them.
|
|
@@ -4154,6 +4338,10 @@ export class LayoutPass {
|
|
|
4154
4338
|
const listChrome: Record<string, Rect> = {};
|
|
4155
4339
|
const listTexts: Record<string, TextLayout> = {};
|
|
4156
4340
|
if (covered) listChrome['bottomInset'] = covered;
|
|
4341
|
+
// The page draws a sidebar's look (`data-sb-list-look`) for a list the engine laid out as one.
|
|
4342
|
+
if (this.listStyleOf(node, ctx) === 'sidebar') listChrome['sidebarLook'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4343
|
+
// A Mac's grouped form: the page draws its cards.
|
|
4344
|
+
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 };
|
|
4157
4345
|
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
4158
4346
|
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
4159
4347
|
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
@@ -4162,8 +4350,8 @@ export class LayoutPass {
|
|
|
4162
4350
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
4163
4351
|
// The Mac idiom's columns form: the label column (chrome `formLabel<i>`
|
|
4164
4352
|
// with its text, or a view placed there) and the flag for the page.
|
|
4165
|
-
if (columns) {
|
|
4166
|
-
|
|
4353
|
+
if (columns) listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4354
|
+
{
|
|
4167
4355
|
(plan.labels ?? []).forEach((label, i) => {
|
|
4168
4356
|
if (label.layout) {
|
|
4169
4357
|
listChrome[`formLabel${i}`] = offset(label.rect);
|
|
@@ -4365,10 +4553,12 @@ export class LayoutPass {
|
|
|
4365
4553
|
// the unified bar takes over; the row is the whole bar.
|
|
4366
4554
|
const mac = this.env.platform === 'macos';
|
|
4367
4555
|
const hasBarItems = parts.toolbars.some((t) => {
|
|
4368
|
-
const placement =
|
|
4556
|
+
const placement = this.barPlacement(t);
|
|
4369
4557
|
return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
|
|
4370
4558
|
});
|
|
4371
|
-
|
|
4559
|
+
// On a Mac the field is the toolbar's, or over a sidebar's list.
|
|
4560
|
+
const searchInSidebar = mac && parts.search !== null && !barHidden && cctx.splitSidebar;
|
|
4561
|
+
const searchInBar = mac && parts.search !== null && !barHidden && !searchInSidebar;
|
|
4372
4562
|
// A Mac tab view's toolbar row (the window's top): the tab's root screen
|
|
4373
4563
|
// draws its bar as that row, without a title (the tab picker is there).
|
|
4374
4564
|
const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
|
|
@@ -4380,13 +4570,14 @@ export class LayoutPass {
|
|
|
4380
4570
|
const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
4381
4571
|
const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
|
|
4382
4572
|
const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
|
|
4573
|
+
const groupGap = mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING;
|
|
4383
4574
|
// A compact height (a phone on its side) has no large titles, whatever the
|
|
4384
4575
|
// display mode, as UIKit: the title is the bar's inline one.
|
|
4385
4576
|
const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
4386
4577
|
const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
4387
4578
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
4388
4579
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
4389
|
-
const placementOf = (t: LayoutNode): string | null =>
|
|
4580
|
+
const placementOf = (t: LayoutNode): string | null => this.barPlacement(t);
|
|
4390
4581
|
const inHiddenBar = (t: LayoutNode): boolean => {
|
|
4391
4582
|
const placement = placementOf(t);
|
|
4392
4583
|
if (placement === 'keyboard') return false;
|
|
@@ -4409,8 +4600,8 @@ export class LayoutPass {
|
|
|
4409
4600
|
: 0;
|
|
4410
4601
|
// In a tab view (a tab bar covers the bottom) the field goes under the
|
|
4411
4602
|
// title, as iOS 26 places it; elsewhere it floats at the bottom.
|
|
4412
|
-
const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
|
|
4413
|
-
const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
4603
|
+
const searchOnTop = (!mac && parts.search !== null && cctx.bottomBar > 0) || searchInSidebar;
|
|
4604
|
+
const searchRow = searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE + MAC_SIDEBAR_SEARCH_HEIGHT + MAC_SIDEBAR_SEARCH_BELOW : searchOnTop ? SEARCH_TOP_ROW : 0;
|
|
4414
4605
|
const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
4415
4606
|
// The on-screen keyboard (device mode) covers the screen's bottom. A
|
|
4416
4607
|
// bottom search field rises above it, as iOS 26 docks it on the
|
|
@@ -4443,12 +4634,12 @@ export class LayoutPass {
|
|
|
4443
4634
|
}
|
|
4444
4635
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
4445
4636
|
if (parts.search && !searchInBar) {
|
|
4446
|
-
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
4637
|
+
const marginH = searchInSidebar ? MAC_SIDEBAR_SEARCH_MARGIN_H : searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
4447
4638
|
chrome['searchField'] = {
|
|
4448
4639
|
x: x + marginH,
|
|
4449
|
-
y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchLift - searchBottom,
|
|
4640
|
+
y: searchOnTop ? y + barHeight + largeHeight + (searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE : SEARCH_TOP_ABOVE) : y + size.height - searchLift - searchBottom,
|
|
4450
4641
|
width: Math.max(0, size.width - 2 * marginH),
|
|
4451
|
-
height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
|
|
4642
|
+
height: searchInSidebar ? MAC_SIDEBAR_SEARCH_HEIGHT : searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
|
|
4452
4643
|
};
|
|
4453
4644
|
// The page draws a field under the title as the bar's (a gray capsule)
|
|
4454
4645
|
// and moves it with the content when that scrolls under the bar.
|
|
@@ -4514,7 +4705,7 @@ export class LayoutPass {
|
|
|
4514
4705
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
4515
4706
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
4516
4707
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
4517
|
-
const placement =
|
|
4708
|
+
const placement = placementOf(toolbar) ?? 'trailing';
|
|
4518
4709
|
if (placement === 'leading' || placement === 'trailing') {
|
|
4519
4710
|
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
|
|
4520
4711
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
@@ -4541,10 +4732,10 @@ export class LayoutPass {
|
|
|
4541
4732
|
const slots = run.groups.flatMap((g) => g.slots ?? []);
|
|
4542
4733
|
if (slots.length === 0) return 0;
|
|
4543
4734
|
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
|
|
4544
|
-
return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
|
|
4735
|
+
return slots.length > 1 ? inner + 2 * (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : inner;
|
|
4545
4736
|
};
|
|
4546
4737
|
for (const toolbar of parts.toolbars) {
|
|
4547
|
-
const placement =
|
|
4738
|
+
const placement = placementOf(toolbar);
|
|
4548
4739
|
if (placement !== 'leading' && placement !== 'trailing') continue;
|
|
4549
4740
|
if (isSpacer(toolbar)) {
|
|
4550
4741
|
delete open[placement];
|
|
@@ -4559,6 +4750,12 @@ export class LayoutPass {
|
|
|
4559
4750
|
runs.push({ placement, groups: [group], width: 0, segmented: true });
|
|
4560
4751
|
continue;
|
|
4561
4752
|
}
|
|
4753
|
+
// A Mac's text item is a capsule of its own (macOS 26), neither joining nor joined.
|
|
4754
|
+
if (mac && toolbar.children.some((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button')) {
|
|
4755
|
+
delete open[placement];
|
|
4756
|
+
runs.push({ placement, groups: [group], width: 0 });
|
|
4757
|
+
continue;
|
|
4758
|
+
}
|
|
4562
4759
|
// An item that hides the bar's shared glass stands alone, at its own width.
|
|
4563
4760
|
if (str(toolbar.props['sharedBackground']) === 'hidden') {
|
|
4564
4761
|
delete open[placement];
|
|
@@ -4576,7 +4773,7 @@ export class LayoutPass {
|
|
|
4576
4773
|
}
|
|
4577
4774
|
for (const run of runs) run.width = runWidth(run);
|
|
4578
4775
|
const runsWidth = (placement: string): number =>
|
|
4579
|
-
runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width +
|
|
4776
|
+
runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + groupGap, 0);
|
|
4580
4777
|
const leadingWidth = runsWidth('leading');
|
|
4581
4778
|
const trailingWidth = runsWidth('trailing');
|
|
4582
4779
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
@@ -4602,7 +4799,7 @@ export class LayoutPass {
|
|
|
4602
4799
|
});
|
|
4603
4800
|
// Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
|
|
4604
4801
|
const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
|
|
4605
|
-
previous && previous.toolbar !== entry.toolbar ?
|
|
4802
|
+
previous && previous.toolbar !== entry.toolbar ? groupGap : 0;
|
|
4606
4803
|
const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
|
|
4607
4804
|
entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
|
|
4608
4805
|
let inlineSecondary: SecondaryItem[] = [];
|
|
@@ -4611,7 +4808,7 @@ export class LayoutPass {
|
|
|
4611
4808
|
// bar) and the trailing groups; the More button takes a slot of it
|
|
4612
4809
|
// once anything collapses.
|
|
4613
4810
|
const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
|
|
4614
|
-
const roomStart = x + (size.width + titleWidth) / 2 +
|
|
4811
|
+
const roomStart = x + (size.width + titleWidth) / 2 + groupGap;
|
|
4615
4812
|
const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
|
|
4616
4813
|
const fitting = (room: number): SecondaryItem[] => {
|
|
4617
4814
|
const out: SecondaryItem[] = [];
|
|
@@ -4624,7 +4821,7 @@ export class LayoutPass {
|
|
|
4624
4821
|
}
|
|
4625
4822
|
return out;
|
|
4626
4823
|
};
|
|
4627
|
-
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass -
|
|
4824
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - groupGap);
|
|
4628
4825
|
}
|
|
4629
4826
|
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
4630
4827
|
|
|
@@ -4634,7 +4831,7 @@ export class LayoutPass {
|
|
|
4634
4831
|
// A Mac window's own title bar (the top of the screen, a title bar in the
|
|
4635
4832
|
// safe area) starts with the window buttons; the items follow them.
|
|
4636
4833
|
const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
|
|
4637
|
-
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4834
|
+
const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : mac ? MAC_BAR_LEADING_MARGIN : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
|
|
4638
4835
|
const glassY = rowY + (rowHeight - glass) / 2;
|
|
4639
4836
|
if (depth > 0 && !barHidden) {
|
|
4640
4837
|
chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
|
|
@@ -4642,20 +4839,21 @@ export class LayoutPass {
|
|
|
4642
4839
|
|
|
4643
4840
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
4644
4841
|
const back = chrome['back'];
|
|
4645
|
-
|
|
4646
|
-
let
|
|
4842
|
+
// On a Mac the back button is the first slot of the leading capsule (macOS 26).
|
|
4843
|
+
let leadingCursor = back ? back.x + back.width + (mac ? 0 : groupGap) : leadingStart;
|
|
4844
|
+
let trailingEdge = x + size.width - (mac ? MAC_BAR_MARGIN_TRAILING : NAV_ITEM_MARGIN);
|
|
4647
4845
|
// On a Mac the search field is the trailing-most item of the title bar
|
|
4648
4846
|
// (chrome `searchBar` tells the page to draw it as the toolbar's field).
|
|
4649
4847
|
if (searchInBar) {
|
|
4650
4848
|
const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
|
|
4651
|
-
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight -
|
|
4849
|
+
chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_BAR_ITEM_SIZE) / 2, width, height: MAC_BAR_ITEM_SIZE };
|
|
4652
4850
|
chrome['searchBar'] = chrome['searchField'];
|
|
4653
|
-
trailingEdge -= width +
|
|
4851
|
+
trailingEdge -= width + groupGap;
|
|
4654
4852
|
}
|
|
4655
4853
|
// The More button is the trailing-most item.
|
|
4656
4854
|
if (collapsedSecondary.length > 0) {
|
|
4657
4855
|
chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
|
|
4658
|
-
trailingEdge -= glass +
|
|
4856
|
+
trailingEdge -= glass + groupGap;
|
|
4659
4857
|
}
|
|
4660
4858
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
4661
4859
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
@@ -4681,7 +4879,7 @@ export class LayoutPass {
|
|
|
4681
4879
|
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
|
|
4682
4880
|
}
|
|
4683
4881
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
4684
|
-
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
4882
|
+
let cursor = startX + (count > 1 ? (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : 0);
|
|
4685
4883
|
for (const group of run.groups) {
|
|
4686
4884
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
4687
4885
|
const first = cursor;
|
|
@@ -4689,31 +4887,34 @@ export class LayoutPass {
|
|
|
4689
4887
|
const itemSize = group.sizes[i] as Size;
|
|
4690
4888
|
const slot = group.slots?.[i] ?? itemSize.width;
|
|
4691
4889
|
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
|
|
4692
|
-
// A Mac toolbar's text button is a bordered 28 button at its slot
|
|
4693
|
-
// (chrome `bordered`, drawn by the page at that rect); an icon is bare.
|
|
4694
|
-
if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
|
|
4695
|
-
const existing = this.chrome.get(item.id);
|
|
4696
|
-
this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
|
|
4697
|
-
}
|
|
4698
4890
|
cursor += slot + runGap;
|
|
4699
4891
|
});
|
|
4700
4892
|
const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
|
|
4701
4893
|
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
|
|
4702
4894
|
}
|
|
4703
4895
|
};
|
|
4704
|
-
|
|
4705
|
-
|
|
4896
|
+
const leadingRuns = runs.filter((r) => r.placement === 'leading' && r.width > 0);
|
|
4897
|
+
for (const run of leadingRuns) {
|
|
4706
4898
|
placeRun(run, leadingCursor);
|
|
4707
|
-
leadingCursor += run.width +
|
|
4899
|
+
leadingCursor += run.width + groupGap;
|
|
4900
|
+
}
|
|
4901
|
+
// A Mac's back button: its capsule is the first leading one's, widened to
|
|
4902
|
+
// take it in, or one of its own.
|
|
4903
|
+
if (mac && back) {
|
|
4904
|
+
const first = leadingRuns[0];
|
|
4905
|
+
const merged = first && !first.bare && !first.segmented ? chrome['leadingGlass0'] : undefined;
|
|
4906
|
+
if (merged) chrome['leadingGlass0'] = { ...merged, x: back.x, width: merged.x + merged.width - back.x };
|
|
4907
|
+
else chrome[`leadingGlass${glassCount.leading++}`] = { ...back };
|
|
4908
|
+
if (leadingRuns.length === 0) leadingCursor = back.x + back.width + groupGap;
|
|
4708
4909
|
}
|
|
4709
4910
|
const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
|
|
4710
|
-
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth -
|
|
4911
|
+
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - groupGap : 0);
|
|
4711
4912
|
const trailingStart = trailingCursor;
|
|
4712
4913
|
for (const run of trailingRuns) {
|
|
4713
4914
|
placeRun(run, trailingCursor);
|
|
4714
|
-
trailingCursor += run.width +
|
|
4915
|
+
trailingCursor += run.width + groupGap;
|
|
4715
4916
|
}
|
|
4716
|
-
if (trailingRuns.length > 0) trailingEdge = trailingStart -
|
|
4917
|
+
if (trailingRuns.length > 0) trailingEdge = trailingStart - groupGap;
|
|
4717
4918
|
for (const group of groups) {
|
|
4718
4919
|
if (group.placement !== 'principal') continue;
|
|
4719
4920
|
const startX = x + (size.width - group.total) / 2;
|
|
@@ -4736,7 +4937,7 @@ export class LayoutPass {
|
|
|
4736
4937
|
cursor += entry.slot;
|
|
4737
4938
|
}
|
|
4738
4939
|
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
|
|
4739
|
-
trailingEdge -= total +
|
|
4940
|
+
trailingEdge -= total + groupGap;
|
|
4740
4941
|
}
|
|
4741
4942
|
for (const toolbar of secondaryToolbars) {
|
|
4742
4943
|
const items = toolbar.children;
|
|
@@ -4795,14 +4996,19 @@ export class LayoutPass {
|
|
|
4795
4996
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
4796
4997
|
if (tabToolbar) {
|
|
4797
4998
|
// The tab picker stands where the title would be.
|
|
4999
|
+
} else if (mac && cctx.splitSidebar) {
|
|
5000
|
+
// A Mac's sidebar shows no title.
|
|
4798
5001
|
} else if ((unified || sheetBar) && !principal && !barHidden) {
|
|
4799
5002
|
// The unified title bar's title (and a Mac sheet's): leading-aligned
|
|
4800
5003
|
// after the back button and the leading items (2 past the window
|
|
4801
5004
|
// buttons' room when nothing is before it), cut to the room before
|
|
4802
5005
|
// the trailing ones.
|
|
4803
5006
|
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
|
|
4804
|
-
|
|
4805
|
-
const
|
|
5007
|
+
// A Mac toolbar's title is 12 after the window buttons' room or after the leading capsules.
|
|
5008
|
+
const titleX = unified
|
|
5009
|
+
? (back || leadingWidth > 0 ? leadingCursor - groupGap : leadingStart) + MAC_TITLE_GAP
|
|
5010
|
+
: Math.max(x + MAC_SHEET_TITLE_X, leadingCursor);
|
|
5011
|
+
const roomEnd = trailingUsed ? trailingEdge + groupGap - 8 : x + size.width - NAV_ITEM_MARGIN;
|
|
4806
5012
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4807
5013
|
if (subtitle.length > 0) {
|
|
4808
5014
|
// The title and the subtitle stacked, centered in the row.
|
|
@@ -4817,11 +5023,11 @@ export class LayoutPass {
|
|
|
4817
5023
|
} else if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
4818
5024
|
const leftUsed = Math.max(
|
|
4819
5025
|
back ? back.x + back.width - x : 0,
|
|
4820
|
-
leadingWidth > 0 ? leadingCursor -
|
|
4821
|
-
cctx.barLeading ? leadingStart -
|
|
5026
|
+
leadingWidth > 0 ? leadingCursor - groupGap - x : 0,
|
|
5027
|
+
cctx.barLeading ? leadingStart - groupGap - x : 0,
|
|
4822
5028
|
);
|
|
4823
5029
|
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
|
|
4824
|
-
const rightUsed = trailingUsed ? x + size.width - (trailingEdge +
|
|
5030
|
+
const rightUsed = trailingUsed ? x + size.width - (trailingEdge + groupGap) : 0;
|
|
4825
5031
|
// As UIKit places it: centered in the bar when it fits there; else slid
|
|
4826
5032
|
// off-center into the room the items leave (between them, 8 clear of
|
|
4827
5033
|
// each side), and truncated only when it does not fit even there.
|
|
@@ -4834,7 +5040,9 @@ export class LayoutPass {
|
|
|
4834
5040
|
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
4835
5041
|
);
|
|
4836
5042
|
const centered = x + (size.width - titleWidth) / 2;
|
|
4837
|
-
|
|
5043
|
+
// A Mac's compact title bar: the title leading-aligned after the window buttons (macOS 26).
|
|
5044
|
+
const leading = mac ? x + (windowButtons ? MAC_COMPACT_TITLE_X : NAV_ITEM_MARGIN) : null;
|
|
5045
|
+
const titleX = leading !== null ? Math.max(roomStart, Math.min(leading, roomEnd - titleWidth)) : Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
4838
5046
|
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4839
5047
|
}
|
|
4840
5048
|
this.setChrome(node, chrome);
|
|
@@ -4842,7 +5050,7 @@ export class LayoutPass {
|
|
|
4842
5050
|
if (node.id >= 0) {
|
|
4843
5051
|
this.fonts.set(node.id, large ? largeTitleFont : titleFont);
|
|
4844
5052
|
const texts: Record<string, TextLayout> = {
|
|
4845
|
-
title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
|
|
5053
|
+
title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar || mac ? 'leading' : 'center', truncated: false },
|
|
4846
5054
|
};
|
|
4847
5055
|
if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
|
|
4848
5056
|
if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
|
|
@@ -5137,38 +5345,34 @@ export class LayoutPass {
|
|
|
5137
5345
|
}
|
|
5138
5346
|
|
|
5139
5347
|
/**
|
|
5140
|
-
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a
|
|
5141
|
-
*
|
|
5142
|
-
*
|
|
5143
|
-
* content is the rest, and the selected tab fills it (its
|
|
5144
|
-
* reserves no status bar there: the row is above it).
|
|
5145
|
-
* the bar as the top one for the page.
|
|
5348
|
+
* The Mac idiom's tab view (MAC_TAB_* above): the bar is a glass capsule
|
|
5349
|
+
* of equal segments centered in a 52 row at the top: the window's toolbar
|
|
5350
|
+
* when the tab view is at the top of the screen, else a row at the top of
|
|
5351
|
+
* the content. The content is the rest, and the selected tab fills it (its
|
|
5352
|
+
* own `navstack` reserves no status bar there: the row is above it).
|
|
5353
|
+
* Chrome `top` marks the bar as the top one for the page.
|
|
5146
5354
|
*/
|
|
5147
5355
|
private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
5148
5356
|
const { safeArea } = this.env;
|
|
5149
|
-
// At the top of the window the row is the window's toolbar (38, the
|
|
5150
|
-
// title bar included); lower down, a 38 row at the top of the content.
|
|
5151
5357
|
const atTop = y <= 0 && safeArea.top > 0;
|
|
5152
|
-
const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
|
|
5153
5358
|
const tabs = node.children;
|
|
5154
5359
|
const n = tabs.length;
|
|
5155
|
-
// Each segment is
|
|
5360
|
+
// Each segment is the widest title (13 regular) and 11 either side.
|
|
5156
5361
|
const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
|
|
5157
|
-
const
|
|
5158
|
-
const
|
|
5362
|
+
const widest = tabs.reduce((w, tab) => Math.max(w, this.measurer.measureWidth(str(tab.props['title']) ?? '', font)), 0);
|
|
5363
|
+
const itemWidth = widest + 2 * MAC_TAB_ITEM_PAD_H;
|
|
5364
|
+
const natural = n * itemWidth + 2 * MAC_TAB_ITEM_INSET;
|
|
5159
5365
|
const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
|
|
5160
|
-
const
|
|
5161
|
-
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height
|
|
5162
|
-
const
|
|
5163
|
-
const
|
|
5164
|
-
const
|
|
5165
|
-
const content: Rect = { x, y: y +
|
|
5366
|
+
const segment = n > 0 ? (width - 2 * MAC_TAB_ITEM_INSET) / n : 0;
|
|
5367
|
+
const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height));
|
|
5368
|
+
const barHeight = Math.min(MAC_TAB_BAR_HEIGHT, rowHeight);
|
|
5369
|
+
const bar: Rect = snapRect({ x: x + (size.width - width) / 2, y: y + (rowHeight - barHeight) / 2, width, height: barHeight });
|
|
5370
|
+
const toolbar: Rect = { x, y, width: size.width, height: rowHeight };
|
|
5371
|
+
const content: Rect = { x, y: y + rowHeight, width: size.width, height: Math.max(0, size.height - rowHeight) };
|
|
5166
5372
|
const chrome: Record<string, Rect> = { bar, content, top: bar };
|
|
5167
|
-
|
|
5373
|
+
const itemHeight = Math.min(MAC_TAB_ITEM_HEIGHT, barHeight);
|
|
5168
5374
|
for (let i = 0; i < n; i++) {
|
|
5169
|
-
|
|
5170
|
-
chrome[`item${i}`] = { x: itemX, y: bar.y + MAC_TAB_ITEM_INSET, width: itemWidth, height: Math.max(0, bar.height - 2 * MAC_TAB_ITEM_INSET) };
|
|
5171
|
-
itemX += itemWidth;
|
|
5375
|
+
chrome[`item${i}`] = snapRect({ x: bar.x + MAC_TAB_ITEM_INSET + i * segment, y: bar.y + (barHeight - itemHeight) / 2, width: segment, height: itemHeight });
|
|
5172
5376
|
}
|
|
5173
5377
|
this.setChrome(node, chrome);
|
|
5174
5378
|
const selected = readSelected(node, n);
|
|
@@ -5261,33 +5465,54 @@ export class LayoutPass {
|
|
|
5261
5465
|
const leading = x <= 0 ? safeArea.leading : 0;
|
|
5262
5466
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
5263
5467
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
5264
|
-
const
|
|
5468
|
+
const mac = this.env.platform === 'macos';
|
|
5469
|
+
const [sidebar, detail, ...rest] = node.children;
|
|
5470
|
+
// The sidebar's width: the app's `.navigationSplitViewColumnWidth` (a
|
|
5471
|
+
// `splitColumnWidth` hint on the sidebar's content, its ideal width
|
|
5472
|
+
// between its min and max), else the platform's default; on a Mac the
|
|
5473
|
+
// user drags the column's edge (`env.sidebarWidths`) between the same
|
|
5474
|
+
// bounds. Never more than 40% of the width.
|
|
5475
|
+
const requested = sidebar ? splitColumnWidthOf(sidebar) : null;
|
|
5476
|
+
const fallback = mac ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH;
|
|
5477
|
+
const minWidth = requested?.min ?? Math.min(fallback, requested?.ideal ?? fallback);
|
|
5478
|
+
const maxWidth = Math.min(requested?.max ?? Infinity, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
5479
|
+
const dragged = mac ? this.env.sidebarWidths?.[node.id] : undefined;
|
|
5480
|
+
const sidebarWidth = Math.max(0, Math.min(maxWidth, Math.max(minWidth, dragged ?? requested?.ideal ?? fallback)));
|
|
5265
5481
|
const visible = node.props['sidebarVisible'] !== false;
|
|
5482
|
+
// A Mac's sidebar is a panel inset from the window's edges, with no separator.
|
|
5483
|
+
const panelInset = mac ? MAC_NAVSPLIT_SIDEBAR_INSET : 0;
|
|
5484
|
+
const separatorWidth = mac ? 0 : NAVSPLIT_SEPARATOR_WIDTH;
|
|
5266
5485
|
// Hidden, the sidebar and its separator sit just off the leading edge.
|
|
5267
|
-
const sidebarX = visible ? x + leading : x - sidebarWidth -
|
|
5486
|
+
const sidebarX = visible ? x + leading + panelInset : x - sidebarWidth - separatorWidth;
|
|
5268
5487
|
const separatorX = sidebarX + sidebarWidth;
|
|
5269
|
-
const detailX = visible ? separatorX +
|
|
5488
|
+
const detailX = visible ? separatorX + separatorWidth : x + leading;
|
|
5270
5489
|
const detailWidth = Math.max(0, x + size.width - trailing - detailX);
|
|
5271
5490
|
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
5272
|
-
// On a Mac the toggle is a
|
|
5273
|
-
//
|
|
5274
|
-
//
|
|
5275
|
-
const mac = this.env.platform === 'macos';
|
|
5491
|
+
// On a Mac the toggle is a bare 36 slot centered in the toolbar's row: at
|
|
5492
|
+
// the sidebar panel's trailing end, or after the window buttons while
|
|
5493
|
+
// the sidebar is hidden.
|
|
5276
5494
|
const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
|
|
5277
|
-
const toggleX =
|
|
5495
|
+
const toggleX = mac && visible
|
|
5496
|
+
? sidebarX + sidebarWidth - MAC_NAVSPLIT_TOGGLE_INSET - toggleSize
|
|
5497
|
+
: x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
|
|
5278
5498
|
const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
|
|
5279
5499
|
const chrome: Record<string, Rect> = {
|
|
5280
5500
|
sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
|
|
5281
|
-
separator: { x: separatorX, y, width:
|
|
5501
|
+
separator: { x: separatorX, y, width: separatorWidth, height: size.height },
|
|
5282
5502
|
detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
|
|
5283
5503
|
toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
|
|
5284
5504
|
};
|
|
5505
|
+
// The Mac's sidebar panel (the page draws it), inset from the window's edges.
|
|
5506
|
+
if (mac) chrome['panel'] = { x: sidebarX, y: y + panelInset, width: sidebarWidth, height: Math.max(0, size.height - 2 * panelInset) };
|
|
5507
|
+
// The Mac's column divider (the page's drag handle for the sidebar's
|
|
5508
|
+
// width), a strip astride the panel's trailing edge while it shows.
|
|
5509
|
+
if (mac && visible) chrome['divider'] = { x: separatorX - MAC_NAVSPLIT_DIVIDER_GRIP / 2, y, width: MAC_NAVSPLIT_DIVIDER_GRIP, height: size.height };
|
|
5285
5510
|
this.setChrome(node, chrome);
|
|
5286
|
-
const toggleRoom = toggleSize + NAV_GROUP_SPACING;
|
|
5287
|
-
const [sidebar, detail, ...rest] = node.children;
|
|
5511
|
+
const toggleRoom = toggleSize + (mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING);
|
|
5288
5512
|
if (sidebar) {
|
|
5289
5513
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
5290
|
-
|
|
5514
|
+
// On a Mac the toggle is at the sidebar's trailing end: its screens' items start where they would.
|
|
5515
|
+
this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible && !mac ? toggleRoom : 0 }, proposal, sidebarX, y);
|
|
5291
5516
|
}
|
|
5292
5517
|
if (detail) {
|
|
5293
5518
|
const proposal: Proposal = { width: detailWidth, height: size.height };
|
|
@@ -5512,12 +5737,20 @@ export class LayoutPass {
|
|
|
5512
5737
|
private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
5513
5738
|
const plan = this.sliderPlan(node, ctx, p);
|
|
5514
5739
|
const { cctx } = plan;
|
|
5515
|
-
// The label is not drawn (a Mac form places it in its label column).
|
|
5516
|
-
if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
|
|
5517
5740
|
const m = this.sliderMetrics();
|
|
5518
5741
|
const rowY = y;
|
|
5519
5742
|
let trackX = x;
|
|
5520
5743
|
let trackEnd = x + size.width;
|
|
5744
|
+
// In a Mac grouped form the label leads the row and the slider takes its
|
|
5745
|
+
// trailing 55%; elsewhere the label is not drawn (a Mac columns form
|
|
5746
|
+
// places it in its label column).
|
|
5747
|
+
if (plan.label && ctx.groupedRow) {
|
|
5748
|
+
const labelSize = this.measure(plan.label, cctx, NIL_PROPOSAL);
|
|
5749
|
+
this.place(plan.label, cctx, NIL_PROPOSAL, x, rowY + (m.height - labelSize.height) / 2);
|
|
5750
|
+
trackX = x + size.width * (1 - MAC_GROUPED_SLIDER_FRACTION);
|
|
5751
|
+
} else if (plan.label && !ctx.formColumn) {
|
|
5752
|
+
this.hide(plan.label, node);
|
|
5753
|
+
}
|
|
5521
5754
|
if (plan.min) {
|
|
5522
5755
|
this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
|
|
5523
5756
|
trackX += plan.minSize.width + SLIDER_LABEL_GAP;
|
|
@@ -5672,7 +5905,8 @@ export class LayoutPass {
|
|
|
5672
5905
|
const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
|
|
5673
5906
|
const textWidth = cardWidth - 2 * m.padH;
|
|
5674
5907
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
5675
|
-
|
|
5908
|
+
// The Mac's message is the body text (13), as large as the title.
|
|
5909
|
+
const messageFont = resolveFont({ textStyle: m === MAC_ALERT_METRICS ? 'body' : 'subheadline' }, ctx.font, this.env);
|
|
5676
5910
|
// Heights first (the card is centered), then the rects.
|
|
5677
5911
|
let h = m.padTop;
|
|
5678
5912
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
@@ -5698,9 +5932,9 @@ export class LayoutPass {
|
|
|
5698
5932
|
const headerHeight = h;
|
|
5699
5933
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
5700
5934
|
h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
|
|
5701
|
-
// Centered in the safe area, as iOS does.
|
|
5935
|
+
// Centered in the safe area, as iOS does; a Mac's panel in the window.
|
|
5702
5936
|
const { safeArea: inset } = this.env;
|
|
5703
|
-
const top = y + (y <= 0 ? inset.top : 0);
|
|
5937
|
+
const top = y + (y <= 0 && m !== MAC_ALERT_METRICS ? inset.top : 0);
|
|
5704
5938
|
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
5705
5939
|
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
5706
5940
|
chrome['card'] = card;
|