@swiftbrowser/web 0.1.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 +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwiftUI-style layout engine for the browser renderer.
|
|
3
|
+
*
|
|
4
|
+
* Pure and DOM-free: `layoutTree` takes the element tree, the device
|
|
5
|
+
* environment and a text measurer and returns a frame for every element
|
|
6
|
+
* (docs/ops-protocol.md, "Layout engine semantics").
|
|
7
|
+
*
|
|
8
|
+
* `LayoutResult.chrome` keys (all rects in screen coordinates):
|
|
9
|
+
*
|
|
10
|
+
* | kind | keys |
|
|
11
|
+
* |---------------------|------|
|
|
12
|
+
* | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (Phase 6: 49 + bottom safe area, when a `bottom` toolbar is present), `content` |
|
|
13
|
+
* | `sheet` | `grabber` (36×5), `content` (below the 20px grabber row) |
|
|
14
|
+
* | `toggle` | `label`, `switch` (51×31 at the trailing edge) |
|
|
15
|
+
* | `button` (bordered) | `label` |
|
|
16
|
+
* | `section` | `rows` (the inset card), `header`, `footer` (when present) |
|
|
17
|
+
* | list rows | `row` (the full-width row rect inside the card); `navlink` rows add `label` and `chevron` (20px at the trailing edge); rows with a Phase 6 `badge` style add `badge` (the text's rect at the trailing inset, full row height) |
|
|
18
|
+
* | `scrollview` | `content` (the content rect at scroll offset 0) |
|
|
19
|
+
* | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset) |
|
|
20
|
+
* | `tabview` | `bar` (49 + bottom safe area at the bottom), `item0` … `itemN-1` (equal-width 49px items in the bar), `content` (above the bar) |
|
|
21
|
+
* | `tab` | `content` (the selected tab's safe content area: below the status bar, above the bar); non-selected tabs are in `hidden` |
|
|
22
|
+
* | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing) |
|
|
23
|
+
* | `gauge` | `track` (the 4px bar), `fill` (the leading `value` share of the track) (Phase 6) |
|
|
24
|
+
*
|
|
25
|
+
* `LayoutResult.layouts` (Phase 6) holds, per `layout` element, the proposal
|
|
26
|
+
* it was placed with and its children's min / ideal / max sizes.
|
|
27
|
+
* | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing); inline (Phase 6): `label` (a 44 row, only for a non-empty label), `option0` … `optionN-1` (44 rows spanning the list row when there is one) |
|
|
28
|
+
* | `toolbar` | none; the element's frame is its item group in the bar (or the whole bottom bar) and the items have ordinary frames |
|
|
29
|
+
* | `navsplit` | `sidebar` (the sidebar column, including the leading safe-area inset), `separator` (the 1px hairline), `detail` (the detail column, including the trailing inset) (Phase 7) |
|
|
30
|
+
* | `listrow` | Phase 8: `editControl` (the 44px leading control, full row height) while the enclosing `list` has `editing`; the row content is shifted right by 44. `swipeactions` / `contextmenu` have no keys: their children after the first are in `hidden` without frames |
|
|
31
|
+
* | `alert` | Phase 8: `card` (270 wide, centered; a dialog's button card), `title` / `message` (when non-empty; `chromeText` carries the centered lines), `button<i>` (the i-th child's row), `ok` (the renderer-drawn OK, only without button children), `cancelCard` (dialog: the `cancel` button's own card) |
|
|
32
|
+
* | `stepper` | Phase 8: `label`, `control` (94 × 32 at the trailing edge), `decrement` / `increment` (its halves) |
|
|
33
|
+
* | `slider` | Phase 8: `track` (4 high, between the min / max labels), `fill` (from the track start to the thumb center), `thumb` (27 × 27) |
|
|
34
|
+
* | `datepicker` | Phase 8: `label`, `date` / `time` (the trailing capsules, 34 high, those the `components` show) |
|
|
35
|
+
* | `sheet` (`full`) | Phase 8: `content` only (no grabber) |
|
|
36
|
+
* | `chart` | Phase 7: `plot` (the marks' SVG), `legend` (the legend row / column, when present), `xband` / `yband` (the axis bands, when the axis is present), `xlabel<i>` / `ylabel<i>` (one per text label of the axis), `legend<i>` (one per legend item); `chromeText` carries `xlabel<i>` / `ylabel<i>` (caption font, one line) and `fonts` the caption font for the legend. `chartitem` children have ordinary frames |
|
|
37
|
+
*
|
|
38
|
+
* `LayoutResult.hidden` lists the ids of elements that must not be shown: the
|
|
39
|
+
* non-selected tabs (which keep a frame), Phase 6, the items of a `menu`
|
|
40
|
+
* (which have none; the renderer shows them in a popover) and, Phase 7, the
|
|
41
|
+
* candidates a `viewthatfits` did not choose (no frame either). Their
|
|
42
|
+
* subtrees have no frames.
|
|
43
|
+
*
|
|
44
|
+
* `LayoutResult.chromeText` carries the text the renderer draws as chrome:
|
|
45
|
+
* `navscreen` {`title`, `largeTitle`}, `section` {`header`, `footer`}
|
|
46
|
+
* (already upper-cased and wrapped), list rows {`badge`} (Phase 6).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export type {
|
|
50
|
+
DynamicTypeSize,
|
|
51
|
+
EdgeInsets,
|
|
52
|
+
FontFamily,
|
|
53
|
+
LayoutChildSizes,
|
|
54
|
+
LayoutEnvironment,
|
|
55
|
+
LayoutNode,
|
|
56
|
+
LayoutRecord,
|
|
57
|
+
LayoutResult,
|
|
58
|
+
Proposal,
|
|
59
|
+
Rect,
|
|
60
|
+
ResolvedFont,
|
|
61
|
+
Size,
|
|
62
|
+
TextAlignment,
|
|
63
|
+
TextLayout,
|
|
64
|
+
TextMeasurer,
|
|
65
|
+
} from './types';
|
|
66
|
+
export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
|
|
67
|
+
export {
|
|
68
|
+
defaultFont,
|
|
69
|
+
resolveFont,
|
|
70
|
+
lineHeightFor,
|
|
71
|
+
textStyleSize,
|
|
72
|
+
makeFont,
|
|
73
|
+
DYNAMIC_TYPE_SIZES,
|
|
74
|
+
TEXT_STYLE_SIZES,
|
|
75
|
+
LINE_HEIGHTS,
|
|
76
|
+
FONT_FAMILIES,
|
|
77
|
+
FONT_WEIGHTS,
|
|
78
|
+
type TextStyle,
|
|
79
|
+
} from './typography';
|
|
80
|
+
export { CanvasTextMeasurer, MonospaceTextMeasurer } from './measure';
|
|
81
|
+
export {
|
|
82
|
+
combineDateSeconds,
|
|
83
|
+
dateInputValue,
|
|
84
|
+
formatPickerDate,
|
|
85
|
+
formatPickerTime,
|
|
86
|
+
sameUTCDay,
|
|
87
|
+
sliderFraction,
|
|
88
|
+
snapSliderValue,
|
|
89
|
+
timeInputValue,
|
|
90
|
+
} from './controls';
|
|
91
|
+
export { wrapText, ELLIPSIS, type WrappedText } from './text';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text measurers. The engine only ever needs the width of a single line; the
|
|
3
|
+
* canvas-backed measurer caches by (font css, text) because the engine runs on
|
|
4
|
+
* every commit and texts rarely change.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ResolvedFont, TextMeasurer } from './types';
|
|
8
|
+
|
|
9
|
+
type Canvas2D = Pick<CanvasRenderingContext2D, 'font' | 'measureText'>;
|
|
10
|
+
|
|
11
|
+
/** Strip the `/line-height` part: some canvas implementations reject it. */
|
|
12
|
+
function canvasFont(font: ResolvedFont): string {
|
|
13
|
+
return font.css.replace(/\/\d+(\.\d+)?px/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Measures with a 2D canvas context (`OffscreenCanvas` when available, else a
|
|
18
|
+
* detached `<canvas>`). The context is created lazily on the first
|
|
19
|
+
* measurement, so importing this module never touches the DOM. When no canvas
|
|
20
|
+
* is available (Node), it falls back to the monospace estimate.
|
|
21
|
+
*/
|
|
22
|
+
export class CanvasTextMeasurer implements TextMeasurer {
|
|
23
|
+
private context: Canvas2D | null | undefined;
|
|
24
|
+
private readonly cache = new Map<string, number>();
|
|
25
|
+
private readonly fallback = new MonospaceTextMeasurer();
|
|
26
|
+
private currentFont = '';
|
|
27
|
+
private static readonly MAX_CACHE = 20_000;
|
|
28
|
+
|
|
29
|
+
measureWidth(text: string, font: ResolvedFont): number {
|
|
30
|
+
if (text.length === 0) return 0;
|
|
31
|
+
const key = `${font.css}\u0000${text}`;
|
|
32
|
+
const cached = this.cache.get(key);
|
|
33
|
+
if (cached !== undefined) return cached;
|
|
34
|
+
const context = this.acquireContext();
|
|
35
|
+
let width: number;
|
|
36
|
+
if (context) {
|
|
37
|
+
const css = canvasFont(font);
|
|
38
|
+
if (this.currentFont !== css) {
|
|
39
|
+
context.font = css;
|
|
40
|
+
this.currentFont = css;
|
|
41
|
+
}
|
|
42
|
+
width = context.measureText(text).width;
|
|
43
|
+
} else {
|
|
44
|
+
width = this.fallback.measureWidth(text, font);
|
|
45
|
+
}
|
|
46
|
+
if (this.cache.size >= CanvasTextMeasurer.MAX_CACHE) this.cache.clear();
|
|
47
|
+
this.cache.set(key, width);
|
|
48
|
+
return width;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Drop cached widths (call when web fonts finish loading). */
|
|
52
|
+
clearCache(): void {
|
|
53
|
+
this.cache.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private acquireContext(): Canvas2D | null {
|
|
57
|
+
if (this.context !== undefined) return this.context;
|
|
58
|
+
this.context = null;
|
|
59
|
+
try {
|
|
60
|
+
const g = globalThis as unknown as {
|
|
61
|
+
OffscreenCanvas?: new (w: number, h: number) => { getContext(id: '2d'): Canvas2D | null };
|
|
62
|
+
document?: { createElement(tag: 'canvas'): { getContext(id: '2d'): Canvas2D | null } };
|
|
63
|
+
};
|
|
64
|
+
if (typeof g.OffscreenCanvas === 'function') {
|
|
65
|
+
this.context = new g.OffscreenCanvas(1, 1).getContext('2d');
|
|
66
|
+
}
|
|
67
|
+
if (!this.context && g.document && typeof g.document.createElement === 'function') {
|
|
68
|
+
this.context = g.document.createElement('canvas').getContext('2d');
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
this.context = null;
|
|
72
|
+
}
|
|
73
|
+
return this.context;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic measurer for tests: 0.6em per code point, 0.3em for a space.
|
|
79
|
+
*/
|
|
80
|
+
export class MonospaceTextMeasurer implements TextMeasurer {
|
|
81
|
+
measureWidth(text: string, font: ResolvedFont): number {
|
|
82
|
+
let width = 0;
|
|
83
|
+
for (const ch of text) width += ch === ' ' ? 0.3 * font.size : 0.6 * font.size;
|
|
84
|
+
return width;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greedy word wrapping with character fallback and `lineLimit` truncation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ResolvedFont, TextMeasurer } from './types';
|
|
6
|
+
|
|
7
|
+
export const ELLIPSIS = '…';
|
|
8
|
+
/**
|
|
9
|
+
* Slack when comparing a line's width with the proposal. Frames are snapped
|
|
10
|
+
* to whole pixels and custom layouts round to 2 decimals, so a text proposed
|
|
11
|
+
* its own measured width can arrive a fraction narrower; half a pixel never
|
|
12
|
+
* changes a line break visibly but stops single words breaking apart.
|
|
13
|
+
*/
|
|
14
|
+
const EPSILON = 0.5;
|
|
15
|
+
|
|
16
|
+
export interface WrappedText {
|
|
17
|
+
lines: string[];
|
|
18
|
+
/** Width of the widest line. */
|
|
19
|
+
width: number;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wrap `text` to `maxWidth` (null or non-finite = a single line per
|
|
25
|
+
* paragraph). Words are split at spaces; a word wider than the line is broken
|
|
26
|
+
* by characters. With `lineLimit`, extra lines are dropped and the last kept
|
|
27
|
+
* line is truncated with an ellipsis so it fits.
|
|
28
|
+
*/
|
|
29
|
+
export function wrapText(
|
|
30
|
+
text: string,
|
|
31
|
+
font: ResolvedFont,
|
|
32
|
+
maxWidth: number | null,
|
|
33
|
+
lineLimit: number | null,
|
|
34
|
+
measurer: TextMeasurer,
|
|
35
|
+
): WrappedText {
|
|
36
|
+
const measure = (s: string): number => (s.length === 0 ? 0 : measurer.measureWidth(s, font));
|
|
37
|
+
const constrained = maxWidth !== null && Number.isFinite(maxWidth);
|
|
38
|
+
const paragraphs = text.split('\n');
|
|
39
|
+
let lines: string[] = [];
|
|
40
|
+
if (constrained) {
|
|
41
|
+
for (const paragraph of paragraphs) wrapParagraph(paragraph, maxWidth, measure, lines);
|
|
42
|
+
} else {
|
|
43
|
+
lines = paragraphs;
|
|
44
|
+
}
|
|
45
|
+
if (lines.length === 0) lines = [''];
|
|
46
|
+
|
|
47
|
+
let truncated = false;
|
|
48
|
+
if (lineLimit !== null && lineLimit >= 1 && lines.length > lineLimit) {
|
|
49
|
+
const kept = lines.slice(0, lineLimit - 1);
|
|
50
|
+
const rest = lines
|
|
51
|
+
.slice(lineLimit - 1)
|
|
52
|
+
.map((l) => l.trim())
|
|
53
|
+
.filter((l) => l.length > 0)
|
|
54
|
+
.join(' ');
|
|
55
|
+
kept.push(truncateLine(rest, constrained ? maxWidth : null, measure));
|
|
56
|
+
lines = kept;
|
|
57
|
+
truncated = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let width = 0;
|
|
61
|
+
for (const line of lines) width = Math.max(width, measure(line));
|
|
62
|
+
return { lines, width, truncated };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function wrapParagraph(
|
|
66
|
+
paragraph: string,
|
|
67
|
+
maxWidth: number,
|
|
68
|
+
measure: (s: string) => number,
|
|
69
|
+
out: string[],
|
|
70
|
+
): void {
|
|
71
|
+
const words = paragraph.split(' ').filter((w) => w.length > 0);
|
|
72
|
+
if (words.length === 0) {
|
|
73
|
+
out.push('');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let current = '';
|
|
77
|
+
for (const word of words) {
|
|
78
|
+
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
|
79
|
+
if (measure(candidate) <= maxWidth + EPSILON) {
|
|
80
|
+
current = candidate;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (current.length > 0) out.push(current);
|
|
84
|
+
if (measure(word) <= maxWidth + EPSILON) {
|
|
85
|
+
current = word;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// The word alone is wider than the line: break it by characters.
|
|
89
|
+
const chunks = breakWord(word, maxWidth, measure);
|
|
90
|
+
for (let i = 0; i < chunks.length - 1; i++) out.push(chunks[i] as string);
|
|
91
|
+
current = chunks[chunks.length - 1] ?? '';
|
|
92
|
+
}
|
|
93
|
+
out.push(current);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function breakWord(word: string, maxWidth: number, measure: (s: string) => number): string[] {
|
|
97
|
+
const chunks: string[] = [];
|
|
98
|
+
let chunk = '';
|
|
99
|
+
for (const ch of word) {
|
|
100
|
+
const candidate = chunk + ch;
|
|
101
|
+
if (chunk.length === 0 || measure(candidate) <= maxWidth + EPSILON) {
|
|
102
|
+
chunk = candidate; // at least one character per line
|
|
103
|
+
} else {
|
|
104
|
+
chunks.push(chunk);
|
|
105
|
+
chunk = ch;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
109
|
+
return chunks;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function truncateLine(
|
|
113
|
+
text: string,
|
|
114
|
+
maxWidth: number | null,
|
|
115
|
+
measure: (s: string) => number,
|
|
116
|
+
): string {
|
|
117
|
+
if (maxWidth === null) return text + ELLIPSIS;
|
|
118
|
+
const chars = Array.from(text);
|
|
119
|
+
while (chars.length > 0 && measure(chars.join('') + ELLIPSIS) > maxWidth + EPSILON) {
|
|
120
|
+
chars.pop();
|
|
121
|
+
while (chars.length > 0 && chars[chars.length - 1] === ' ') chars.pop();
|
|
122
|
+
}
|
|
123
|
+
return chars.join('') + ELLIPSIS;
|
|
124
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the SwiftUI-style layout engine (docs/ops-protocol.md, "Layout
|
|
3
|
+
* engine semantics"). Everything here is DOM-free and independent of the
|
|
4
|
+
* renderer so the engine can be unit-tested in Node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AssetCatalog } from '../assets';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A size proposal from a parent to a child. `null` means "whatever you want",
|
|
11
|
+
* `0` asks for the minimum and `Infinity` for the maximum.
|
|
12
|
+
*/
|
|
13
|
+
export type Proposal = { width: number | null; height: number | null };
|
|
14
|
+
|
|
15
|
+
export interface Size {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A frame in screen coordinates (CSS px). */
|
|
21
|
+
export interface Rect {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EdgeInsets {
|
|
29
|
+
top: number;
|
|
30
|
+
leading: number;
|
|
31
|
+
bottom: number;
|
|
32
|
+
trailing: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The element tree the renderer keeps, in the shape the engine reads. */
|
|
36
|
+
export interface LayoutNode {
|
|
37
|
+
id: number;
|
|
38
|
+
kind: string;
|
|
39
|
+
props: Record<string, unknown>;
|
|
40
|
+
children: LayoutNode[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DynamicTypeSize =
|
|
44
|
+
| 'xSmall'
|
|
45
|
+
| 'small'
|
|
46
|
+
| 'medium'
|
|
47
|
+
| 'large'
|
|
48
|
+
| 'xLarge'
|
|
49
|
+
| 'xxLarge'
|
|
50
|
+
| 'xxxLarge'
|
|
51
|
+
| 'accessibility1'
|
|
52
|
+
| 'accessibility2'
|
|
53
|
+
| 'accessibility3'
|
|
54
|
+
| 'accessibility4'
|
|
55
|
+
| 'accessibility5';
|
|
56
|
+
|
|
57
|
+
export type FontFamily = 'system' | 'rounded' | 'monospaced' | 'serif';
|
|
58
|
+
|
|
59
|
+
export interface ResolvedFont {
|
|
60
|
+
family: FontFamily;
|
|
61
|
+
/** Point size in CSS px (may be fractional after Dynamic Type scaling). */
|
|
62
|
+
size: number;
|
|
63
|
+
/** CSS numeric weight, 100...900. */
|
|
64
|
+
weight: number;
|
|
65
|
+
/** Line height in CSS px (an integer). */
|
|
66
|
+
lineHeight: number;
|
|
67
|
+
/** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
|
|
68
|
+
css: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TextMeasurer {
|
|
72
|
+
/** Width of one line of text in CSS px when drawn with `font`. */
|
|
73
|
+
measureWidth(text: string, font: ResolvedFont): number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LayoutEnvironment {
|
|
77
|
+
screen: Size;
|
|
78
|
+
safeArea: EdgeInsets;
|
|
79
|
+
dynamicTypeSize: DynamicTypeSize;
|
|
80
|
+
devicePixelRatio: number;
|
|
81
|
+
colorScheme: 'light' | 'dark';
|
|
82
|
+
/**
|
|
83
|
+
* Phase 5: the app's asset catalog manifest, for the intrinsic size of
|
|
84
|
+
* named images. Absent (or empty) when the app has no catalog; named
|
|
85
|
+
* images then measure as unknown (44×44 placeholders).
|
|
86
|
+
*/
|
|
87
|
+
assets?: AssetCatalog;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
|
91
|
+
|
|
92
|
+
export interface TextLayout {
|
|
93
|
+
font: ResolvedFont;
|
|
94
|
+
/** The exact lines the renderer must draw (already wrapped / truncated). */
|
|
95
|
+
lines: string[];
|
|
96
|
+
alignment: TextAlignment;
|
|
97
|
+
/** True when `lineLimit` cut text off (the last line ends with an ellipsis). */
|
|
98
|
+
truncated: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LayoutResult {
|
|
102
|
+
/** Screen-coordinate frame for every element id in the tree. */
|
|
103
|
+
frames: Map<number, Rect>;
|
|
104
|
+
/** For every `text` element: the resolved font and the wrapped lines. */
|
|
105
|
+
text: Map<number, TextLayout>;
|
|
106
|
+
/**
|
|
107
|
+
* Resolved font for every `image` element, plus `navscreen` (its title font)
|
|
108
|
+
* and `section` (its header/footer font).
|
|
109
|
+
*/
|
|
110
|
+
fonts: Map<number, ResolvedFont>;
|
|
111
|
+
/** For `scrollview` and `list`: the total content size (for scrolling). */
|
|
112
|
+
contentSizes: Map<number, Size>;
|
|
113
|
+
/**
|
|
114
|
+
* Sub-rects the renderer needs for compound elements, in screen coordinates.
|
|
115
|
+
* See the key list in `index.ts`.
|
|
116
|
+
*/
|
|
117
|
+
chrome: Map<number, Record<string, Rect>>;
|
|
118
|
+
/**
|
|
119
|
+
* Text the renderer draws as part of chrome (not a `text` element), keyed
|
|
120
|
+
* like `chrome`: `navscreen` {title, largeTitle}, `section` {header, footer}.
|
|
121
|
+
*/
|
|
122
|
+
chromeText: Map<number, Record<string, TextLayout>>;
|
|
123
|
+
/**
|
|
124
|
+
* Elements that keep their frame but are not shown: the non-selected `tab`s
|
|
125
|
+
* of a `tabview` (their content is not laid out at all).
|
|
126
|
+
*/
|
|
127
|
+
hidden: Set<number>;
|
|
128
|
+
/**
|
|
129
|
+
* Phase 6: for every `layout` element that was placed, the proposal it was
|
|
130
|
+
* placed with and its children's min / ideal / max sizes (raw numbers,
|
|
131
|
+
* `Infinity` for an unbounded axis). The renderer turns these into `layout`
|
|
132
|
+
* events.
|
|
133
|
+
*/
|
|
134
|
+
layouts: Map<number, LayoutRecord>;
|
|
135
|
+
/**
|
|
136
|
+
* Phase 9: the ids this run placed, hid or dropped (every other entry in
|
|
137
|
+
* the maps above is unchanged since the previous run). Absent from a
|
|
138
|
+
* one-off `layoutTree` result, which is all new.
|
|
139
|
+
*/
|
|
140
|
+
changed?: Set<number>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Host measurements of one subview of a `layout` element. */
|
|
144
|
+
export interface LayoutChildSizes {
|
|
145
|
+
min: Size;
|
|
146
|
+
ideal: Size;
|
|
147
|
+
max: Size;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface LayoutRecord {
|
|
151
|
+
proposal: Proposal;
|
|
152
|
+
children: LayoutChildSizes[];
|
|
153
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Type and font resolution.
|
|
3
|
+
*
|
|
4
|
+
* Point sizes come from Apple's Human Interface Guidelines, "Typography" >
|
|
5
|
+
* "Specifications" > "iOS, iPadOS Dynamic Type sizes" and "Larger
|
|
6
|
+
* accessibility type sizes" (https://developer.apple.com/design/human-interface-guidelines/typography).
|
|
7
|
+
* Line heights for the Large (default) sizes are the iOS values from the same
|
|
8
|
+
* tables; other sizes use round(size * 1.2).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
|
|
12
|
+
|
|
13
|
+
export type TextStyle =
|
|
14
|
+
| 'largeTitle'
|
|
15
|
+
| 'title'
|
|
16
|
+
| 'title2'
|
|
17
|
+
| 'title3'
|
|
18
|
+
| 'headline'
|
|
19
|
+
| 'body'
|
|
20
|
+
| 'callout'
|
|
21
|
+
| 'subheadline'
|
|
22
|
+
| 'footnote'
|
|
23
|
+
| 'caption'
|
|
24
|
+
| 'caption2';
|
|
25
|
+
|
|
26
|
+
/** Size categories in the column order of `TEXT_STYLE_SIZES`. */
|
|
27
|
+
export const DYNAMIC_TYPE_SIZES: readonly DynamicTypeSize[] = [
|
|
28
|
+
'xSmall',
|
|
29
|
+
'small',
|
|
30
|
+
'medium',
|
|
31
|
+
'large',
|
|
32
|
+
'xLarge',
|
|
33
|
+
'xxLarge',
|
|
34
|
+
'xxxLarge',
|
|
35
|
+
'accessibility1',
|
|
36
|
+
'accessibility2',
|
|
37
|
+
'accessibility3',
|
|
38
|
+
'accessibility4',
|
|
39
|
+
'accessibility5',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const LARGE_INDEX = 3;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Point size per text style and size category. Columns: xSmall, small,
|
|
46
|
+
* medium, large, xLarge, xxLarge, xxxLarge, AX1, AX2, AX3, AX4, AX5.
|
|
47
|
+
* (HIG "iOS, iPadOS Dynamic Type sizes" + "Larger accessibility type sizes".)
|
|
48
|
+
*/
|
|
49
|
+
export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
|
|
50
|
+
largeTitle: [31, 32, 33, 34, 36, 38, 40, 44, 48, 52, 56, 60],
|
|
51
|
+
title: [25, 26, 27, 28, 30, 32, 34, 38, 43, 48, 53, 58],
|
|
52
|
+
title2: [19, 20, 21, 22, 24, 26, 28, 34, 39, 44, 50, 56],
|
|
53
|
+
title3: [17, 18, 19, 20, 22, 24, 26, 31, 37, 43, 49, 55],
|
|
54
|
+
headline: [14, 15, 16, 17, 19, 21, 23, 28, 33, 40, 47, 53],
|
|
55
|
+
body: [14, 15, 16, 17, 19, 21, 23, 28, 33, 40, 47, 53],
|
|
56
|
+
callout: [13, 14, 15, 16, 18, 20, 22, 26, 32, 38, 44, 51],
|
|
57
|
+
subheadline: [12, 13, 14, 15, 17, 19, 21, 25, 30, 36, 42, 49],
|
|
58
|
+
footnote: [12, 12, 12, 13, 15, 17, 19, 23, 27, 33, 38, 44],
|
|
59
|
+
caption: [11, 11, 11, 12, 14, 16, 18, 22, 26, 32, 37, 43],
|
|
60
|
+
caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** iOS line heights for the standard (Large) point sizes. */
|
|
64
|
+
export const LINE_HEIGHTS: Readonly<Record<number, number>> = {
|
|
65
|
+
34: 41,
|
|
66
|
+
28: 34,
|
|
67
|
+
22: 28,
|
|
68
|
+
20: 25,
|
|
69
|
+
17: 22,
|
|
70
|
+
16: 21,
|
|
71
|
+
15: 20,
|
|
72
|
+
13: 18,
|
|
73
|
+
12: 16,
|
|
74
|
+
11: 13,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const FONT_WEIGHTS: Readonly<Record<string, number>> = {
|
|
78
|
+
ultraLight: 100,
|
|
79
|
+
thin: 200,
|
|
80
|
+
light: 300,
|
|
81
|
+
regular: 400,
|
|
82
|
+
medium: 500,
|
|
83
|
+
semibold: 600,
|
|
84
|
+
bold: 700,
|
|
85
|
+
heavy: 800,
|
|
86
|
+
black: 900,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** CSS family stacks; keep in sync with the renderer's stylesheet. */
|
|
90
|
+
export const FONT_FAMILIES: Readonly<Record<FontFamily, string>> = {
|
|
91
|
+
system:
|
|
92
|
+
'-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, system-ui, sans-serif',
|
|
93
|
+
rounded:
|
|
94
|
+
'ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, system-ui, sans-serif',
|
|
95
|
+
monospaced:
|
|
96
|
+
'ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", "Roboto Mono", Consolas, monospace',
|
|
97
|
+
serif: 'ui-serif, "New York", Georgia, "Times New Roman", serif',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const TEXT_STYLES = new Set<string>(Object.keys(TEXT_STYLE_SIZES));
|
|
101
|
+
const DESIGN_TO_FAMILY: Readonly<Record<string, FontFamily>> = {
|
|
102
|
+
default: 'system',
|
|
103
|
+
rounded: 'rounded',
|
|
104
|
+
monospaced: 'monospaced',
|
|
105
|
+
serif: 'serif',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function isTextStyle(value: unknown): value is TextStyle {
|
|
109
|
+
return typeof value === 'string' && TEXT_STYLES.has(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function sizeCategoryIndex(size: DynamicTypeSize): number {
|
|
113
|
+
const index = DYNAMIC_TYPE_SIZES.indexOf(size);
|
|
114
|
+
return index < 0 ? LARGE_INDEX : index;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Point size of a text style at a Dynamic Type size category. */
|
|
118
|
+
export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
|
|
119
|
+
const row = TEXT_STYLE_SIZES[style];
|
|
120
|
+
return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Line height for a point size: the iOS value for standard sizes, else round(size * 1.2). */
|
|
124
|
+
export function lineHeightFor(size: number): number {
|
|
125
|
+
const known = LINE_HEIGHTS[size];
|
|
126
|
+
return known ?? Math.round(size * 1.2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatPx(value: number): string {
|
|
130
|
+
// Avoid long float tails from Dynamic Type scaling in the CSS string.
|
|
131
|
+
return `${Math.round(value * 100) / 100}px`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function makeFont(family: FontFamily, size: number, weight: number): ResolvedFont {
|
|
135
|
+
const lineHeight = lineHeightFor(size);
|
|
136
|
+
const css = `${weight} ${formatPx(size)}/${lineHeight}px ${FONT_FAMILIES[family]}`;
|
|
137
|
+
return { family, size, weight, lineHeight, css };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Body text for the current `dynamicTypeSize` (17/22 at Large). */
|
|
141
|
+
export function defaultFont(env: LayoutEnvironment): ResolvedFont {
|
|
142
|
+
return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Apply a Font JSON object (`textStyle` | `size`, `weight`, `design`,
|
|
147
|
+
* `relativeTo`) to an inherited font. A `textStyle` or `size` defines a new
|
|
148
|
+
* font (weight defaults to regular, or semibold for `headline`; design to the
|
|
149
|
+
* system family); without either, only the given `weight`/`design` change.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveFont(
|
|
152
|
+
fontProps: unknown,
|
|
153
|
+
inherited: ResolvedFont,
|
|
154
|
+
env: LayoutEnvironment,
|
|
155
|
+
): ResolvedFont {
|
|
156
|
+
if (typeof fontProps !== 'object' || fontProps === null) return inherited;
|
|
157
|
+
const font = fontProps as Record<string, unknown>;
|
|
158
|
+
const weightName = typeof font['weight'] === 'string' ? font['weight'] : undefined;
|
|
159
|
+
const explicitWeight = weightName !== undefined ? FONT_WEIGHTS[weightName] : undefined;
|
|
160
|
+
const design = typeof font['design'] === 'string' ? font['design'] : undefined;
|
|
161
|
+
const explicitFamily = design !== undefined ? DESIGN_TO_FAMILY[design] : undefined;
|
|
162
|
+
|
|
163
|
+
const textStyle = font['textStyle'];
|
|
164
|
+
const fixedSize = font['size'];
|
|
165
|
+
let size: number;
|
|
166
|
+
let defaultWeight = 400;
|
|
167
|
+
let isNewFont = false;
|
|
168
|
+
if (isTextStyle(textStyle)) {
|
|
169
|
+
size = textStyleSize(textStyle, env.dynamicTypeSize);
|
|
170
|
+
if (textStyle === 'headline') defaultWeight = 600;
|
|
171
|
+
isNewFont = true;
|
|
172
|
+
} else if (typeof fixedSize === 'number' && Number.isFinite(fixedSize) && fixedSize > 0) {
|
|
173
|
+
size = fixedSize;
|
|
174
|
+
const relativeTo = font['relativeTo'];
|
|
175
|
+
if (isTextStyle(relativeTo)) {
|
|
176
|
+
const base = TEXT_STYLE_SIZES[relativeTo][LARGE_INDEX] ?? 17;
|
|
177
|
+
size = fixedSize * (textStyleSize(relativeTo, env.dynamicTypeSize) / base);
|
|
178
|
+
}
|
|
179
|
+
isNewFont = true;
|
|
180
|
+
} else {
|
|
181
|
+
size = inherited.size;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const weight = explicitWeight ?? (isNewFont ? defaultWeight : inherited.weight);
|
|
185
|
+
const family = explicitFamily ?? (isNewFont ? 'system' : inherited.family);
|
|
186
|
+
if (!isNewFont && weight === inherited.weight && family === inherited.family) return inherited;
|
|
187
|
+
return makeFont(family, size, weight);
|
|
188
|
+
}
|