@namiml/cli 3.4.12-dev.202609302217 → 3.4.12-dev.202610051802
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/dist/placements/page.js +34 -0
- package/dist/render/capabilities.js +63 -0
- package/dist/render/graphics.js +203 -0
- package/dist/render/image.js +362 -0
- package/dist/render/layout.js +544 -0
- package/dist/render/page-model.js +610 -0
- package/dist/render/paint.js +625 -0
- package/dist/render/style.js +413 -0
- package/dist/render/surface.js +377 -0
- package/dist/render/text.js +145 -0
- package/dist/screens/LaunchScreen.js +85 -14
- package/dist/screens/PageRender.js +273 -0
- package/dist/screens/Shell.js +24 -5
- package/dist/screens/useTerminalImages.js +129 -0
- package/dist/utils/terminal.js +17 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render model → absolute cell boxes, laid out by Yoga.
|
|
3
|
+
*
|
|
4
|
+
* This is the layout half of the cell compositor (NAM-4548 reopened).
|
|
5
|
+
* `src/render/page-model.ts` decides *what* is on the page, `src/render/style.ts`
|
|
6
|
+
* parses the authored values, and this module turns the two into one flat array
|
|
7
|
+
* of rectangles in the order a painter should draw them. Nothing here reads a
|
|
8
|
+
* template, resolves a condition or parses a colour — a second parser for an
|
|
9
|
+
* authored value is exactly how two views of the same page drift apart, so
|
|
10
|
+
* every `width`, `height`, `padding`, `spacing` and alignment goes through
|
|
11
|
+
* `style.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Four things are worth knowing before changing it:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Yoga reports parent-relative coordinates.** `getComputedLayout()` on a
|
|
16
|
+
* child is measured from its parent's box, so a button that reads `y = 2`
|
|
17
|
+
* inside a content section starting at row 3 really sits on row 5.
|
|
18
|
+
* `collect()` accumulates the offsets on the way down; every number in a
|
|
19
|
+
* `LaidOut` is absolute within the page.
|
|
20
|
+
* 2. **The background slot is absolutely positioned.** It is laid out at the
|
|
21
|
+
* top left at `100%` × `100%`, out of flow, so it fills the frame *behind*
|
|
22
|
+
* the other three sections instead of pushing them down — and it is emitted
|
|
23
|
+
* first, so a painter walking the array straight through paints it beneath
|
|
24
|
+
* everything else. That is the bug this ticket was reopened for.
|
|
25
|
+
* 3. **So is the footer slot.** Only two of the four sections are in flow.
|
|
26
|
+
* The footer is pinned to the bottom of the *frame* at `100%` wide, over
|
|
27
|
+
* the content rather than after it, and the content section is given a
|
|
28
|
+
* bottom inset equal to the footer's measured height — `sdk/web`'s absolute
|
|
29
|
+
* `Footer.ts` plus the `--nami-footer-height` its `Content.ts` folds into
|
|
30
|
+
* its `padding-bottom`, which is Apple's measured-footer content inset in
|
|
31
|
+
* turn. Measuring it needs a layout, so `layoutPage` runs two passes.
|
|
32
|
+
* 4. **The Yoga nodes are freed.** They are WASM-backed, so the tree and its
|
|
33
|
+
* config are released as soon as the numbers have been read. `layoutPage`
|
|
34
|
+
* is pure in both directions: it neither keeps native memory nor touches
|
|
35
|
+
* the model it was handed, which is what lets a screen re-lay-out a page on
|
|
36
|
+
* every keystroke.
|
|
37
|
+
*
|
|
38
|
+
* `height` is the page's own height, not the frame's: a page taller than `rows`
|
|
39
|
+
* reports its full height and the frame scrolls it rather than squashing it. A
|
|
40
|
+
* page shorter than `rows` is padded out to the frame, so a background always
|
|
41
|
+
* has the whole frame to fill.
|
|
42
|
+
*/
|
|
43
|
+
import { alignmentProps, cellScale, parseSize, toCells } from "./style.js";
|
|
44
|
+
import { textColumns } from "./surface.js";
|
|
45
|
+
// Static, the way Ink itself imports it (`node_modules/ink/build/dom.js`). A
|
|
46
|
+
// top-level `await import` here makes this module async, and an async module in
|
|
47
|
+
// a screen's import graph breaks Ink's reconciler under Jest with "import a
|
|
48
|
+
// file after the Jest environment has been torn down".
|
|
49
|
+
import Yoga, { Align, Direction, Edge, FlexDirection, Gutter, Justify, PositionType } from "yoga-layout";
|
|
50
|
+
/**
|
|
51
|
+
* The device a frame assumes when the caller does not name one: the phone the
|
|
52
|
+
* ticket measured against. `cols` and `rows` cannot supply it — they say how
|
|
53
|
+
* big the frame is in cells, not how many device pixels a cell is worth, and an
|
|
54
|
+
* authored `36` is a number of device pixels.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_VIEWPORT = { width: 393, height: 852 };
|
|
57
|
+
/** Rows an image gets when nothing authored says otherwise. */
|
|
58
|
+
const DEFAULT_IMAGE_ROWS = 4;
|
|
59
|
+
/** `› ` + `[ ` + label + ` ]` — the cells a button spends on chrome. */
|
|
60
|
+
const BUTTON_CHROME = 6;
|
|
61
|
+
/** A product card is drawn inside a one-cell border. */
|
|
62
|
+
const CARD_BORDER = 2;
|
|
63
|
+
/** Two edges and a row between them: the least a drawn box can hold a label in. */
|
|
64
|
+
const BUTTON_BOX_ROWS = 3;
|
|
65
|
+
/**
|
|
66
|
+
* Whether a button will be drawn as a box rather than as a line of text — which
|
|
67
|
+
* is what `paint.ts` decides from the same two properties.
|
|
68
|
+
*/
|
|
69
|
+
function drawsAsBox(style) {
|
|
70
|
+
const border = style.border;
|
|
71
|
+
const bordered = !!border && typeof border.width === "number" && border.width > 0;
|
|
72
|
+
const filled = !!style.fill && style.fill !== "transparent";
|
|
73
|
+
return bordered || filled;
|
|
74
|
+
}
|
|
75
|
+
/** `style.ts` has already turned the authored words into flex words. */
|
|
76
|
+
const JUSTIFY = {
|
|
77
|
+
"flex-start": Justify.FlexStart,
|
|
78
|
+
"flex-end": Justify.FlexEnd,
|
|
79
|
+
center: Justify.Center,
|
|
80
|
+
"space-between": Justify.SpaceBetween,
|
|
81
|
+
"space-around": Justify.SpaceAround,
|
|
82
|
+
"space-evenly": Justify.SpaceEvenly,
|
|
83
|
+
};
|
|
84
|
+
const ALIGN = {
|
|
85
|
+
"flex-start": Align.FlexStart,
|
|
86
|
+
"flex-end": Align.FlexEnd,
|
|
87
|
+
center: Align.Center,
|
|
88
|
+
stretch: Align.Stretch,
|
|
89
|
+
};
|
|
90
|
+
const EDGE_SIDES = [
|
|
91
|
+
["top", Edge.Top, "height"],
|
|
92
|
+
["right", Edge.Right, "width"],
|
|
93
|
+
["bottom", Edge.Bottom, "height"],
|
|
94
|
+
["left", Edge.Left, "width"],
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* Rows a device viewport occupies in a frame this many columns wide.
|
|
98
|
+
*
|
|
99
|
+
* The frame's height is not the terminal's: a phone is 852 device pixels tall
|
|
100
|
+
* and a cell stands for a fixed number of them, so the frame a page is laid out
|
|
101
|
+
* against comes from the device. A screen showing fewer rows than this scrolls
|
|
102
|
+
* a window over the result.
|
|
103
|
+
*/
|
|
104
|
+
export function frameRows(viewport, cols) {
|
|
105
|
+
const width = Math.max(1, Math.floor(cols));
|
|
106
|
+
return Math.max(1, toCells(viewport.height, "height", cellScale(viewport, width)));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Lay a page out into absolute boxes, in paint order.
|
|
110
|
+
*
|
|
111
|
+
* `cols` × `rows` is the frame in cells; `viewport` is the device those cells
|
|
112
|
+
* stand for, and matters only because authored sizes are in device pixels.
|
|
113
|
+
*
|
|
114
|
+
* **Two passes, because the footer is measured.** `sdk/web`'s `PaywallScreen`
|
|
115
|
+
* puts the footer `position: absolute; bottom: 0` over the content container
|
|
116
|
+
* and publishes its rendered height as `--nami-footer-height`, which
|
|
117
|
+
* `Content.ts` folds into its own `padding-bottom` calc — the same
|
|
118
|
+
* measured-footer content inset Apple applies. Nothing can know that height
|
|
119
|
+
* before the footer has been laid out, so the first pass measures it and the
|
|
120
|
+
* second gives the content section the inset. Yoga is cheap enough for that
|
|
121
|
+
* (500 layouts of the real PPO page take 142 ms), and a page with no footer
|
|
122
|
+
* never runs the second pass at all.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* Known limitation: **horizontal overflow is not clamped.** Authored widths are
|
|
126
|
+
* device pixels scaled to cells, and on the real Penguin page a section comes
|
|
127
|
+
* out 79 columns wide inside a 44-column frame, so its copy wraps at 79 and the
|
|
128
|
+
* painter clips what falls outside — a sentence can lose its tail off the right
|
|
129
|
+
* edge. Clamping every node with `setMaxWidthPercent(100)` does remove the
|
|
130
|
+
* overflow, but it also moved a centred child one cell left of its parent and
|
|
131
|
+
* broke the containment invariant this module's tests pin, so it was reverted
|
|
132
|
+
* rather than shipped. Fixing it properly means agreeing the width *before*
|
|
133
|
+
* measurement, so layout and paint wrap at the same number.
|
|
134
|
+
*/
|
|
135
|
+
export function layoutPage(page, cols, rows, viewport = DEFAULT_VIEWPORT) {
|
|
136
|
+
const width = Math.max(1, Math.floor(cols));
|
|
137
|
+
const frame = Math.max(1, Math.floor(rows));
|
|
138
|
+
const scale = cellScale(viewport, width);
|
|
139
|
+
const measured = layoutOnce(page, width, frame, scale, 0);
|
|
140
|
+
if (measured.footerHeight === 0)
|
|
141
|
+
return measured;
|
|
142
|
+
return layoutOnce(page, width, frame, scale, measured.footerHeight);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* One Yoga pass. `contentInset` is the bottom inset the content section carries
|
|
146
|
+
* for the footer drawn over it — zero on the measuring pass.
|
|
147
|
+
*/
|
|
148
|
+
function layoutOnce(page, width, frame, scale, contentInset) {
|
|
149
|
+
const config = Yoga.Config.create();
|
|
150
|
+
// Whole cells: a terminal has no sub-cell positions, and rounding once here
|
|
151
|
+
// is what stops two adjacent boxes overlapping or leaving a seam.
|
|
152
|
+
config.setPointScaleFactor(1);
|
|
153
|
+
const root = Yoga.Node.createWithConfig(config);
|
|
154
|
+
root.setFlexDirection(FlexDirection.Column);
|
|
155
|
+
root.setWidth(width);
|
|
156
|
+
root.setMinHeight(frame);
|
|
157
|
+
// Nothing may measure wider than the frame — see `fit`.
|
|
158
|
+
const cap = width;
|
|
159
|
+
const built = { node: page.root, ynode: root, children: [] };
|
|
160
|
+
for (const child of paintOrder(page.root)) {
|
|
161
|
+
const sub = build(child, "column", scale, config, cap);
|
|
162
|
+
if (isFooter(child)) {
|
|
163
|
+
// Out of flow, pinned to the bottom of the *frame* — not of the page,
|
|
164
|
+
// which is regularly taller and would drop the footer below the fold.
|
|
165
|
+
root.insertChild(frameLayer(config, frame, sub.ynode), root.getChildCount());
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
if (contentInset > 0 && isContent(child))
|
|
169
|
+
insetForFooter(sub.ynode, child, contentInset, scale);
|
|
170
|
+
root.insertChild(sub.ynode, root.getChildCount());
|
|
171
|
+
if (isBackground(child))
|
|
172
|
+
asBackdrop(sub.ynode);
|
|
173
|
+
}
|
|
174
|
+
built.children.push(sub);
|
|
175
|
+
}
|
|
176
|
+
root.calculateLayout(width, undefined, Direction.LTR);
|
|
177
|
+
const boxes = [];
|
|
178
|
+
collect(built, 0, 0, 0, boxes);
|
|
179
|
+
const height = pageHeight(boxes, Math.round(root.getComputedLayout().height));
|
|
180
|
+
const footer = boxes.find((laid) => laid.section === "footer");
|
|
181
|
+
root.freeRecursive();
|
|
182
|
+
config.free();
|
|
183
|
+
return { boxes, height, footerHeight: footer ? footer.h : 0 };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The page's real height, and the backdrop stretched to match it.
|
|
187
|
+
*
|
|
188
|
+
* A section can overflow its own box — a child sized `50%` of a parent that is
|
|
189
|
+
* itself auto-height does not add to that parent's content height, which is
|
|
190
|
+
* ordinary flexbox behaviour and happens on the committed Pimsleur fixture,
|
|
191
|
+
* where the sections measure 105 rows and the content reaches row 196. The page
|
|
192
|
+
* is as tall as the lowest row anything reaches, or the frame, whichever is
|
|
193
|
+
* more; anything less and the scroll window would clip content that exists.
|
|
194
|
+
*
|
|
195
|
+
* The root and the background slot are then stretched to that height so the
|
|
196
|
+
* backdrop really is behind the whole page rather than behind the first screen
|
|
197
|
+
* of it. Re-running the layout against the taller root is deliberately **not**
|
|
198
|
+
* done: percentage-sized children would grow with it and the page would chase
|
|
199
|
+
* its own tail.
|
|
200
|
+
*/
|
|
201
|
+
function pageHeight(boxes, rootHeight) {
|
|
202
|
+
const bottom = boxes.reduce((lowest, laid) => Math.max(lowest, laid.y + laid.h), 0);
|
|
203
|
+
const height = Math.max(rootHeight, bottom);
|
|
204
|
+
for (const laid of boxes) {
|
|
205
|
+
if (laid.depth === 0 || isBackground(laid.node))
|
|
206
|
+
laid.h = Math.max(laid.h, height);
|
|
207
|
+
}
|
|
208
|
+
return height;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The page root's children in the order they are painted: the background slot
|
|
212
|
+
* first, then the rest in the order the model emitted them — header, content,
|
|
213
|
+
* footer. A page with no background slot is left alone.
|
|
214
|
+
*/
|
|
215
|
+
function paintOrder(root) {
|
|
216
|
+
if (root.kind !== "box")
|
|
217
|
+
return [];
|
|
218
|
+
const background = root.children.filter(isBackground);
|
|
219
|
+
if (background.length === 0)
|
|
220
|
+
return root.children;
|
|
221
|
+
return [...background, ...root.children.filter((child) => !isBackground(child))];
|
|
222
|
+
}
|
|
223
|
+
function isBackground(node) {
|
|
224
|
+
return sectionOf(node) === "background";
|
|
225
|
+
}
|
|
226
|
+
function isFooter(node) {
|
|
227
|
+
return sectionOf(node) === "footer";
|
|
228
|
+
}
|
|
229
|
+
function isContent(node) {
|
|
230
|
+
return sectionOf(node) === "content";
|
|
231
|
+
}
|
|
232
|
+
function sectionOf(node) {
|
|
233
|
+
return node.kind === "box" ? node.section : undefined;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Take the background out of flow and give it the whole frame. Its own authored
|
|
237
|
+
* size is deliberately overridden: a background container authored `100%` tall
|
|
238
|
+
* would otherwise stack above the header and push the page off the bottom of
|
|
239
|
+
* the very frame it is meant to sit behind.
|
|
240
|
+
*/
|
|
241
|
+
function asBackdrop(ynode) {
|
|
242
|
+
ynode.setPositionType(PositionType.Absolute);
|
|
243
|
+
ynode.setPosition(Edge.Left, 0);
|
|
244
|
+
ynode.setPosition(Edge.Top, 0);
|
|
245
|
+
ynode.setWidthPercent(100);
|
|
246
|
+
ynode.setHeightPercent(100);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The footer, pinned to the bottom of the frame and full width, the way
|
|
250
|
+
* `sdk/web`'s `containers/Footer.ts` is `position: absolute; bottom: 0;
|
|
251
|
+
* width: 100%` over the content container.
|
|
252
|
+
*
|
|
253
|
+
* "The bottom" has to mean the bottom of the **frame**: the page is usually
|
|
254
|
+
* taller than the terminal and scrolls, and a footer pinned to the bottom of
|
|
255
|
+
* the page would only appear once the developer had scrolled all the way down —
|
|
256
|
+
* which is the opposite of what a viewport-anchored footer does. So the footer
|
|
257
|
+
* is hung inside a layer the size of the frame, itself out of flow at the top
|
|
258
|
+
* left, and the numbers come back absolute like every other box: the footer
|
|
259
|
+
* lands at `frame - its height` whatever the page does below it.
|
|
260
|
+
*
|
|
261
|
+
* A footer taller than the frame gets a negative top, exactly as an over-tall
|
|
262
|
+
* absolute element overflows its container's top edge on the web. Nothing in
|
|
263
|
+
* flow moves for it, so the content still starts at the top of the page.
|
|
264
|
+
*/
|
|
265
|
+
function frameLayer(config, frame, footer) {
|
|
266
|
+
const layer = Yoga.Node.createWithConfig(config);
|
|
267
|
+
layer.setPositionType(PositionType.Absolute);
|
|
268
|
+
layer.setPosition(Edge.Left, 0);
|
|
269
|
+
layer.setPosition(Edge.Top, 0);
|
|
270
|
+
layer.setWidthPercent(100);
|
|
271
|
+
layer.setHeight(frame);
|
|
272
|
+
footer.setPositionType(PositionType.Absolute);
|
|
273
|
+
footer.setPosition(Edge.Bottom, 0);
|
|
274
|
+
footer.setWidthPercent(100);
|
|
275
|
+
layer.insertChild(footer, 0);
|
|
276
|
+
return layer;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The content section's bottom inset: its own authored bottom padding **plus**
|
|
280
|
+
* the footer's measured height, which is `Content.ts`'s
|
|
281
|
+
* `padding-bottom: calc(<authored>px + var(--nami-footer-height, 0px))` in
|
|
282
|
+
* cells. Without it the last line of content sits under a footer drawn over it.
|
|
283
|
+
*/
|
|
284
|
+
function insetForFooter(ynode, node, footerHeight, scale) {
|
|
285
|
+
const authored = node.kind === "box" ? node.style.padding?.bottom : undefined;
|
|
286
|
+
const own = typeof authored === "number" ? toCells(authored, "height", scale) : 0;
|
|
287
|
+
ynode.setPadding(Edge.Bottom, own + footerHeight);
|
|
288
|
+
}
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Model → Yoga
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
function build(node, parent, scale, config, cap) {
|
|
293
|
+
const ynode = Yoga.Node.createWithConfig(config);
|
|
294
|
+
const children = [];
|
|
295
|
+
// Only a row's children share a width. Vertically the frame scrolls rather
|
|
296
|
+
// than squashing, so nothing shrinks on that axis; `minWidth: 0` is the
|
|
297
|
+
// flexbox rule that lets a flex item shrink below its own content, without
|
|
298
|
+
// which two cards in one row paint over each other.
|
|
299
|
+
if (parent === "row") {
|
|
300
|
+
ynode.setFlexShrink(1);
|
|
301
|
+
ynode.setMinWidth(0);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
ynode.setFlexShrink(0);
|
|
305
|
+
}
|
|
306
|
+
switch (node.kind) {
|
|
307
|
+
case "box": {
|
|
308
|
+
const flow = node.style.direction === "horizontal" ? "row" : "column";
|
|
309
|
+
ynode.setFlexDirection(flow === "row" ? FlexDirection.Row : FlexDirection.Column);
|
|
310
|
+
applyStyle(ynode, node.style, flow, parent, scale, node.children.length > 0);
|
|
311
|
+
// A row container the template did not size fills the width it was given,
|
|
312
|
+
// the way a block-level flex container does on the web. Without it two
|
|
313
|
+
// `100%` cards side by side spill out of a row sized to its content.
|
|
314
|
+
if (flow === "row" && node.style.width === undefined)
|
|
315
|
+
ynode.setWidthPercent(100);
|
|
316
|
+
for (const child of node.children) {
|
|
317
|
+
const sub = build(child, flow, scale, config, cap);
|
|
318
|
+
ynode.insertChild(sub.ynode, ynode.getChildCount());
|
|
319
|
+
children.push(sub);
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "spacer": {
|
|
324
|
+
// A spacer pushes along whichever axis its parent runs.
|
|
325
|
+
const cells = Math.max(1, toCells(node.size, parent === "row" ? "width" : "height", scale));
|
|
326
|
+
if (parent === "row")
|
|
327
|
+
ynode.setWidth(cells);
|
|
328
|
+
else
|
|
329
|
+
ynode.setHeight(cells);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "text": {
|
|
333
|
+
applyStyle(ynode, node.style, "column", parent, scale, false);
|
|
334
|
+
const body = plain(node.segments, node.style.capitalize);
|
|
335
|
+
ynode.setMeasureFunc((available) => measureLines([body], available, cap));
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "button": {
|
|
339
|
+
applyStyle(ynode, node.style, "column", parent, scale, false);
|
|
340
|
+
const label = plain(node.label);
|
|
341
|
+
// A drawn box needs three rows to hold anything: one for each edge and one
|
|
342
|
+
// for what is inside. An authored TV button is about 60 device pixels tall,
|
|
343
|
+
// which rounds to two cells, and at two the frame took both rows and the
|
|
344
|
+
// label ended up written along the top edge — `╭──MORE PLANS──╮` over an
|
|
345
|
+
// empty box. Rounding such a button up by one row is the same kind of
|
|
346
|
+
// approximation as the cell grid itself, and it is the difference between
|
|
347
|
+
// a button and a caption with a box under it.
|
|
348
|
+
if (drawsAsBox(node.style))
|
|
349
|
+
ynode.setMinHeight(BUTTON_BOX_ROWS);
|
|
350
|
+
// A control is one line. An authored icon button is a few cells wide and
|
|
351
|
+
// its label does not fit; wrapping it would push everything below it down
|
|
352
|
+
// and paint over whatever shares its row, so it clips instead.
|
|
353
|
+
ynode.setMeasureFunc((available) => ({
|
|
354
|
+
width: Math.min(fit(available, cap), textColumns(label) + BUTTON_CHROME),
|
|
355
|
+
height: 1,
|
|
356
|
+
}));
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case "product": {
|
|
360
|
+
applyStyle(ynode, node.style ?? {}, "column", parent, scale, false);
|
|
361
|
+
const lines = node.lines.map((line) => plain(line));
|
|
362
|
+
ynode.setMeasureFunc((available) => {
|
|
363
|
+
const inner = measureLines(lines, fit(available, cap) - CARD_BORDER, cap);
|
|
364
|
+
return { width: inner.width + CARD_BORDER, height: inner.height + CARD_BORDER };
|
|
365
|
+
});
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "image": {
|
|
369
|
+
applyStyle(ynode, node.style ?? {}, "column", parent, scale, false);
|
|
370
|
+
// With no bytes in hand there is no aspect to honour, so an unsized image
|
|
371
|
+
// takes a fixed few rows and whatever width it was given.
|
|
372
|
+
if (parseSize(node.style?.height, "height", scale).kind === "content")
|
|
373
|
+
ynode.setHeight(DEFAULT_IMAGE_ROWS);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { node, ynode, children };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* One node's authored box style onto its Yoga node. `flow` is the direction
|
|
381
|
+
* this node lays its own children out in (gap and alignment); `parent` is the
|
|
382
|
+
* direction it sits in (whether it may shrink to its content).
|
|
383
|
+
*/
|
|
384
|
+
function applyStyle(ynode, style, flow, parent, scale, hasChildren) {
|
|
385
|
+
applySize(ynode, parseSize(style.width, "width", scale), "width");
|
|
386
|
+
const height = parseSize(style.height, "height", scale);
|
|
387
|
+
// An authored height is a shape, not a cap: terminal copy wraps to more rows
|
|
388
|
+
// than a device's type does, and a hard height would let the overflow paint
|
|
389
|
+
// over the next section. A container takes its height as a floor and grows;
|
|
390
|
+
// the frame is the one thing that really clips.
|
|
391
|
+
applySize(ynode, height, hasChildren && height.kind !== "content" ? "minHeight" : "height");
|
|
392
|
+
// `fitContent` is an explicit request to shrink to the content. An absent
|
|
393
|
+
// size is not — it means "stretch", which is what flexbox does by default —
|
|
394
|
+
// and `parseSize` reports the two the same way, so the authored value decides.
|
|
395
|
+
if (shrinksToContent(style, parent, scale))
|
|
396
|
+
ynode.setAlignSelf(Align.FlexStart);
|
|
397
|
+
applyEdges(style.padding, scale, (edge, value) => ynode.setPadding(edge, value));
|
|
398
|
+
applyEdges(style.margin, scale, (edge, value) => ynode.setMargin(edge, value));
|
|
399
|
+
if (style.spacing !== undefined) {
|
|
400
|
+
const gap = toCells(style.spacing, flow === "row" ? "width" : "height", scale);
|
|
401
|
+
if (gap > 0)
|
|
402
|
+
ynode.setGap(flow === "row" ? Gutter.Column : Gutter.Row, gap);
|
|
403
|
+
}
|
|
404
|
+
// `sdk/web`'s `alignAndJustifyItems`: a bare `alignment` is the fallback for
|
|
405
|
+
// the horizontal axis in both directions, which is why it folds in here
|
|
406
|
+
// rather than being treated as a third axis.
|
|
407
|
+
const aligned = alignmentProps(style.horizontalAlignment ?? style.alignment, style.verticalAlignment, flow);
|
|
408
|
+
const justify = aligned.justifyContent === undefined ? undefined : JUSTIFY[aligned.justifyContent];
|
|
409
|
+
if (justify !== undefined)
|
|
410
|
+
ynode.setJustifyContent(justify);
|
|
411
|
+
const align = aligned.alignItems === undefined ? undefined : ALIGN[aligned.alignItems];
|
|
412
|
+
if (align !== undefined)
|
|
413
|
+
ynode.setAlignItems(align);
|
|
414
|
+
}
|
|
415
|
+
/** True when the node authored a cross-axis size that resolves to "as big as my content". */
|
|
416
|
+
function shrinksToContent(style, parent, scale) {
|
|
417
|
+
const axis = parent === "row" ? "height" : "width";
|
|
418
|
+
const authored = parent === "row" ? style.height : style.width;
|
|
419
|
+
if (authored === undefined)
|
|
420
|
+
return false;
|
|
421
|
+
return parseSize(authored, axis, scale).kind === "content";
|
|
422
|
+
}
|
|
423
|
+
function applySize(ynode, size, prop) {
|
|
424
|
+
switch (size.kind) {
|
|
425
|
+
case "percent":
|
|
426
|
+
if (prop === "width")
|
|
427
|
+
ynode.setWidthPercent(size.value);
|
|
428
|
+
else if (prop === "height")
|
|
429
|
+
ynode.setHeightPercent(size.value);
|
|
430
|
+
else
|
|
431
|
+
ynode.setMinHeightPercent(size.value);
|
|
432
|
+
break;
|
|
433
|
+
case "cells":
|
|
434
|
+
if (prop === "width")
|
|
435
|
+
ynode.setWidth(size.value);
|
|
436
|
+
else if (prop === "height")
|
|
437
|
+
ynode.setHeight(size.value);
|
|
438
|
+
else
|
|
439
|
+
ynode.setMinHeight(size.value);
|
|
440
|
+
break;
|
|
441
|
+
default:
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function applyEdges(edges, scale, set) {
|
|
446
|
+
if (!edges)
|
|
447
|
+
return;
|
|
448
|
+
for (const [side, edge, axis] of EDGE_SIDES) {
|
|
449
|
+
const authored = edges[side];
|
|
450
|
+
// A value still carrying `${state.safeAreaTop}` is not a number of pixels.
|
|
451
|
+
if (typeof authored !== "number")
|
|
452
|
+
continue;
|
|
453
|
+
const cells = toCells(authored, axis, scale);
|
|
454
|
+
if (cells > 0)
|
|
455
|
+
set(edge, cells);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// Reading the layout back
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
/**
|
|
462
|
+
* Depth first, a parent before its children, accumulating the parent offsets.
|
|
463
|
+
*
|
|
464
|
+
* The slot a box belongs to is inherited on the way down, so every box in the
|
|
465
|
+
* footer's subtree carries `section: "footer"` and a painter can lift the lot
|
|
466
|
+
* out of the scrolling page in one filter.
|
|
467
|
+
*/
|
|
468
|
+
function collect(built, originX, originY, depth, out, section) {
|
|
469
|
+
const slot = sectionOf(built.node) ?? section;
|
|
470
|
+
const layout = built.ynode.getComputedLayout();
|
|
471
|
+
const x = originX + Math.round(layout.left);
|
|
472
|
+
const y = originY + Math.round(layout.top);
|
|
473
|
+
const laid = { node: built.node, x, y, w: Math.round(layout.width), h: Math.round(layout.height), depth };
|
|
474
|
+
if (slot)
|
|
475
|
+
laid.section = slot;
|
|
476
|
+
out.push(laid);
|
|
477
|
+
for (const child of built.children)
|
|
478
|
+
collect(child, x, y, depth + 1, out, slot);
|
|
479
|
+
}
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Measuring
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
/**
|
|
484
|
+
* Wrap one string into terminal rows.
|
|
485
|
+
*
|
|
486
|
+
* Greedy word wrap on spaces, with a word longer than the line broken where it
|
|
487
|
+
* runs out — a 64-character asset name must not push everything beside it off
|
|
488
|
+
* the row. Authored newlines are kept, and an empty string is one empty row
|
|
489
|
+
* rather than none, because a blank line still occupies the page.
|
|
490
|
+
*/
|
|
491
|
+
export function wrapText(value, width) {
|
|
492
|
+
const limit = Number.isFinite(width) && width >= 1 ? Math.floor(width) : 1;
|
|
493
|
+
const rows = [];
|
|
494
|
+
for (const paragraph of value.split("\n")) {
|
|
495
|
+
let line = "";
|
|
496
|
+
for (const word of paragraph.split(" ")) {
|
|
497
|
+
if (line !== "" && textColumns(line) + 1 + textColumns(word) <= limit) {
|
|
498
|
+
line = `${line} ${word}`;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (line !== "")
|
|
502
|
+
rows.push(line);
|
|
503
|
+
let pending = word;
|
|
504
|
+
while (textColumns(pending) > limit) {
|
|
505
|
+
rows.push(pending.slice(0, limit));
|
|
506
|
+
pending = pending.slice(limit);
|
|
507
|
+
}
|
|
508
|
+
line = pending;
|
|
509
|
+
}
|
|
510
|
+
rows.push(line);
|
|
511
|
+
}
|
|
512
|
+
return rows;
|
|
513
|
+
}
|
|
514
|
+
/** The box a list of lines needs at a given available width. */
|
|
515
|
+
function measureLines(lines, available, cap) {
|
|
516
|
+
const limit = Math.max(1, fit(available, cap));
|
|
517
|
+
let width = 0;
|
|
518
|
+
let height = 0;
|
|
519
|
+
for (const line of lines) {
|
|
520
|
+
const wrapped = wrapText(line, limit);
|
|
521
|
+
height += wrapped.length;
|
|
522
|
+
for (const row of wrapped)
|
|
523
|
+
width = Math.max(width, textColumns(row));
|
|
524
|
+
}
|
|
525
|
+
return { width: Math.min(width, limit), height: Math.max(height, 1) };
|
|
526
|
+
}
|
|
527
|
+
/** Yoga passes `NaN` for "as much as you like"; a measure function has to cope. */
|
|
528
|
+
/**
|
|
529
|
+
* Yoga passes `NaN` for "as much as you like", which happens whenever a parent
|
|
530
|
+
* lets a child size to content — an authored `horizontalAlignment: center` does
|
|
531
|
+
* exactly that. Measuring unconstrained then reports max-content: on the real
|
|
532
|
+
* Penguin page one sentence measured 79 columns inside a 44-column frame, its
|
|
533
|
+
* `width: 100%` children inherited that, and the painter clipped the tail off
|
|
534
|
+
* the right edge. Capping an unconstrained measure at the frame keeps
|
|
535
|
+
* measurement and paint agreeing on where copy wraps.
|
|
536
|
+
*/
|
|
537
|
+
function fit(available, cap) {
|
|
538
|
+
const limit = Number.isFinite(available) && available > 0 ? Math.floor(available) : cap;
|
|
539
|
+
return Math.max(1, Math.min(limit, cap));
|
|
540
|
+
}
|
|
541
|
+
function plain(segments, capitalize) {
|
|
542
|
+
const value = segments.map((segment) => segment.text).join("");
|
|
543
|
+
return capitalize ? value.toUpperCase() : value;
|
|
544
|
+
}
|