@swiftbrowser/web 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/layout/chart.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const CHART_DEFAULT_WIDTH = 300;
|
|
|
14
14
|
export const CHART_DEFAULT_HEIGHT = 200;
|
|
15
15
|
/** Gap between an axis label and the plot edge (the ticks sit inside it). */
|
|
16
16
|
export const CHART_AXIS_PAD = 6;
|
|
17
|
+
/** How far a leading-anchored x label (a date's) starts after its tick. */
|
|
18
|
+
export const CHART_LEADING_LABEL_OFFSET = 2;
|
|
17
19
|
export const CHART_TICK_LENGTH = 4;
|
|
18
20
|
/** Legend glyph: a `symbolSize`-px symbol, or an 18 × 3 line when the item has no symbol. */
|
|
19
21
|
export const CHART_LEGEND_GLYPH = 10;
|
|
@@ -32,8 +34,11 @@ export type ChartItemPosition = 'top' | 'bottom' | 'leading' | 'trailing' | 'ove
|
|
|
32
34
|
|
|
33
35
|
export interface ChartAxisInput {
|
|
34
36
|
position: ChartAxisPosition;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Text labels: unit position and measured width (height is `labelHeight`);
|
|
39
|
+
* `leading`: an x label starts at its tick (a date's) instead of centering on it.
|
|
40
|
+
*/
|
|
41
|
+
labels: { at: number; width: number; leading?: boolean }[];
|
|
37
42
|
/** Line height of the axis font. */
|
|
38
43
|
labelHeight: number;
|
|
39
44
|
/** View labels (`xLabel` / `yLabel` children) at their ideal size. */
|
|
@@ -186,7 +191,8 @@ export function layoutChart(input: ChartLayoutInput): ChartGeometry {
|
|
|
186
191
|
const top = (h: number) => (xTop ? plot.y - CHART_AXIS_PAD - h : plot.y + plot.height + CHART_AXIS_PAD);
|
|
187
192
|
for (const label of xAxis.labels) {
|
|
188
193
|
const h = xAxis.labelHeight;
|
|
189
|
-
const
|
|
194
|
+
const start = label.leading ? mapX(plot, label.at) + CHART_LEADING_LABEL_OFFSET : mapX(plot, label.at) - label.width / 2;
|
|
195
|
+
const x = clampTo(start, label.width, 0, width);
|
|
190
196
|
xLabels.push({ x, y: top(h), width: label.width, height: h });
|
|
191
197
|
}
|
|
192
198
|
for (const view of xAxis.views) {
|
package/src/layout/engine.ts
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
|
|
23
|
-
import { wrapText } from './text';
|
|
23
|
+
import { wrapRuns, wrapText, type TextRunInput } from './text';
|
|
24
24
|
import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
|
|
25
|
+
import { videoSize } from './video';
|
|
25
26
|
import {
|
|
26
27
|
CHART_LEGEND_GAP,
|
|
27
28
|
CHART_LEGEND_GLYPH,
|
|
@@ -83,6 +84,11 @@ export interface Context {
|
|
|
83
84
|
* the screen at the root, a scroll view's viewport inside one.
|
|
84
85
|
*/
|
|
85
86
|
container: Size;
|
|
87
|
+
/**
|
|
88
|
+
* The lower bound of the enclosing `.lineLimit(2...4)` range (`lineLimitMin`):
|
|
89
|
+
* a vertical text field is at least this many lines tall. Absent for 1.
|
|
90
|
+
*/
|
|
91
|
+
minLines?: number;
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
// Constants from the spec.
|
|
@@ -100,6 +106,8 @@ const TOGGLE_GAP = 8;
|
|
|
100
106
|
const TEXTFIELD_DEFAULT_WIDTH = 200;
|
|
101
107
|
const TEXTFIELD_HEIGHT_ROUNDED = 34;
|
|
102
108
|
const TEXTFIELD_HEIGHT_PLAIN = 22;
|
|
109
|
+
/** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
|
|
110
|
+
const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
|
|
103
111
|
const NAV_BAR_HEIGHT = 44;
|
|
104
112
|
const LARGE_TITLE_HEIGHT = 52;
|
|
105
113
|
/** Chevron glyph plus its gap, in px. */
|
|
@@ -132,6 +140,10 @@ const PICKER_MENU_GAP = 12;
|
|
|
132
140
|
const GAUGE_BAR_HEIGHT = 4;
|
|
133
141
|
const GAUGE_LABEL_GAP = 4;
|
|
134
142
|
const GAUGE_DEFAULT_WIDTH = 100;
|
|
143
|
+
/** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
|
|
144
|
+
const GAUGE_RING_SIZE = 58;
|
|
145
|
+
/** The square inside the ring its label is proposed, as a fraction of the side. */
|
|
146
|
+
const GAUGE_RING_INNER = 0.62;
|
|
135
147
|
const GRID_ITEM_DEFAULT_MIN = 10;
|
|
136
148
|
// Phase 6 (docs/phase6/chrome.md).
|
|
137
149
|
const PICKER_INLINE_ROW_HEIGHT = 44;
|
|
@@ -457,6 +469,8 @@ interface RowHints {
|
|
|
457
469
|
badge: string | null;
|
|
458
470
|
/** `.listRowInsets(_:)`, or null for the defaults. */
|
|
459
471
|
insets: EdgeInsets | null;
|
|
472
|
+
/** `.listRowSeparator(.hidden, edges:)`: the edges whose separator the row hides. */
|
|
473
|
+
separatorHidden: { top: boolean; bottom: boolean } | null;
|
|
460
474
|
}
|
|
461
475
|
|
|
462
476
|
/**
|
|
@@ -464,7 +478,7 @@ interface RowHints {
|
|
|
464
478
|
* outermost wins, like `layoutPriority`): `badge` and `listRowInsets`.
|
|
465
479
|
*/
|
|
466
480
|
function rowHintsOf(node: LayoutNode): RowHints {
|
|
467
|
-
const hints: RowHints = { badge: null, insets: null };
|
|
481
|
+
const hints: RowHints = { badge: null, insets: null, separatorHidden: null };
|
|
468
482
|
let current: LayoutNode | undefined = node;
|
|
469
483
|
// `NavigationLink(value:) { OrderRow().badge(n) }`: the hints sit on the
|
|
470
484
|
// link's label, which is the row's content. Phase 8 wrappers (`listrow`,
|
|
@@ -477,6 +491,11 @@ function rowHintsOf(node: LayoutNode): RowHints {
|
|
|
477
491
|
if (badge !== null && badge.length > 0) hints.badge = badge;
|
|
478
492
|
}
|
|
479
493
|
if (hints.insets === null) hints.insets = readInsets(style['listRowInsets']);
|
|
494
|
+
const separator = style['listRowSeparator'];
|
|
495
|
+
if (hints.separatorHidden === null && typeof separator === 'object' && separator !== null) {
|
|
496
|
+
const edges = separator as Record<string, unknown>;
|
|
497
|
+
hints.separatorHidden = { top: edges['top'] === true, bottom: edges['bottom'] === true };
|
|
498
|
+
}
|
|
480
499
|
current = current.children.length === 1 ? current.children[0] : undefined;
|
|
481
500
|
} else if (current.kind === 'navlink' && current.children.length === 1) {
|
|
482
501
|
current = current.children[0];
|
|
@@ -581,6 +600,49 @@ function alignIn(container: Size, child: Size, alignment: string): { dx: number;
|
|
|
581
600
|
};
|
|
582
601
|
}
|
|
583
602
|
|
|
603
|
+
/** The guide names a pair of anchors aligns by: `leading`/`center`/`trailing` and `top`/`center`/`bottom`. */
|
|
604
|
+
function guideNames([ax, ay]: [number, number]): [string, string] {
|
|
605
|
+
return [ax === 0 ? 'leading' : ax === 1 ? 'trailing' : 'center', ay === 0 ? 'top' : ay === 1 ? 'bottom' : 'center'];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The guide `name` on `axis` a view moved with `.alignmentGuide`, as
|
|
610
|
+
* `[a, b, c]` (the guide is `a + b·width + c·height`): set on the view or on
|
|
611
|
+
* a `styled` wrapper inside it (the modifiers between change nothing here).
|
|
612
|
+
*/
|
|
613
|
+
export function explicitGuide(node: LayoutNode, axis: 'horizontal' | 'vertical', name: string): [number, number, number] | null {
|
|
614
|
+
let current: LayoutNode | undefined = node;
|
|
615
|
+
for (let depth = 0; current && current.kind === 'styled' && depth < 32; depth++) {
|
|
616
|
+
const guide = (current.props['style'] as { alignmentGuide?: { axis?: string; name?: string; value?: unknown } } | undefined)?.alignmentGuide;
|
|
617
|
+
if (guide && guide.axis === axis && guide.name === name && Array.isArray(guide.value) && guide.value.length === 3) {
|
|
618
|
+
const [a, b, c] = guide.value.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) as [number, number, number];
|
|
619
|
+
return [a, b, c];
|
|
620
|
+
}
|
|
621
|
+
current = current.children.length === 1 ? current.children[0] : undefined;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function guideValue([a, b, c]: [number, number, number], size: Size): number {
|
|
627
|
+
return a + b * size.width + c * size.height;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* `alignIn` for a child that may have moved its guides (`.alignmentGuide`):
|
|
632
|
+
* on an axis where it did, its guide meets the container's guide for the
|
|
633
|
+
* same alignment (0, the middle or the end); elsewhere it aligns as before.
|
|
634
|
+
*/
|
|
635
|
+
function alignChild(container: Size, child: Size, alignment: string, node: LayoutNode): { dx: number; dy: number } {
|
|
636
|
+
const anchors = alignmentAnchors(alignment);
|
|
637
|
+
const [horizontal, vertical] = guideNames(anchors);
|
|
638
|
+
const offsets = alignIn(container, child, alignment);
|
|
639
|
+
const gx = explicitGuide(node, 'horizontal', horizontal);
|
|
640
|
+
const gy = explicitGuide(node, 'vertical', vertical);
|
|
641
|
+
if (gx && Number.isFinite(container.width)) offsets.dx = container.width * anchors[0] - guideValue(gx, child);
|
|
642
|
+
if (gy && Number.isFinite(container.height)) offsets.dy = container.height * anchors[1] - guideValue(gy, child);
|
|
643
|
+
return offsets;
|
|
644
|
+
}
|
|
645
|
+
|
|
584
646
|
function safeOffset(container: number, child: number, anchor: number): number {
|
|
585
647
|
if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
|
|
586
648
|
return (container - child) * anchor;
|
|
@@ -682,6 +744,17 @@ interface ListRowPlan {
|
|
|
682
744
|
badge: { rect: Rect; layout: TextLayout } | null;
|
|
683
745
|
/** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
|
|
684
746
|
edit: { listRow: LayoutNode; control: Rect } | null;
|
|
747
|
+
/** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
|
|
748
|
+
separatorHidden?: { top: boolean; bottom: boolean } | null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
|
|
752
|
+
interface ListSupplementPlan {
|
|
753
|
+
node: LayoutNode;
|
|
754
|
+
ctx: Context;
|
|
755
|
+
proposal: Proposal;
|
|
756
|
+
/** Relative to the list origin at scroll 0. */
|
|
757
|
+
rect: Rect;
|
|
685
758
|
}
|
|
686
759
|
|
|
687
760
|
interface ListGroupPlan {
|
|
@@ -689,6 +762,8 @@ interface ListGroupPlan {
|
|
|
689
762
|
frame: Rect;
|
|
690
763
|
header: { rect: Rect; layout: TextLayout } | null;
|
|
691
764
|
footer: { rect: Rect; layout: TextLayout } | null;
|
|
765
|
+
headerView: ListSupplementPlan | null;
|
|
766
|
+
footerView: ListSupplementPlan | null;
|
|
692
767
|
card: Rect;
|
|
693
768
|
rows: ListRowPlan[];
|
|
694
769
|
}
|
|
@@ -769,6 +844,8 @@ interface GaugePlan {
|
|
|
769
844
|
/** Height of the label row (0 without labels). */
|
|
770
845
|
labelRow: number;
|
|
771
846
|
labelCtx: Context;
|
|
847
|
+
/** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
|
|
848
|
+
ring?: { center: LayoutNode | null; centerSize: Size };
|
|
772
849
|
}
|
|
773
850
|
|
|
774
851
|
/** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
|
|
@@ -1030,7 +1107,11 @@ export class LayoutPass {
|
|
|
1030
1107
|
if (font !== ctx.font) next = { ...next, font };
|
|
1031
1108
|
if ('lineLimit' in style) {
|
|
1032
1109
|
const limit = num(style['lineLimit']);
|
|
1110
|
+
const min = num(style['lineLimitMin']);
|
|
1033
1111
|
next = { ...next, lineLimit: limit !== null && limit >= 1 ? Math.floor(limit) : null };
|
|
1112
|
+
// A new limit replaces the whole range, lower bound included.
|
|
1113
|
+
if (min !== null && min > 1) next.minLines = Math.floor(min);
|
|
1114
|
+
else delete next.minLines;
|
|
1034
1115
|
}
|
|
1035
1116
|
const alignment = str(style['multilineTextAlignment']);
|
|
1036
1117
|
if (alignment === 'leading' || alignment === 'center' || alignment === 'trailing') {
|
|
@@ -1120,6 +1201,9 @@ export class LayoutPass {
|
|
|
1120
1201
|
return this.measureImage(node, ctx, p);
|
|
1121
1202
|
case 'spacer':
|
|
1122
1203
|
return this.measureSpacer(node, ctx, p);
|
|
1204
|
+
case 'lazyspacer':
|
|
1205
|
+
// Lazy rows not built yet: no space (the page builds them as it nears).
|
|
1206
|
+
return ZERO;
|
|
1123
1207
|
case 'divider':
|
|
1124
1208
|
if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
|
|
1125
1209
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
@@ -1136,6 +1220,7 @@ export class LayoutPass {
|
|
|
1136
1220
|
case 'toggle':
|
|
1137
1221
|
return this.measureToggle(node, ctx, p);
|
|
1138
1222
|
case 'textfield': {
|
|
1223
|
+
if (node.props['axis'] === 'vertical') return this.measureMultilineField(node, ctx, p);
|
|
1139
1224
|
const style = str(node.props['style']);
|
|
1140
1225
|
return {
|
|
1141
1226
|
width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
|
|
@@ -1144,7 +1229,11 @@ export class LayoutPass {
|
|
|
1144
1229
|
}
|
|
1145
1230
|
case 'color':
|
|
1146
1231
|
case 'shape':
|
|
1232
|
+
case 'canvas':
|
|
1147
1233
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1234
|
+
case 'video':
|
|
1235
|
+
// Fills the proposal; an open axis follows the video's aspect ratio.
|
|
1236
|
+
return videoSize(node.props, p);
|
|
1148
1237
|
case 'scrollview':
|
|
1149
1238
|
return this.scrollPlan(node, ctx, p).size;
|
|
1150
1239
|
case 'list':
|
|
@@ -1230,8 +1319,26 @@ export class LayoutPass {
|
|
|
1230
1319
|
}
|
|
1231
1320
|
}
|
|
1232
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* A vertical `textfield`: the proposed width (200 when unconstrained) and
|
|
1324
|
+
* as many lines as its text (or, empty, its placeholder) wraps to in the
|
|
1325
|
+
* context font, between the range's `minLines` and `lineLimit`.
|
|
1326
|
+
*/
|
|
1327
|
+
private measureMultilineField(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1328
|
+
const inset = str(node.props['style']) === 'roundedBorder' ? TEXTFIELD_ROUNDED_INSET : { horizontal: 0, vertical: 0 };
|
|
1329
|
+
const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
|
|
1330
|
+
const text = str(node.props['text']) || str(node.props['placeholder']) || '';
|
|
1331
|
+
const wrapped = wrapText(text, ctx.font, Math.max(1, width - 2 * inset.horizontal), null, this.measurer);
|
|
1332
|
+
const min = Math.max(1, ctx.minLines ?? 1);
|
|
1333
|
+
const max = Math.max(min, ctx.lineLimit ?? Infinity);
|
|
1334
|
+
const lines = Math.min(Math.max(wrapped.lines.length, min), max);
|
|
1335
|
+
return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1233
1338
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
1234
1339
|
return this.plan(node, p, () => {
|
|
1340
|
+
const runs = node.props['runs'];
|
|
1341
|
+
if (Array.isArray(runs)) return this.richTextPlan(runs, ctx, p);
|
|
1235
1342
|
const text = str(node.props['text']) ?? '';
|
|
1236
1343
|
const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1237
1344
|
return {
|
|
@@ -1246,6 +1353,47 @@ export class LayoutPass {
|
|
|
1246
1353
|
});
|
|
1247
1354
|
}
|
|
1248
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* Rich text: each run's font (the text's font, changed by the run's), its
|
|
1358
|
+
* kerning, and an inline image's size; wrapped as one paragraph flow.
|
|
1359
|
+
*/
|
|
1360
|
+
private richTextPlan(runs: unknown[], ctx: Context, p: Proposal): TextPlan {
|
|
1361
|
+
const inputs: TextRunInput[] = runs.map((value) => {
|
|
1362
|
+
const run = obj(value) ?? {};
|
|
1363
|
+
let font = resolveFont(run['font'], ctx.font, this.env);
|
|
1364
|
+
if (run['italic'] === true) font = { ...font, css: `italic ${font.css}` };
|
|
1365
|
+
const kerning = num(run['kerning']) ?? 0;
|
|
1366
|
+
const image = obj(run['image']);
|
|
1367
|
+
if (image) return { image: this.inlineImageSize(image, font), font, kerning };
|
|
1368
|
+
return { text: str(run['text']) ?? '', font, kerning };
|
|
1369
|
+
});
|
|
1370
|
+
const wrapped = wrapRuns(inputs, ctx.font, p.width, ctx.lineLimit, this.measurer);
|
|
1371
|
+
const lines = wrapped.lines.map((line) => line.map((fragment) => fragment.text ?? '\uFFFC').join(''));
|
|
1372
|
+
return {
|
|
1373
|
+
size: { width: wrapped.width, height: wrapped.heights.reduce((total, height) => total + height, 0) },
|
|
1374
|
+
layout: {
|
|
1375
|
+
font: ctx.font,
|
|
1376
|
+
lines,
|
|
1377
|
+
alignment: ctx.textAlignment,
|
|
1378
|
+
truncated: wrapped.truncated,
|
|
1379
|
+
rich: { lines: wrapped.lines, heights: wrapped.heights, fonts: inputs.map((input) => input.font) },
|
|
1380
|
+
},
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
|
|
1385
|
+
private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
|
|
1386
|
+
if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
|
|
1387
|
+
return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
|
|
1388
|
+
}
|
|
1389
|
+
const name = str(props['name']);
|
|
1390
|
+
if (name === null) return square(font.lineHeight);
|
|
1391
|
+
const asset = this.env.assets?.images[name];
|
|
1392
|
+
if (!asset) return square(UNKNOWN_IMAGE_SIDE);
|
|
1393
|
+
if (asset.kind === 'symbol') return square(font.lineHeight);
|
|
1394
|
+
return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1249
1397
|
private measureSpacer(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1250
1398
|
const min = num(node.props['minLength']);
|
|
1251
1399
|
if (ctx.stackAxis === null) {
|
|
@@ -1326,6 +1474,10 @@ export class LayoutPass {
|
|
|
1326
1474
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1327
1475
|
const { screen, safeArea } = this.env;
|
|
1328
1476
|
if (first === 'medium') return screen.height / 2;
|
|
1477
|
+
// `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
|
|
1478
|
+
const large = screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
1479
|
+
if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
|
|
1480
|
+
if (first?.startsWith('fraction:')) return Math.min(large, screen.height * (Number(first.slice(9)) || 1));
|
|
1329
1481
|
// Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
|
|
1330
1482
|
if (first === 'full') return screen.height;
|
|
1331
1483
|
return screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
@@ -1582,12 +1734,27 @@ export class LayoutPass {
|
|
|
1582
1734
|
const anchors = alignmentAnchors(alignment);
|
|
1583
1735
|
const crossAnchor = axis === 'horizontal' ? anchors[1] : anchors[0];
|
|
1584
1736
|
const crossSize = crossOf(size, axis);
|
|
1737
|
+
// `.alignmentGuide`: the children's guides for the stack's alignment
|
|
1738
|
+
// line up. A child's guide is its explicit one, else the default (none,
|
|
1739
|
+
// half or all of its cross size); the line is the stack's own guide, or
|
|
1740
|
+
// further along when a guide reaches past it (the children move with it).
|
|
1741
|
+
const guideAxis = axis === 'horizontal' ? 'vertical' : 'horizontal';
|
|
1742
|
+
const guideName = guideNames(anchors)[axis === 'horizontal' ? 1 : 0];
|
|
1743
|
+
const guides = children.map((child, i) => {
|
|
1744
|
+
const explicit = explicitGuide(child, guideAxis, guideName);
|
|
1745
|
+
return explicit ? guideValue(explicit, plan.childSizes[i] as Size) : null;
|
|
1746
|
+
});
|
|
1747
|
+
const line = guides.some((g) => g !== null) && Number.isFinite(crossSize)
|
|
1748
|
+
? Math.max(crossSize * crossAnchor, ...guides.filter((g): g is number => g !== null))
|
|
1749
|
+
: null;
|
|
1585
1750
|
let cursor = 0;
|
|
1586
1751
|
for (let i = 0; i < children.length; i++) {
|
|
1587
1752
|
const child = children[i] as LayoutNode;
|
|
1588
1753
|
const childSize = plan.childSizes[i] as Size;
|
|
1589
1754
|
const proposal = plan.childProposals[i] as Proposal;
|
|
1590
|
-
const crossOffset =
|
|
1755
|
+
const crossOffset = line === null
|
|
1756
|
+
? safeOffset(crossSize, crossOf(childSize, axis), crossAnchor)
|
|
1757
|
+
: line - (guides[i] ?? crossOf(childSize, axis) * crossAnchor);
|
|
1591
1758
|
if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
|
|
1592
1759
|
else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
|
|
1593
1760
|
cursor += axisOf(childSize, axis) + spacing;
|
|
@@ -1606,6 +1773,13 @@ export class LayoutPass {
|
|
|
1606
1773
|
* fixed 44×44 so the placeholder is visible.
|
|
1607
1774
|
*/
|
|
1608
1775
|
private measureImage(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1776
|
+
if (typeof node.props['url'] === 'string' || typeof node.props['bitmap'] === 'object') {
|
|
1777
|
+
// Phase 13: a remote image carries its own point size, as does a bitmap.
|
|
1778
|
+
const width = num(node.props['width']) ?? 0;
|
|
1779
|
+
const height = num(node.props['height']) ?? 0;
|
|
1780
|
+
if (node.props['resizable'] !== true) return { width, height };
|
|
1781
|
+
return { width: p.width ?? width, height: p.height ?? height };
|
|
1782
|
+
}
|
|
1609
1783
|
const name = str(node.props['name']);
|
|
1610
1784
|
if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
|
|
1611
1785
|
const asset = this.env.assets?.images[name];
|
|
@@ -1815,6 +1989,8 @@ export class LayoutPass {
|
|
|
1815
1989
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
1816
1990
|
// the space between sections.
|
|
1817
1991
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
1992
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 44 by default.
|
|
1993
|
+
const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
|
|
1818
1994
|
const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
|
|
1819
1995
|
const plain = listStyle === 'plain';
|
|
1820
1996
|
const listTop = plain ? 0 : LIST_TOP;
|
|
@@ -1834,17 +2010,30 @@ export class LayoutPass {
|
|
|
1834
2010
|
// its content shifts right by as much.
|
|
1835
2011
|
const editing = node.props['editing'] === true;
|
|
1836
2012
|
|
|
1837
|
-
// Group consecutive plain rows into an implicit section.
|
|
1838
|
-
|
|
2013
|
+
// Group consecutive plain rows into an implicit section. A section's
|
|
2014
|
+
// view header and footer are not rows.
|
|
2015
|
+
type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
2016
|
+
const groupsSource: GroupSource[] = [];
|
|
1839
2017
|
for (const child of node.children) {
|
|
1840
2018
|
if (child.kind === 'section') {
|
|
1841
|
-
|
|
2019
|
+
const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
|
|
2020
|
+
const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
|
|
2021
|
+
const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
|
|
2022
|
+
groupsSource.push({ section: child, rows, headerView, footerView });
|
|
1842
2023
|
} else {
|
|
1843
2024
|
const last = groupsSource[groupsSource.length - 1];
|
|
1844
2025
|
if (last && last.section === null) last.rows.push(child);
|
|
1845
|
-
else groupsSource.push({ section: null, rows: [child] });
|
|
2026
|
+
else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
|
|
1846
2027
|
}
|
|
1847
2028
|
}
|
|
2029
|
+
// View headers and footers: the content width, the footnote font.
|
|
2030
|
+
const supplementCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
|
|
2031
|
+
const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
|
|
2032
|
+
if (!view) return null;
|
|
2033
|
+
const proposal: Proposal = { width: contentWidth, height: null };
|
|
2034
|
+
const size = this.measure(view, supplementCtx, proposal);
|
|
2035
|
+
return { node: view, ctx: supplementCtx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
|
|
2036
|
+
};
|
|
1848
2037
|
|
|
1849
2038
|
const groups: ListGroupPlan[] = [];
|
|
1850
2039
|
let y = listTop;
|
|
@@ -1856,11 +2045,13 @@ export class LayoutPass {
|
|
|
1856
2045
|
};
|
|
1857
2046
|
};
|
|
1858
2047
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
1859
|
-
const source = groupsSource[g] as
|
|
2048
|
+
const source = groupsSource[g] as GroupSource;
|
|
1860
2049
|
if (g > 0) y += sectionGap;
|
|
1861
2050
|
const top = y;
|
|
1862
2051
|
let header: ListGroupPlan['header'] = null;
|
|
1863
2052
|
let footer: ListGroupPlan['footer'] = null;
|
|
2053
|
+
const headerView = supplement(source.headerView, y);
|
|
2054
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP;
|
|
1864
2055
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
1865
2056
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
1866
2057
|
if (headerText !== null && headerText.length > 0) {
|
|
@@ -1871,6 +2062,12 @@ export class LayoutPass {
|
|
|
1871
2062
|
const cardTop = y;
|
|
1872
2063
|
const rows: ListRowPlan[] = [];
|
|
1873
2064
|
for (const rowNode of source.rows) {
|
|
2065
|
+
if (rowNode.kind === 'lazyspacer') {
|
|
2066
|
+
// Lazy rows not built yet: a row of no height, without a cell.
|
|
2067
|
+
const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
|
|
2068
|
+
rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
1874
2071
|
// A picker already reports its row height (44 for a menu, 44 per
|
|
1875
2072
|
// option inline); other rows get the 11px vertical padding. Phase 6
|
|
1876
2073
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
@@ -1891,7 +2088,7 @@ export class LayoutPass {
|
|
|
1891
2088
|
const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
|
|
1892
2089
|
const proposal: Proposal = { width: rowContentWidth, height: null };
|
|
1893
2090
|
const contentSize = this.measure(rowNode, rowCtx, proposal);
|
|
1894
|
-
const rowHeight = Math.max(
|
|
2091
|
+
const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
|
|
1895
2092
|
const row: Rect = { x: cardX, y, width: cardWidth, height: rowHeight };
|
|
1896
2093
|
rows.push({
|
|
1897
2094
|
node: rowNode,
|
|
@@ -1912,6 +2109,7 @@ export class LayoutPass {
|
|
|
1912
2109
|
}
|
|
1913
2110
|
: null,
|
|
1914
2111
|
edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
|
|
2112
|
+
separatorHidden: hints.separatorHidden,
|
|
1915
2113
|
});
|
|
1916
2114
|
y += rowHeight;
|
|
1917
2115
|
}
|
|
@@ -1922,11 +2120,16 @@ export class LayoutPass {
|
|
|
1922
2120
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
1923
2121
|
y += size.height;
|
|
1924
2122
|
}
|
|
2123
|
+
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
|
|
2124
|
+
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
2125
|
+
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
1925
2126
|
groups.push({
|
|
1926
2127
|
section: source.section,
|
|
1927
2128
|
frame: { x: cardX, y: top, width: cardWidth, height: y - top },
|
|
1928
2129
|
header,
|
|
1929
2130
|
footer,
|
|
2131
|
+
headerView,
|
|
2132
|
+
footerView,
|
|
1930
2133
|
card,
|
|
1931
2134
|
rows,
|
|
1932
2135
|
});
|
|
@@ -1944,15 +2147,21 @@ export class LayoutPass {
|
|
|
1944
2147
|
// Place
|
|
1945
2148
|
// -------------------------------------------------------------------------
|
|
1946
2149
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2150
|
+
/**
|
|
2151
|
+
* Phase 9: placed before, same proposal, same origin, nothing in the
|
|
2152
|
+
* subtree invalidated since: the last run's output still holds.
|
|
2153
|
+
*/
|
|
2154
|
+
private placedAlready(node: LayoutNode, p: Proposal, x: number, y: number): boolean {
|
|
1951
2155
|
const last = this.placed.get(node);
|
|
1952
|
-
|
|
2156
|
+
return last !== undefined && last.key === proposalKey(p) && last.x === x && last.y === y;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2160
|
+
if (this.placedAlready(node, p, x, y)) {
|
|
1953
2161
|
this.stats.skipped += 1;
|
|
1954
2162
|
return;
|
|
1955
2163
|
}
|
|
2164
|
+
const key = proposalKey(p);
|
|
1956
2165
|
this.placed.set(node, { key, x, y });
|
|
1957
2166
|
this.stats.placed += 1;
|
|
1958
2167
|
if (node.id >= 0) {
|
|
@@ -1979,11 +2188,17 @@ export class LayoutPass {
|
|
|
1979
2188
|
case 'image':
|
|
1980
2189
|
if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
|
|
1981
2190
|
return;
|
|
2191
|
+
case 'textfield':
|
|
2192
|
+
// A vertical field draws its text in the context font (one-line fields in the body font).
|
|
2193
|
+
if (node.props['axis'] === 'vertical' && node.id >= 0) this.fonts.set(node.id, ctx.font);
|
|
2194
|
+
return;
|
|
1982
2195
|
case 'spacer':
|
|
2196
|
+
case 'lazyspacer':
|
|
1983
2197
|
case 'divider':
|
|
1984
|
-
case 'textfield':
|
|
1985
2198
|
case 'color':
|
|
1986
2199
|
case 'shape':
|
|
2200
|
+
case 'canvas':
|
|
2201
|
+
case 'video':
|
|
1987
2202
|
return;
|
|
1988
2203
|
case 'vstack':
|
|
1989
2204
|
case 'hstack': {
|
|
@@ -1998,7 +2213,7 @@ export class LayoutPass {
|
|
|
1998
2213
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
1999
2214
|
for (const child of node.children) {
|
|
2000
2215
|
const childSize = this.measure(child, cctx, p);
|
|
2001
|
-
const { dx, dy } =
|
|
2216
|
+
const { dx, dy } = alignChild(size, childSize, alignment, child);
|
|
2002
2217
|
this.place(child, cctx, p, x + dx, y + dy);
|
|
2003
2218
|
}
|
|
2004
2219
|
return;
|
|
@@ -2038,7 +2253,7 @@ export class LayoutPass {
|
|
|
2038
2253
|
);
|
|
2039
2254
|
return;
|
|
2040
2255
|
}
|
|
2041
|
-
const { dx, dy } =
|
|
2256
|
+
const { dx, dy } = alignChild(box, plan.childSize, plan.alignment, child);
|
|
2042
2257
|
this.place(child, cctx, plan.childProposal, x + box.x + dx, y + box.y + dy);
|
|
2043
2258
|
}
|
|
2044
2259
|
return;
|
|
@@ -2208,7 +2423,7 @@ export class LayoutPass {
|
|
|
2208
2423
|
const proposal: Proposal = { width: size.width, height: size.height };
|
|
2209
2424
|
const decorationSize = this.measure(decoration, cctx, proposal);
|
|
2210
2425
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
2211
|
-
const { dx, dy } =
|
|
2426
|
+
const { dx, dy } = alignChild(size, decorationSize, alignment, decoration);
|
|
2212
2427
|
this.place(decoration, cctx, proposal, x + dx, y + dy);
|
|
2213
2428
|
}
|
|
2214
2429
|
return;
|
|
@@ -2289,6 +2504,9 @@ export class LayoutPass {
|
|
|
2289
2504
|
}
|
|
2290
2505
|
this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
|
|
2291
2506
|
this.setChrome(group.section, chrome);
|
|
2507
|
+
for (const view of [group.headerView, group.footerView]) {
|
|
2508
|
+
if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
|
|
2509
|
+
}
|
|
2292
2510
|
if (group.section.id >= 0) {
|
|
2293
2511
|
this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
|
|
2294
2512
|
if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
|
|
@@ -2297,6 +2515,10 @@ export class LayoutPass {
|
|
|
2297
2515
|
for (const row of group.rows) {
|
|
2298
2516
|
// Set the row chrome before placing so a navlink can merge its own keys.
|
|
2299
2517
|
const chrome: Record<string, Rect> = { row: offset(row.row) };
|
|
2518
|
+
// `.listRowSeparator(.hidden)`: flags the renderer turns into data
|
|
2519
|
+
// attributes on the row element (its separator line is CSS).
|
|
2520
|
+
if (row.separatorHidden?.top) chrome['separatorTopHidden'] = offset(row.row);
|
|
2521
|
+
if (row.separatorHidden?.bottom) chrome['separatorBottomHidden'] = offset(row.row);
|
|
2300
2522
|
if (row.badge) {
|
|
2301
2523
|
chrome['badge'] = offset(row.badge.rect);
|
|
2302
2524
|
if (row.node.id >= 0) this.chromeText.set(row.node.id, { badge: row.badge.layout });
|
|
@@ -2307,8 +2529,15 @@ export class LayoutPass {
|
|
|
2307
2529
|
if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
|
|
2308
2530
|
else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
|
|
2309
2531
|
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2532
|
+
const rowX = x + row.content.x;
|
|
2533
|
+
const rowY = y + row.content.y;
|
|
2534
|
+
// A row that is skipped keeps the chrome it set itself when it was
|
|
2535
|
+
// placed (a link's chevron and label); only the list's keys are new.
|
|
2536
|
+
const own = this.placedAlready(row.node, row.proposal, rowX, rowY) && row.node.id >= 0
|
|
2537
|
+
? this.chrome.get(row.node.id)
|
|
2538
|
+
: undefined;
|
|
2539
|
+
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
2540
|
+
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
2312
2541
|
}
|
|
2313
2542
|
}
|
|
2314
2543
|
}
|
|
@@ -2461,13 +2690,17 @@ export class LayoutPass {
|
|
|
2461
2690
|
}
|
|
2462
2691
|
|
|
2463
2692
|
// The inline title: centered, no wider than 56% of the bar, and clear of
|
|
2464
|
-
// whatever the back button and the bar items occupy on either side.
|
|
2693
|
+
// whatever the back button and the bar items occupy on either side. Its
|
|
2694
|
+
// width is rounded up to whole pixels: snapping floors both edges, which
|
|
2695
|
+
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
2696
|
+
// the title truncates with an ellipsis ("Animati…") when its box is any
|
|
2697
|
+
// narrower than its text.
|
|
2465
2698
|
if (!large && !principal) {
|
|
2466
2699
|
const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_ITEM_SPACING - x : 0);
|
|
2467
2700
|
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_ITEM_SPACING) : 0;
|
|
2468
2701
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2469
2702
|
const available = Math.max(0, size.width - 2 * side);
|
|
2470
|
-
const titleWidth = Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56, available);
|
|
2703
|
+
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
|
|
2471
2704
|
chrome['title'] = { x: x + (size.width - titleWidth) / 2, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
|
|
2472
2705
|
}
|
|
2473
2706
|
this.setChrome(node, chrome);
|
|
@@ -3389,7 +3622,9 @@ export class LayoutPass {
|
|
|
3389
3622
|
/**
|
|
3390
3623
|
* A linear gauge: width = proposal (100 when none), a 4px bar below an
|
|
3391
3624
|
* optional label row (label leading, current value label trailing, both in
|
|
3392
|
-
* the `caption` text style) with a 4px gap.
|
|
3625
|
+
* the `caption` text style) with a 4px gap. A circular gauge is a 58-point
|
|
3626
|
+
* square, whatever the proposal, with the current value label (else the
|
|
3627
|
+
* label) centered in the ring, proposed the square inside it.
|
|
3393
3628
|
*/
|
|
3394
3629
|
private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
|
|
3395
3630
|
return this.plan(node, p, () => {
|
|
@@ -3399,6 +3634,13 @@ export class LayoutPass {
|
|
|
3399
3634
|
let index = 0;
|
|
3400
3635
|
const label = node.props['hasLabel'] === true ? (node.children[index++] ?? null) : null;
|
|
3401
3636
|
const current = node.props['hasCurrentValueLabel'] === true ? (node.children[index++] ?? null) : null;
|
|
3637
|
+
const style = str(node.props['style']);
|
|
3638
|
+
if (style === 'accessoryCircular' || style === 'accessoryCircularCapacity') {
|
|
3639
|
+
const center = current ?? label;
|
|
3640
|
+
const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
|
|
3641
|
+
const centerSize = center ? this.measure(center, labelCtx, { width: inner, height: inner }) : ZERO;
|
|
3642
|
+
return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
|
|
3643
|
+
}
|
|
3402
3644
|
const proposal: Proposal = { width, height: null };
|
|
3403
3645
|
const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
|
|
3404
3646
|
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
@@ -3410,6 +3652,18 @@ export class LayoutPass {
|
|
|
3410
3652
|
|
|
3411
3653
|
private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
3412
3654
|
const plan = this.gaugePlan(node, ctx, p);
|
|
3655
|
+
if (plan.ring) {
|
|
3656
|
+
const { center, centerSize } = plan.ring;
|
|
3657
|
+
const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
|
|
3658
|
+
if (center) {
|
|
3659
|
+
const { dx, dy } = alignIn(size, centerSize, 'center');
|
|
3660
|
+
this.place(center, plan.labelCtx, { width: inner, height: inner }, x + dx, y + dy);
|
|
3661
|
+
}
|
|
3662
|
+
// The other label is not shown (SwiftUI shows one in the middle).
|
|
3663
|
+
for (const other of [plan.label, plan.current]) if (other && other !== center) this.hide(other, node);
|
|
3664
|
+
this.setChrome(node, { ring: { x, y, width: size.width, height: size.height } });
|
|
3665
|
+
return;
|
|
3666
|
+
}
|
|
3413
3667
|
const proposal: Proposal = { width: size.width, height: null };
|
|
3414
3668
|
if (plan.label) {
|
|
3415
3669
|
this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
|
|
@@ -3464,13 +3718,13 @@ export class LayoutPass {
|
|
|
3464
3718
|
const axisInput = (axis: unknown, fallback: string, views: ChartItemPlan[], key: 'x0' | 'y0'): ChartAxisInput | null => {
|
|
3465
3719
|
const a = obj(axis);
|
|
3466
3720
|
if (!a) return null;
|
|
3467
|
-
const labels:
|
|
3721
|
+
const labels: ChartAxisInput['labels'] = [];
|
|
3468
3722
|
const rawLabels = Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
|
|
3469
3723
|
for (const raw of rawLabels) {
|
|
3470
3724
|
const label = obj(raw);
|
|
3471
3725
|
if (!label) continue;
|
|
3472
3726
|
const text = str(label['text']) ?? '';
|
|
3473
|
-
labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text) });
|
|
3727
|
+
labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text), leading: label['anchor'] === 'leading' });
|
|
3474
3728
|
}
|
|
3475
3729
|
return {
|
|
3476
3730
|
position: (str(a['position']) ?? fallback) as ChartAxisInput['position'],
|
package/src/layout/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export type {
|
|
|
61
61
|
Size,
|
|
62
62
|
TextAlignment,
|
|
63
63
|
TextLayout,
|
|
64
|
+
TextFragment,
|
|
65
|
+
RichTextLayout,
|
|
64
66
|
TextMeasurer,
|
|
65
67
|
} from './types';
|
|
66
68
|
export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
|
|
@@ -88,4 +90,4 @@ export {
|
|
|
88
90
|
snapSliderValue,
|
|
89
91
|
timeInputValue,
|
|
90
92
|
} from './controls';
|
|
91
|
-
export { wrapText, ELLIPSIS, type WrappedText } from './text';
|
|
93
|
+
export { wrapText, wrapRuns, ELLIPSIS, type WrappedText, type WrappedRuns, type TextRunInput } from './text';
|