@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.
@@ -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
+ }