@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610060208
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,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laid-out boxes → a painted character grid.
|
|
3
|
+
*
|
|
4
|
+
* This is the composing third 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, `src/render/layout.ts` turns the two into absolute
|
|
7
|
+
* rectangles **in paint order**, and `src/render/surface.ts` is the grid. This
|
|
8
|
+
* module does one thing: walk the rectangles in the order the layout returned
|
|
9
|
+
* them and paint each one.
|
|
10
|
+
*
|
|
11
|
+
* That order is the whole ticket. The layout emits the background slot first,
|
|
12
|
+
* then header, content and footer, and a parent before its children — so
|
|
13
|
+
* everything painted later lands on top of what came before. A cell keeps the
|
|
14
|
+
* background it was given unless something explicitly repaints it, which is how
|
|
15
|
+
* copy ends up **over** a background image rather than instead of it.
|
|
16
|
+
*
|
|
17
|
+
* The footer is the one slot that is **not** painted into the page surface.
|
|
18
|
+
* `sdk/web` gives it `position: absolute; bottom: 0; width: 100%` over the
|
|
19
|
+
* content container, so it stays put while the content scrolls under it; here
|
|
20
|
+
* the page surface scrolls, so the footer is painted on its own by
|
|
21
|
+
* `paintFooter` and the screen drops it against the bottom of the frame. It is
|
|
22
|
+
* still painted *over* the content, never under it.
|
|
23
|
+
*
|
|
24
|
+
* Six things worth knowing before changing it:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Wrapping comes from `layout.ts`.** `wrapText` is the function the layout
|
|
27
|
+
* measured the boxes with, so wrapping with anything else would paint rows
|
|
28
|
+
* the boxes were never sized for — and `wrapText` measures with the same
|
|
29
|
+
* `textColumns` used here, so the two cannot disagree about how wide a
|
|
30
|
+
* string is. That is the point of there being one of it: the surface
|
|
31
|
+
* decides how many columns a glyph really takes, and the layout asks it.
|
|
32
|
+
* 2. **Nothing passes a background to `drawText`** for ordinary copy. The
|
|
33
|
+
* surface keeps whatever is already in the cell, and a background handed in
|
|
34
|
+
* here would punch a hole through the page's backdrop.
|
|
35
|
+
* 3. **Horizontal overflow is real.** The Penguin page reaches column 81 in a
|
|
36
|
+
* 44-column frame, because that is what the authored widths say. The frame
|
|
37
|
+
* clips it — the surface does that silently — rather than this module
|
|
38
|
+
* squashing or re-wrapping it.
|
|
39
|
+
* 4. **A translucent fill is composited, not stamped.** A scrim authored
|
|
40
|
+
* `rgba(1 25 50 / 0.08)` is blended with the cell beneath it, so the image
|
|
41
|
+
* under it still shows through; a fill that parses to nothing (both NFL
|
|
42
|
+
* gradients take their stops from deferred `${block.*}` data) paints
|
|
43
|
+
* nothing at all rather than inventing a colour.
|
|
44
|
+
* 5. **The footer paints last, somewhere else.** `paintPage` skips every box
|
|
45
|
+
* the layout tagged `section: "footer"` and `paintFooter` paints exactly
|
|
46
|
+
* those, translated to their own surface. Nothing else knows the
|
|
47
|
+
* difference: both walk the same boxes with the same `paint`.
|
|
48
|
+
* 6. **An image has no dimensions at layout time**, so its box is whatever was
|
|
49
|
+
* authored, or a flat few rows. `halfBlockRows` never upscales, so a grid
|
|
50
|
+
* smaller than its box is placed at the box's top left and never stretched.
|
|
51
|
+
*
|
|
52
|
+
* The surface is as tall as the **page**, which is regularly taller than the
|
|
53
|
+
* frame: the screen scrolls a window over it rather than this module deciding
|
|
54
|
+
* what is visible.
|
|
55
|
+
*/
|
|
56
|
+
import { DEFAULT_VIEWPORT, frameRows, layoutPage, wrapText } from "./layout.js";
|
|
57
|
+
import { Surface, cellWidth, graphemes, textColumns } from "./surface.js";
|
|
58
|
+
import { alignmentFraction, alignmentProps, blend, cellScale, parseColor, parseFill, sampleGradient, toCells, toHex, } from "./style.js";
|
|
59
|
+
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
60
|
+
/**
|
|
61
|
+
* `› ` — the two cells `layout.ts` reserves in a button's width for the focus
|
|
62
|
+
* marker. The marker itself belongs to the screen, which knows what is focused;
|
|
63
|
+
* the slot is left blank here so the label never moves when it appears.
|
|
64
|
+
*/
|
|
65
|
+
const BUTTON_MARKER = 2;
|
|
66
|
+
/** A product card is drawn inside a one-cell border, which its geometry reserves. */
|
|
67
|
+
const CARD_BORDER = 1;
|
|
68
|
+
/** `fontSize` at or above which copy reads as a heading. The real pages run 32 / 20 / 15 / 12. */
|
|
69
|
+
const HEADING_FONT_SIZE = 24;
|
|
70
|
+
/** `fontSize` at or below which copy is fine print. */
|
|
71
|
+
const FINE_PRINT_FONT_SIZE = 13;
|
|
72
|
+
/** Text types that are headings or fine print whatever size they were given. */
|
|
73
|
+
const HEADING_TYPES = new Set(["title", "heading", "headline"]);
|
|
74
|
+
const FINE_PRINT_TYPES = new Set(["legal", "caption", "footnote"]);
|
|
75
|
+
/** Box-drawing glyphs: square corners, and the round set an authored radius picks. */
|
|
76
|
+
const SQUARE = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" };
|
|
77
|
+
const ROUND = { ...SQUARE, tl: "╭", tr: "╮", bl: "╰", br: "╯" };
|
|
78
|
+
/** Enough of an image's name to recognise; the real urls are 64-character hashes. */
|
|
79
|
+
const NAME_LIMIT = 28;
|
|
80
|
+
const EDGE_AXIS = { top: "height", right: "width", bottom: "height", left: "width" };
|
|
81
|
+
/**
|
|
82
|
+
* Paint a page into a fresh surface — **everything but the footer**.
|
|
83
|
+
*
|
|
84
|
+
* `cols` × `rows` is the frame in cells and `viewport` is the device those
|
|
85
|
+
* cells stand for — authored padding and heights are device pixels, so without
|
|
86
|
+
* it nothing scales. The surface comes back `cols` wide and as tall as the page
|
|
87
|
+
* the layout measured, which is never less than `rows`.
|
|
88
|
+
*
|
|
89
|
+
* The footer is left out because it does not scroll with the page: it is drawn
|
|
90
|
+
* over the bottom of the frame by `paintFooter`, the way `sdk/web` pins its
|
|
91
|
+
* `position: absolute; bottom: 0` footer over a scrolling content container.
|
|
92
|
+
* The content the layout inset for it is already out of its way.
|
|
93
|
+
*/
|
|
94
|
+
export function paintPage(page, cols, rows, viewport = DEFAULT_VIEWPORT, deps = {}) {
|
|
95
|
+
const width = Math.max(1, Math.floor(cols));
|
|
96
|
+
const { boxes, height } = layoutPage(page, width, rows, viewport);
|
|
97
|
+
const surface = new Surface(width, height);
|
|
98
|
+
const scale = cellScale(viewport, width);
|
|
99
|
+
// In the order the layout returned them: background first, a parent before
|
|
100
|
+
// its children, so everything painted later sits on top.
|
|
101
|
+
for (const laid of boxes)
|
|
102
|
+
if (laid.section !== "footer")
|
|
103
|
+
paint(surface, laid, scale, deps);
|
|
104
|
+
return surface;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Paint the footer alone, into a surface exactly its own height.
|
|
108
|
+
*
|
|
109
|
+
* `null` when the page has no footer slot, which is a real case — the committed
|
|
110
|
+
* Penguin fixture authors an empty `footer: []` — and is what tells a screen
|
|
111
|
+
* there is nothing to pin. The rows come back with their own origin at row 0,
|
|
112
|
+
* so a screen can drop them against the bottom of whatever it is showing
|
|
113
|
+
* without knowing where in the page the layout put them.
|
|
114
|
+
*
|
|
115
|
+
* There is no `rows` argument because the footer is measured against the device
|
|
116
|
+
* frame (`frameRows`), not against the pane: the same frame `paintPage` is
|
|
117
|
+
* given by the screen, so the two agree on the footer's height.
|
|
118
|
+
*/
|
|
119
|
+
export function paintFooter(page, cols, viewport = DEFAULT_VIEWPORT, deps = {}) {
|
|
120
|
+
const width = Math.max(1, Math.floor(cols));
|
|
121
|
+
const { boxes, footerHeight } = layoutPage(page, width, frameRows(viewport, width), viewport);
|
|
122
|
+
const footer = boxes.filter((laid) => laid.section === "footer");
|
|
123
|
+
if (footer.length === 0 || footerHeight <= 0)
|
|
124
|
+
return null;
|
|
125
|
+
const surface = new Surface(width, footerHeight);
|
|
126
|
+
const scale = cellScale(viewport, width);
|
|
127
|
+
// The footer's own top row becomes row 0 of its surface. A footer taller than
|
|
128
|
+
// the frame starts above it — a negative `y`, exactly as it overflows the top
|
|
129
|
+
// of its container on the web — and this is the shift that brings it back.
|
|
130
|
+
const top = footer[0].y;
|
|
131
|
+
for (const laid of footer)
|
|
132
|
+
paint(surface, { ...laid, y: laid.y - top }, scale, deps);
|
|
133
|
+
return surface;
|
|
134
|
+
}
|
|
135
|
+
function paint(surface, laid, scale, deps) {
|
|
136
|
+
const { node } = laid;
|
|
137
|
+
switch (node.kind) {
|
|
138
|
+
case "spacer":
|
|
139
|
+
return;
|
|
140
|
+
case "box":
|
|
141
|
+
paintFill(surface, laid, node.style.fill);
|
|
142
|
+
paintBorder(surface, laid, node.style.border);
|
|
143
|
+
return;
|
|
144
|
+
case "text":
|
|
145
|
+
paintFill(surface, laid, node.style.fill);
|
|
146
|
+
paintBorder(surface, laid, node.style.border);
|
|
147
|
+
paintText(surface, laid, node, scale);
|
|
148
|
+
return;
|
|
149
|
+
case "button":
|
|
150
|
+
paintFill(surface, laid, node.style.fill);
|
|
151
|
+
paintBorder(surface, laid, node.style.border);
|
|
152
|
+
paintButton(surface, laid, node, scale, deps.focused);
|
|
153
|
+
return;
|
|
154
|
+
case "product":
|
|
155
|
+
paintFill(surface, laid, node.style?.fill);
|
|
156
|
+
paintCard(surface, laid, node, scale, deps.focused);
|
|
157
|
+
return;
|
|
158
|
+
case "image":
|
|
159
|
+
paintFill(surface, laid, node.style?.fill);
|
|
160
|
+
paintImage(surface, laid, node, scale, deps);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Fills and borders
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
/**
|
|
168
|
+
* A box's background.
|
|
169
|
+
*
|
|
170
|
+
* An opaque fill — solid or gradient — is one `fillRectRows` sweep, with
|
|
171
|
+
* `sampleGradient` giving each row its own colour. A fill carrying alpha is
|
|
172
|
+
* composited cell by cell against what is already painted, which is what keeps
|
|
173
|
+
* an authored scrim a scrim instead of a lid; a fully transparent one paints
|
|
174
|
+
* nothing, and so does a fill that parses to nothing.
|
|
175
|
+
*/
|
|
176
|
+
function paintFill(surface, laid, value) {
|
|
177
|
+
const fill = parseFill(value);
|
|
178
|
+
if (!fill)
|
|
179
|
+
return;
|
|
180
|
+
const { x, y, w, h } = laid;
|
|
181
|
+
if (w <= 0 || h <= 0)
|
|
182
|
+
return;
|
|
183
|
+
if (isOpaque(fill)) {
|
|
184
|
+
surface.fillRectRows(x, y, w, h, (t) => sampleGradient(fill, t));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (let row = 0; row < h; row += 1) {
|
|
188
|
+
const color = sampleGradient(fill, h === 1 ? 0 : row / (h - 1));
|
|
189
|
+
if (!color || color.a <= 0)
|
|
190
|
+
continue;
|
|
191
|
+
if (color.a >= 1) {
|
|
192
|
+
surface.fillRect(x, y + row, w, 1, color);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (let col = 0; col < w; col += 1) {
|
|
196
|
+
const under = surface.cellAt(x + col, y + row);
|
|
197
|
+
if (!under)
|
|
198
|
+
continue;
|
|
199
|
+
// With nothing beneath, the terminal's own background is what shows, and
|
|
200
|
+
// we do not know it — so the authored colour is drawn as authored.
|
|
201
|
+
surface.fillRect(x + col, y + row, 1, 1, under.bg ? blend(color, under.bg) : color);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** True when nothing about this fill needs compositing with what is beneath it. */
|
|
206
|
+
function isOpaque(fill) {
|
|
207
|
+
if (!fill)
|
|
208
|
+
return false;
|
|
209
|
+
if (fill.kind === "solid")
|
|
210
|
+
return fill.color.a >= 1;
|
|
211
|
+
return fill.stops.length > 0 && fill.stops.every((stop) => stop.color.a >= 1);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* An authored border, drawn **inside** the box's own area.
|
|
215
|
+
*
|
|
216
|
+
* Only a product card's geometry reserves its border, so a border drawn here
|
|
217
|
+
* would otherwise push into its neighbour. It is painted with the box rather
|
|
218
|
+
* than after its children, so a child that reaches the edge draws over it — a
|
|
219
|
+
* border must never hide content.
|
|
220
|
+
*/
|
|
221
|
+
function paintBorder(surface, laid, border) {
|
|
222
|
+
if (!border || !(typeof border.width === "number") || border.width <= 0)
|
|
223
|
+
return;
|
|
224
|
+
const color = parseColor(border.color);
|
|
225
|
+
drawFrame(surface, laid, (border.radius ?? 0) > 0, color ? { fg: color } : {});
|
|
226
|
+
}
|
|
227
|
+
function drawFrame(surface, rect, round, style) {
|
|
228
|
+
const { x, y, w, h } = rect;
|
|
229
|
+
if (w < 2 || h < 2)
|
|
230
|
+
return;
|
|
231
|
+
const glyphs = round ? ROUND : SQUARE;
|
|
232
|
+
const span = glyphs.h.repeat(w - 2);
|
|
233
|
+
surface.drawText(x, y, `${glyphs.tl}${span}${glyphs.tr}`, style, w);
|
|
234
|
+
surface.drawText(x, y + h - 1, `${glyphs.bl}${span}${glyphs.br}`, style, w);
|
|
235
|
+
for (let row = 1; row < h - 1; row += 1) {
|
|
236
|
+
surface.drawText(x, y + row, glyphs.v, style, 1);
|
|
237
|
+
surface.drawText(x + w - 1, y + row, glyphs.v, style, 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Copy
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
function paintText(surface, laid, node, scale) {
|
|
244
|
+
const area = inner(laid, insets(node.style, scale));
|
|
245
|
+
if (area.w <= 0 || area.h <= 0)
|
|
246
|
+
return;
|
|
247
|
+
const segments = node.style.capitalize
|
|
248
|
+
? node.segments.map((segment) => ({ ...segment, text: segment.text.toUpperCase() }))
|
|
249
|
+
: node.segments;
|
|
250
|
+
const styles = styleRuns(segments, node.style);
|
|
251
|
+
const full = segments.map((segment) => segment.text).join("");
|
|
252
|
+
// `alignItems` is the horizontal axis for a column, which is how `style.ts`
|
|
253
|
+
// folds a bare `alignment` in — the same call the layout makes.
|
|
254
|
+
const align = alignmentProps(node.style.horizontalAlignment ?? node.style.alignment, node.style.verticalAlignment, "column").alignItems;
|
|
255
|
+
// Wrap at what is actually visible. The layout caps a measure at the frame
|
|
256
|
+
// width, but a box that starts at x still runs past the right edge by its own
|
|
257
|
+
// offset, and the surface would clip the tail of every line. Wrapping to the
|
|
258
|
+
// visible width loses no words; the reserved-height guard below still stops a
|
|
259
|
+
// box painting into the next one.
|
|
260
|
+
const visible = Math.max(1, Math.min(area.w, surface.cols - area.x));
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
wrapText(full, visible).forEach((line, row) => {
|
|
263
|
+
// The layout measured the same string with the same wrap, so a line past
|
|
264
|
+
// the rows it reserved would be painting over the next box.
|
|
265
|
+
if (row >= area.h)
|
|
266
|
+
return;
|
|
267
|
+
const found = full.indexOf(line, cursor);
|
|
268
|
+
const start = found < 0 ? cursor : found;
|
|
269
|
+
cursor = start + line.length;
|
|
270
|
+
drawRuns(surface, area.x + indent(align, area.w, line), area.y + row, line, start, styles);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/** One style object per character of the joined text, so a run is an identity comparison. */
|
|
274
|
+
function styleRuns(segments, style) {
|
|
275
|
+
const linkColor = parseColor(style.linkColor);
|
|
276
|
+
const fontColor = parseColor(style.fontColor);
|
|
277
|
+
const heading = isHeading(style);
|
|
278
|
+
const finePrint = isFinePrint(style);
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const segment of segments) {
|
|
281
|
+
const cell = {};
|
|
282
|
+
const color = segment.link !== undefined && linkColor ? linkColor : fontColor;
|
|
283
|
+
// A colour with no alpha at all is nothing to draw, not black.
|
|
284
|
+
if (color && color.a > 0)
|
|
285
|
+
cell.fg = color;
|
|
286
|
+
if (heading || segment.bold)
|
|
287
|
+
cell.bold = true;
|
|
288
|
+
if (segment.italic)
|
|
289
|
+
cell.italic = true;
|
|
290
|
+
if (segment.link !== undefined)
|
|
291
|
+
cell.underline = true;
|
|
292
|
+
if (finePrint)
|
|
293
|
+
cell.dim = true;
|
|
294
|
+
for (let i = 0; i < segment.text.length; i += 1)
|
|
295
|
+
out.push(cell);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Draw one wrapped line as runs of like-styled characters.
|
|
301
|
+
*
|
|
302
|
+
* No background is passed: the cells keep whatever is already beneath them,
|
|
303
|
+
* which is what puts copy over a fill or an image instead of through it.
|
|
304
|
+
*/
|
|
305
|
+
function drawRuns(surface, x, y, line, start, styles,
|
|
306
|
+
/** Stop here, exclusive. A run past it is dropped rather than drawn over whatever is there. */
|
|
307
|
+
until = Infinity) {
|
|
308
|
+
let col = x;
|
|
309
|
+
let at = 0;
|
|
310
|
+
while (at < line.length && col < until) {
|
|
311
|
+
const style = styles[start + at] ?? {};
|
|
312
|
+
let end = at + 1;
|
|
313
|
+
while (end < line.length && styles[start + end] === style)
|
|
314
|
+
end += 1;
|
|
315
|
+
const run = line.slice(at, end);
|
|
316
|
+
surface.drawText(col, y, run, style, Math.max(0, until - col));
|
|
317
|
+
col += textColumns(run);
|
|
318
|
+
at = end;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Where a line starts inside a box the template asked to be centred or right-aligned. */
|
|
322
|
+
function indent(align, width, line) {
|
|
323
|
+
const slack = width - textColumns(line);
|
|
324
|
+
if (slack <= 0)
|
|
325
|
+
return 0;
|
|
326
|
+
if (align === "center")
|
|
327
|
+
return Math.floor(slack / 2);
|
|
328
|
+
if (align === "flex-end")
|
|
329
|
+
return slack;
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
function isHeading(style) {
|
|
333
|
+
if (style.textType && HEADING_TYPES.has(style.textType))
|
|
334
|
+
return true;
|
|
335
|
+
return style.fontSize !== undefined && style.fontSize >= HEADING_FONT_SIZE;
|
|
336
|
+
}
|
|
337
|
+
function isFinePrint(style) {
|
|
338
|
+
if (style.textType && FINE_PRINT_TYPES.has(style.textType))
|
|
339
|
+
return true;
|
|
340
|
+
return style.fontSize !== undefined && style.fontSize <= FINE_PRINT_FONT_SIZE;
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Controls and cards
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
/**
|
|
346
|
+
* One pressable control on one line: `[ Close ]`, never the word `button` with
|
|
347
|
+
* its label on the line below. The two cells the layout reserved for the focus
|
|
348
|
+
* marker stay blank — the screen owns focus, and the label must not shift when
|
|
349
|
+
* the marker arrives.
|
|
350
|
+
*/
|
|
351
|
+
function paintButton(surface, laid, node, scale, focused) {
|
|
352
|
+
const area = inner(laid, insets(node.style, scale));
|
|
353
|
+
if (area.w <= 0 || area.h <= 0)
|
|
354
|
+
return;
|
|
355
|
+
// The label's row: the middle of the button, not its first.
|
|
356
|
+
//
|
|
357
|
+
// A control the template gave height to is a box, and writing the label at the
|
|
358
|
+
// top of it put the words *on* the box's own top edge — through the border of
|
|
359
|
+
// a bordered button, and against the ceiling of a filled one, with the rest of
|
|
360
|
+
// the button empty underneath. Centring is what makes it read as a button
|
|
361
|
+
// with its label inside rather than a label with a box hanging off it.
|
|
362
|
+
const row = area.y + Math.floor(Math.max(0, area.h - 1) / 2);
|
|
363
|
+
if (focused === node)
|
|
364
|
+
surface.drawText(area.x, row, "› ", { bold: true }, BUTTON_MARKER);
|
|
365
|
+
const x = area.x + BUTTON_MARKER;
|
|
366
|
+
const width = area.w - BUTTON_MARKER;
|
|
367
|
+
if (width <= 0)
|
|
368
|
+
return;
|
|
369
|
+
const label = node.label;
|
|
370
|
+
const text = label.map((segment) => segment.text).join("");
|
|
371
|
+
// A control the template actually styled — the real buy CTA is
|
|
372
|
+
// `fillColor #ffffff, borderColor #ffffff, borderWidth 2, borderRadius 8` —
|
|
373
|
+
// is drawn as that button: its own fill and border are already painted, so
|
|
374
|
+
// the label is centred in it. The `[ … ]` chrome is our own affordance for a
|
|
375
|
+
// control that would otherwise be indistinguishable from a line of text, and
|
|
376
|
+
// on a button that already looks like one it is clutter — a bordered button
|
|
377
|
+
// came out reading `—[ RESTORE PURCHASE ]—`, its own frame and our brackets
|
|
378
|
+
// both.
|
|
379
|
+
const filled = sampleGradient(parseFill(node.style.fill), 0.5);
|
|
380
|
+
const boxed = drewBorder(laid, node.style.border);
|
|
381
|
+
/**
|
|
382
|
+
* Centre the label in the button itself, not in its padded content area.
|
|
383
|
+
*
|
|
384
|
+
* The two differ by the padding and by the two cells reserved for the focus
|
|
385
|
+
* marker, and centring in the smaller one pushed the label right until it
|
|
386
|
+
* overwrote the border it was supposed to sit inside — `RESTORE PURCHASE`
|
|
387
|
+
* came out with no closing edge. A bordered button keeps a cell clear at each
|
|
388
|
+
* edge; a filled one has the whole box.
|
|
389
|
+
*/
|
|
390
|
+
const centred = (colour) => {
|
|
391
|
+
const from = boxed ? laid.x + 1 : laid.x;
|
|
392
|
+
const span = Math.max(0, boxed ? laid.w - 2 : laid.w);
|
|
393
|
+
const shown = ellipsize(text, span);
|
|
394
|
+
const room = Math.max(0, span - textColumns(shown));
|
|
395
|
+
// Never under the focus marker, which is drawn at the left of the same row.
|
|
396
|
+
const start = Math.max(from + Math.floor(room / 2), focused === node ? area.x + BUTTON_MARKER : from);
|
|
397
|
+
// And never past the far edge, whatever the arithmetic above worked out.
|
|
398
|
+
drawRuns(surface, start, row, shown, 0, styleRuns(label, colour), from + span);
|
|
399
|
+
};
|
|
400
|
+
if (filled && filled.a > 0) {
|
|
401
|
+
// A cell has no alpha, so a label in the fill's own colour would vanish.
|
|
402
|
+
centred({ fontColor: toHex(readable(filled)) });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (boxed) {
|
|
406
|
+
centred({});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const styles = styleRuns(label, {});
|
|
410
|
+
// A control is one line: an authored icon button is a few cells wide and its
|
|
411
|
+
// label does not fit, and wrapping it would push the rows below it down.
|
|
412
|
+
const inside = ellipsize(text, width - BRACKETS);
|
|
413
|
+
surface.drawText(x, row, "[ ", {}, width);
|
|
414
|
+
drawRuns(surface, x + 2, row, inside, 0, styles, x + width);
|
|
415
|
+
const tail = 2 + textColumns(inside);
|
|
416
|
+
if (tail + 2 <= width)
|
|
417
|
+
surface.drawText(x + tail, row, " ]", {}, width - tail);
|
|
418
|
+
}
|
|
419
|
+
/** `[ ` and ` ]` around a label the template did not style. */
|
|
420
|
+
const BRACKETS = 4;
|
|
421
|
+
/**
|
|
422
|
+
* `text`, or as much of it as fits in `width` with an ellipsis for the rest.
|
|
423
|
+
*
|
|
424
|
+
* A control is one line — wrapping it would push everything below it down — so
|
|
425
|
+
* a label longer than its button has to lose something. Losing it silently
|
|
426
|
+
* reads as a shorter label than the template has: `SIGN IN WITH TV` looks
|
|
427
|
+
* authored, `SIGN IN WITH T…` says the button is too narrow for its words,
|
|
428
|
+
* which is the truth and is what the SDKs' own paywalls do.
|
|
429
|
+
*
|
|
430
|
+
* Grapheme by grapheme, because the ellipsis has to land on a whole character
|
|
431
|
+
* and an emoji or a CJK glyph is two columns wide.
|
|
432
|
+
*/
|
|
433
|
+
function ellipsize(text, width) {
|
|
434
|
+
if (width <= 0)
|
|
435
|
+
return "";
|
|
436
|
+
if (textColumns(text) <= width)
|
|
437
|
+
return text;
|
|
438
|
+
if (width === 1)
|
|
439
|
+
return "…";
|
|
440
|
+
let out = "";
|
|
441
|
+
let used = 0;
|
|
442
|
+
for (const glyph of graphemes(text)) {
|
|
443
|
+
const w = cellWidth(glyph);
|
|
444
|
+
if (used + w > width - 1)
|
|
445
|
+
break;
|
|
446
|
+
out += glyph;
|
|
447
|
+
used += w;
|
|
448
|
+
}
|
|
449
|
+
return `${out.trimEnd()}…`;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Whether `paintBorder` actually drew a frame around this box.
|
|
453
|
+
*
|
|
454
|
+
* Not merely whether one was authored: `drawFrame` needs two cells in each
|
|
455
|
+
* direction, so a one-row button's border is asked for and never appears — and
|
|
456
|
+
* that button does still want the `[ … ]` chrome, being otherwise a bare line
|
|
457
|
+
* of text.
|
|
458
|
+
*/
|
|
459
|
+
function drewBorder(laid, border) {
|
|
460
|
+
return (!!border && typeof border.width === "number" && border.width > 0 && laid.w >= 2 && laid.h >= 2);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Near-black or near-white, whichever reads against this fill. A terminal cell
|
|
464
|
+
* has no alpha, so a label painted in the fill's own colour would vanish — the
|
|
465
|
+
* real buy CTA is white on white without this.
|
|
466
|
+
*/
|
|
467
|
+
function readable(bg) {
|
|
468
|
+
// Rec. 601 luma, which is what every contrast rule of thumb is built on.
|
|
469
|
+
const luma = (bg.r * 299 + bg.g * 587 + bg.b * 114) / 1000;
|
|
470
|
+
return luma > 140 ? { r: 17, g: 17, b: 17, a: 1 } : { r: 255, g: 255, b: 255, a: 1 };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* A sku card: its own lines inside a one-cell border, which is the border the
|
|
474
|
+
* layout reserved two cells of geometry for.
|
|
475
|
+
*/
|
|
476
|
+
function paintCard(surface, laid, node, scale, focused) {
|
|
477
|
+
const area = inner(laid, insets(node.style, scale));
|
|
478
|
+
if (area.w < 2 || area.h < 2)
|
|
479
|
+
return;
|
|
480
|
+
if (focused === node)
|
|
481
|
+
surface.drawText(area.x, area.y, "›", { bold: true }, 1);
|
|
482
|
+
const border = node.style?.border;
|
|
483
|
+
const color = parseColor(border?.color);
|
|
484
|
+
drawFrame(surface, area, (border?.radius ?? 0) > 0, color ? { fg: color } : {});
|
|
485
|
+
const width = area.w - CARD_BORDER * 2;
|
|
486
|
+
// Wrap with the same function the layout measured the card's height with
|
|
487
|
+
// (`layout.ts`'s `measureLines` -> `wrapText`). Clipping here instead would
|
|
488
|
+
// truncate a line the layout had already reserved a second row for, which is
|
|
489
|
+
// how `${sku.displayText}` lost its closing brace.
|
|
490
|
+
// A card wearing its CTA's fill needs a label that reads against it — the real
|
|
491
|
+
// buy control is white, and white-on-white is invisible.
|
|
492
|
+
const filled = sampleGradient(parseFill(node.style?.fill), 0.5);
|
|
493
|
+
const cardText = filled && filled.a > 0 ? { fontColor: toHex(readable(filled)) } : {};
|
|
494
|
+
let row = 0;
|
|
495
|
+
for (const line of node.lines) {
|
|
496
|
+
const text = line.map((segment) => segment.text).join("");
|
|
497
|
+
const styles = styleRuns(line, cardText);
|
|
498
|
+
let cursor = 0;
|
|
499
|
+
for (const part of wrapText(text, width)) {
|
|
500
|
+
if (row >= area.h - CARD_BORDER * 2)
|
|
501
|
+
return;
|
|
502
|
+
// Where this wrapped row starts in the unwrapped line, so its styling
|
|
503
|
+
// stays aligned: `wrapText` breaks on spaces, so the rows are in order.
|
|
504
|
+
const at = text.indexOf(part, cursor);
|
|
505
|
+
const start = at === -1 ? cursor : at;
|
|
506
|
+
drawRuns(surface, area.x + CARD_BORDER, area.y + CARD_BORDER + row, part, start, styles);
|
|
507
|
+
cursor = start + part.length;
|
|
508
|
+
row += 1;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Images
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
/**
|
|
516
|
+
* An image's cells, placed at its box's top left.
|
|
517
|
+
*
|
|
518
|
+
* The cells carry their own colours, and an empty `fg` or `bg` means "leave
|
|
519
|
+
* what is underneath" — `drawCells` already honours that, so nothing is
|
|
520
|
+
* pre-filled behind an image. There are no pixels at layout time, so the box is
|
|
521
|
+
* whatever was authored; a grid smaller than its box is never stretched to fill
|
|
522
|
+
* it. Anything that cannot be drawn is named rather than left as a hole.
|
|
523
|
+
*/
|
|
524
|
+
function paintImage(surface, laid, node, scale, deps) {
|
|
525
|
+
const area = inner(laid, insets(node.style, scale));
|
|
526
|
+
if (area.w <= 0 || area.h <= 0)
|
|
527
|
+
return;
|
|
528
|
+
// A terminal that draws real pixels takes the whole box. Nothing is painted
|
|
529
|
+
// there — a placeholder or a glyph image would show through wherever the
|
|
530
|
+
// picture is transparent — and whatever was painted there already is cleared,
|
|
531
|
+
// because such an image is drawn below any cell that carries a background
|
|
532
|
+
// colour and a fill left behind would hide it outright.
|
|
533
|
+
// What is already under this box — the page fill, a section's fill, whatever
|
|
534
|
+
// was painted before it. Both renderers composite the image against it, so it
|
|
535
|
+
// has to be read before the cells are cleared for one of them.
|
|
536
|
+
const beneath = surface.cellAt(area.x, area.y)?.bg;
|
|
537
|
+
const covered = deps.reserveImage?.({
|
|
538
|
+
url: node.url,
|
|
539
|
+
x: area.x,
|
|
540
|
+
y: area.y,
|
|
541
|
+
w: area.w,
|
|
542
|
+
h: area.h,
|
|
543
|
+
cropping: node.cropping,
|
|
544
|
+
section: laid.section,
|
|
545
|
+
backdrop: beneath,
|
|
546
|
+
align: imageAlignment(node.style),
|
|
547
|
+
});
|
|
548
|
+
if (covered) {
|
|
549
|
+
// Only what the picture actually covers. An image keeps its own shape, so
|
|
550
|
+
// it rarely fills the box it was given, and clearing the whole box left the
|
|
551
|
+
// terminal's own background showing in bands either side of every logo.
|
|
552
|
+
surface.clearRect(covered.x, covered.y, covered.w, covered.h);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const cells = deps.image?.(node.url, area.w, area.h, beneath, node.cropping) ?? null;
|
|
556
|
+
if (cells && cells.length > 0) {
|
|
557
|
+
// Keeping the image's shape means it rarely fills its box exactly, so where
|
|
558
|
+
// it sits in the space left over is the authored alignment's business. Both
|
|
559
|
+
// renderers centred regardless, which put the NFL+ logo — `alignment: left`
|
|
560
|
+
// — in the middle of its box.
|
|
561
|
+
const align = imageAlignment(node.style);
|
|
562
|
+
const wide = Math.max(...cells.map((row) => row.length));
|
|
563
|
+
const x = area.x + Math.max(0, Math.floor((area.w - wide) * align.x));
|
|
564
|
+
const y = area.y + Math.max(0, Math.floor((area.h - cells.length) * align.y));
|
|
565
|
+
surface.drawCells(x, y, cells);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// A background image that cannot be drawn paints nothing. It is decoration
|
|
569
|
+
// sized to the whole page, so its name would land at the top-left and sit
|
|
570
|
+
// over the header — on the real NFL location page that put `[image: …]`
|
|
571
|
+
// through the centred logo. A header or content image keeps its placeholder,
|
|
572
|
+
// because there it occupies layout a developer needs to see.
|
|
573
|
+
if (laid.section === "background")
|
|
574
|
+
return;
|
|
575
|
+
surface.drawText(area.x, area.y, `[image: ${shortName(node.alt ?? fileName(node.url))}]`, { dim: true }, area.w);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* The authored alignment of an image inside its own box, as fractions.
|
|
579
|
+
*
|
|
580
|
+
* `horizontalAlignment` wins over the bare `alignment` the templates also use,
|
|
581
|
+
* which is the same precedence `paintText` and the layout apply.
|
|
582
|
+
*/
|
|
583
|
+
function imageAlignment(style) {
|
|
584
|
+
return {
|
|
585
|
+
x: alignmentFraction(style?.horizontalAlignment ?? style?.alignment),
|
|
586
|
+
y: alignmentFraction(style?.verticalAlignment),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function shortName(name) {
|
|
590
|
+
return name.length <= NAME_LIMIT ? name : `…${name.slice(-(NAME_LIMIT - 1))}`;
|
|
591
|
+
}
|
|
592
|
+
function fileName(url) {
|
|
593
|
+
const path = url.split(/[?#]/)[0];
|
|
594
|
+
return path.split("/").filter(Boolean).pop() ?? url;
|
|
595
|
+
}
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Geometry
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
/**
|
|
600
|
+
* A box's padding in whole cells, through `style.ts`'s own `toCells` — the same
|
|
601
|
+
* call the layout made, so the content lands where the layout measured it.
|
|
602
|
+
* Yoga's computed width includes the padding, which is why it comes back off.
|
|
603
|
+
*/
|
|
604
|
+
function insets(style, scale) {
|
|
605
|
+
const edges = style?.padding;
|
|
606
|
+
if (!edges)
|
|
607
|
+
return NO_INSETS;
|
|
608
|
+
const out = { ...NO_INSETS };
|
|
609
|
+
for (const side of ["top", "right", "bottom", "left"]) {
|
|
610
|
+
const authored = edges[side];
|
|
611
|
+
// A value still carrying `${state.safeAreaTop}` is not a number of pixels.
|
|
612
|
+
if (typeof authored !== "number")
|
|
613
|
+
continue;
|
|
614
|
+
out[side] = toCells(authored, EDGE_AXIS[side], scale);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
function inner(laid, pad) {
|
|
619
|
+
return {
|
|
620
|
+
x: laid.x + pad.left,
|
|
621
|
+
y: laid.y + pad.top,
|
|
622
|
+
w: laid.w - pad.left - pad.right,
|
|
623
|
+
h: laid.h - pad.top - pad.bottom,
|
|
624
|
+
};
|
|
625
|
+
}
|