react-x11 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +12 -4
  3. package/src/Reconciler.js +19 -31
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/appcontext.js +59 -30
  7. package/src/bootstrap.js +14 -0
  8. package/src/clientmessage.js +1 -1
  9. package/src/cocoa/app.js +303 -49
  10. package/src/cocoa/bezels.js +175 -30
  11. package/src/cocoa/dnd.js +27 -13
  12. package/src/cocoa/fonts.js +3 -3
  13. package/src/cocoa/glarea.js +24 -5
  14. package/src/cocoa/main.d.ts +8 -0
  15. package/src/cocoa/main.js +43 -0
  16. package/src/cocoa/overlay.js +159 -0
  17. package/src/cocoa/panehost.js +15 -5
  18. package/src/cocoa/presenter.js +13 -9
  19. package/src/cocoa/promotion.js +17 -7
  20. package/src/cocoa/relaunch.js +207 -0
  21. package/src/cocoa/threaded.js +246 -0
  22. package/src/cocoa/window.js +256 -42
  23. package/src/components/Select.js +2 -2
  24. package/src/components/anchor.js +3 -3
  25. package/src/components/native.js +12 -7
  26. package/src/components/theme.js +2 -2
  27. package/src/debug.js +1 -1
  28. package/src/decorations.js +1 -1
  29. package/src/editmenu.js +2 -2
  30. package/src/embedding.js +31 -0
  31. package/src/errors.js +46 -0
  32. package/src/events.js +78 -18
  33. package/src/foreignnodes.js +59 -5
  34. package/src/frames.js +2 -2
  35. package/src/glnodes.js +172 -41
  36. package/src/gloverlay.js +383 -0
  37. package/src/grid.js +1653 -0
  38. package/src/host.d.ts +230 -1
  39. package/src/host.js +11 -3
  40. package/src/imagesource.js +1 -1
  41. package/src/index.d.ts +34 -4
  42. package/src/index.js +9 -1
  43. package/src/layouts.js +721 -0
  44. package/src/node.d.ts +16 -3
  45. package/src/node.js +19 -21
  46. package/src/nodes/animation.js +644 -0
  47. package/src/nodes/box.js +21 -0
  48. package/src/nodes/boxpaint.js +473 -0
  49. package/src/nodes/canvas.js +269 -0
  50. package/src/nodes/cascade.js +600 -0
  51. package/src/nodes/damage.js +183 -0
  52. package/src/nodes/edithistory.js +124 -0
  53. package/src/nodes/editmenupopup.js +260 -0
  54. package/src/nodes/hittest.js +185 -0
  55. package/src/nodes/image.js +266 -0
  56. package/src/nodes/install.js +75 -0
  57. package/src/nodes/invalidate.js +465 -0
  58. package/src/nodes/kinds.js +31 -0
  59. package/src/nodes/layout.js +439 -0
  60. package/src/nodes/layouthost.js +949 -0
  61. package/src/nodes/node.js +868 -0
  62. package/src/nodes/paint.js +466 -0
  63. package/src/nodes/position.js +366 -0
  64. package/src/nodes/preedit.js +127 -0
  65. package/src/nodes/queries.js +330 -0
  66. package/src/nodes/rects.js +102 -0
  67. package/src/nodes/scrollable.js +891 -0
  68. package/src/nodes/scrollbars.js +138 -0
  69. package/src/nodes/scrollblit.js +1034 -0
  70. package/src/nodes/selectable.js +142 -0
  71. package/src/nodes/styling.js +225 -0
  72. package/src/nodes/text.js +649 -0
  73. package/src/nodes/textarea.js +391 -0
  74. package/src/nodes/textinput.js +1146 -0
  75. package/src/nodes/util.js +17 -0
  76. package/src/nodes/window/anchoring.js +161 -0
  77. package/src/nodes/window/capabilities.js +190 -0
  78. package/src/nodes/window/debugpaint.js +83 -0
  79. package/src/nodes/window/droptarget.js +145 -0
  80. package/src/nodes/window/floors.js +577 -0
  81. package/src/nodes/window/flush.js +369 -0
  82. package/src/nodes/window/hints.js +482 -0
  83. package/src/nodes/window/listeners.js +222 -0
  84. package/src/nodes/window/popup.js +71 -0
  85. package/src/nodes/window/size.js +591 -0
  86. package/src/nodes/window/window.js +954 -0
  87. package/src/palette.js +1 -1
  88. package/src/registry.js +7 -3
  89. package/src/styles.js +137 -15
  90. package/src/svgnodes.js +2 -1
  91. package/src/testing/harness.js +2 -2
  92. package/src/textselection.js +5 -3
  93. package/src/trace-registry.js +1 -1
  94. package/src/types/components.d.ts +38 -6
  95. package/src/types/elements.d.ts +26 -14
  96. package/src/types/nodes.d.ts +17 -2
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
@@ -0,0 +1,649 @@
1
+ // <text> and its chunks: shaped, wrapped and elided text, the line bands a
2
+ // selection paints, and the glyph strip.
3
+
4
+ import { localTextStyleChanged, TEXT_REMEASURE } from '../styles.js';
5
+ import { cssColorStraight } from 'ntk';
6
+ import { hooks as a11yHooks } from '../a11y.js';
7
+ import { codePointAtOffset, codeUnitOffsets } from '../textrange.js';
8
+ import { NO_DAMAGE } from './damage.js';
9
+ import { Node } from './node.js';
10
+
11
+ /**
12
+ * Yoga's measure modes, in words. Indexed by the integer yoga hands a
13
+ * measure function, so `MEASURE_MODES[widthMode]` is the name.
14
+ *
15
+ * The names are the public vocabulary (`measureContent`) and the integers
16
+ * are not: an element that wrote `widthMode === 0` would be pinned to
17
+ * yoga's ABI through us, which is exactly what the seam exists to stop.
18
+ */
19
+ /**
20
+ * Text smaller than this many logical pixels is painted as a strip in its
21
+ * ink instead of as glyphs (`TextNode._paintsStrip`). Six is under the
22
+ * smallest size any UI sets on purpose and over what a zoomed-out view
23
+ * shrinks its labels to; `createRoot({ textStripBelow })` is the seam.
24
+ */
25
+ export const TEXT_STRIP_BELOW = 6;
26
+ // The band a strip covers, in ems around the baseline — the x-height and a
27
+ // little of the ascenders above it, the descenders below — and the share of
28
+ // the ink it is painted at, which is roughly how much of that band small
29
+ // text actually inks.
30
+ const STRIP_ABOVE_BASELINE = 0.6;
31
+ const STRIP_BELOW_BASELINE = 0.1;
32
+ const STRIP_COVERAGE = 0.45;
33
+
34
+ const textStripBelow = new WeakMap();
35
+
36
+ /** The size under which this connection's text is painted as strips. */
37
+ export function setTextStripBelow(app, below) {
38
+ if (below === undefined) return;
39
+ if (typeof below !== 'number' || !(below >= 0)) {
40
+ throw new Error(
41
+ `react-x11: createRoot({ textStripBelow: ${JSON.stringify(below)} }) ` +
42
+ '— a size in logical pixels, or 0 to paint glyphs at every size.',
43
+ );
44
+ }
45
+ textStripBelow.set(app, below);
46
+ }
47
+
48
+ // `REACT_X11_TEXT_STRIP_BELOW` is the same line for a process that cannot
49
+ // reach `createRoot` — a bench comparing the strip against glyphs, an app
50
+ // run under a harness — read once
51
+ const textStripBelowEnv = Number(process.env.REACT_X11_TEXT_STRIP_BELOW);
52
+
53
+ const textStripBelowFor = (app) =>
54
+ textStripBelow.get(app) ??
55
+ (Number.isFinite(textStripBelowEnv) && textStripBelowEnv >= 0
56
+ ? textStripBelowEnv
57
+ : TEXT_STRIP_BELOW);
58
+
59
+ /**
60
+ * Downward shift that recreates CSS "half-leading". ntk's TextLayout puts
61
+ * the first baseline at exactly `ascent` and packs each line's leading
62
+ * (font line gap + any lineHeight surplus) entirely *below* the glyphs, so
63
+ * a layout drawn at the top of its measured box rides visually high —
64
+ * most noticeable centered in buttons/inputs (fonts like Helvetica carry a
65
+ * 0.5em line gap). CSS instead splits that leading evenly above and below
66
+ * the ink (see seek-oss capsize for the metrics background).
67
+ */
68
+ function halfLeading(layout) {
69
+ const last = layout.lines?.[layout.lines.length - 1];
70
+ if (!last) return 0;
71
+ return Math.max(0, (layout.height - (last.baseline + last.descent)) / 2);
72
+ }
73
+
74
+ /** A selected line with nothing on it still shows, so a blank line inside a
75
+ * selection does not read as the highlight having stopped. */
76
+ const EMPTY_LINE_BAND = 4;
77
+
78
+ /**
79
+ * The rectangles a highlight over `[start, end)` code points fills, in the
80
+ * layout's own coordinates — one per line, and one per **direction run**
81
+ * inside a line.
82
+ *
83
+ * The per-run walk is the whole reason this is not four lines of caret
84
+ * arithmetic. A selection is contiguous in *logical* order and a line is
85
+ * laid out in *visual* order, so in "the file مرحبا here" a range that
86
+ * crosses into the Arabic covers two disjoint stretches of pixels, and a
87
+ * single rect from one caret x to the other paints over text nobody
88
+ * selected. Each run is intersected with the range in code units — the space
89
+ * ntk reports run extents in — and only a boundary falling *inside* a run
90
+ * costs a `caretPosition`; a fully covered run is its own two edges, which
91
+ * with the merge below is what keeps a plain paragraph at one rect per line.
92
+ *
93
+ * It belongs in ntk's `TextLayout`, beside the private offset table it
94
+ * rebuilds here. It is here because the selection needs it now.
95
+ */
96
+ export function rangeBands(layout, text, start, end) {
97
+ const lines = layout.lines;
98
+ if (!lines?.length || end <= start) return [];
99
+ const offsets = codeUnitOffsets(text);
100
+ const last = offsets.length - 1;
101
+ const from = offsets[Math.max(0, Math.min(start, last))];
102
+ const to = offsets[Math.max(0, Math.min(end, last))];
103
+ if (to <= from) return [];
104
+ const bands = [];
105
+ for (const line of lines) {
106
+ if (line.end <= from || line.start >= to) continue;
107
+ const spans = [];
108
+ for (const positioned of line.runs) {
109
+ const a = Math.max(from, positioned.start);
110
+ const b = Math.min(to, positioned.end);
111
+ if (b <= a) continue;
112
+ const rtl = positioned.run?.direction === 'rtl';
113
+ const near = line.x + positioned.x;
114
+ const far = near + positioned.width;
115
+ // a boundary at the run's own logical edge is that edge — which side
116
+ // of the pixels it is on is what the run's direction decides
117
+ const edgeAt = (cu, logicalStart) => {
118
+ if (logicalStart ? cu <= positioned.start : cu >= positioned.end) {
119
+ return rtl === logicalStart ? far : near;
120
+ }
121
+ return layout.caretPosition(codePointAtOffset(offsets, cu)).x;
122
+ };
123
+ const x1 = edgeAt(a, true);
124
+ const x2 = edgeAt(b, false);
125
+ spans.push([Math.min(x1, x2), Math.max(x1, x2)]);
126
+ }
127
+ if (!spans.length) {
128
+ bands.push({
129
+ x: line.x,
130
+ y: line.y,
131
+ width: EMPTY_LINE_BAND,
132
+ height: line.height,
133
+ });
134
+ continue;
135
+ }
136
+ // Runs also split at every style span, so an ordinary line with a bold
137
+ // word in it is three rectangles that touch. Merging keeps the common
138
+ // case at one per line.
139
+ spans.sort((p, q) => p[0] - q[0]);
140
+ let [left, right] = spans[0];
141
+ for (let i = 1; i <= spans.length; i++) {
142
+ const next = spans[i];
143
+ if (next && next[0] <= right + 0.5) {
144
+ right = Math.max(right, next[1]);
145
+ continue;
146
+ }
147
+ if (right > left) {
148
+ bands.push({
149
+ x: left,
150
+ y: line.y,
151
+ width: right - left,
152
+ height: line.height,
153
+ });
154
+ }
155
+ if (next) [left, right] = next;
156
+ }
157
+ }
158
+ return bands;
159
+ }
160
+
161
+ /** Raw string/number children of <text>. */
162
+ export class TextChunkNode extends Node {
163
+ constructor(text, app) {
164
+ super('textchunk', {}, app, { yoga: false });
165
+ this.text = String(text);
166
+ }
167
+
168
+ setText(text) {
169
+ this.text = String(text);
170
+ this.parent?._textContentChanged();
171
+ a11yHooks.textContent?.(this);
172
+ // the chunk has no geometry of its own — the ancestor that owns a yoga
173
+ // node is the box that rewraps, and its before/after rects are the
174
+ // bound on what a new string can repaint
175
+ let owner = this.parent;
176
+ while (owner && !owner.yoga) owner = owner.parent;
177
+ if (owner) owner._invalidateLayout('text');
178
+ else this.root?.invalidate(true, null, 'text');
179
+ }
180
+
181
+ _textContentChanged() {
182
+ this.parent?._textContentChanged();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * <text>. The outermost <text> owns a yoga node with a measure function;
188
+ * nested <text> elements are style spans (no yoga node) — the paragraph is
189
+ * laid out as one run list so wrapping spans the whole content
190
+ * (ntk TextLayout accepts [{ text, ...style overrides, color }] spans).
191
+ */
192
+ export class TextNode extends Node {
193
+ constructor(props, app, { span = false } = {}) {
194
+ super('text', props, app, { yoga: !span });
195
+ this.isSpan = span;
196
+ this._layouts = new Map();
197
+ }
198
+
199
+ /** Height for a width: the paragraph shaped into whatever is on offer.
200
+ * The offer is `Infinity` when nothing bounds it, which is also what
201
+ * `textWrap: 'nowrap'` asks for, so neither needs a mode.
202
+ *
203
+ * Both answers are **whole pixels**, the trimmed one included — see
204
+ * `_trim` for why the rounding is not cosmetic. The glyphs are placed
205
+ * from the unrounded trim (`_placedLayout`), so what the rounding moves
206
+ * is the bottom edge of the box, by less than half a pixel. */
207
+ measureContent({ width }) {
208
+ const layout = this._layoutFor(this._wrapWidth(width));
209
+ if (!layout) return { width: 0, height: 0 };
210
+ const trim = this._trim(layout);
211
+ return {
212
+ width: Math.ceil(layout.width),
213
+ height: Math.max(
214
+ 0,
215
+ trim
216
+ ? Math.round(Math.ceil(layout.height) - (trim.top + trim.bottom))
217
+ : Math.ceil(layout.height),
218
+ ),
219
+ };
220
+ }
221
+
222
+ _textContentChanged() {
223
+ if (this.isSpan) {
224
+ this.parent?._textContentChanged();
225
+ return;
226
+ }
227
+ this._layouts.clear();
228
+ if (this.yoga) this.yoga.markDirty();
229
+ }
230
+
231
+ /**
232
+ * The cached layout is stale, but the box it reported cannot have moved.
233
+ *
234
+ * `textRendering` rides on the spans inside a layout, so a cached one keeps
235
+ * answering with the old value and has to go — but it decides only how
236
+ * glyph origins are rounded at draw time, and ntk's layout measures
237
+ * byte-identically whichever way it is set. So the layout is dropped
238
+ * without marking yoga dirty: the next paint calls `_layoutFor` and
239
+ * rebuilds it, and nothing reflows on the way.
240
+ */
241
+ _textPaintChanged() {
242
+ if (this.isSpan) {
243
+ this.parent?._textPaintChanged();
244
+ return;
245
+ }
246
+ this._layouts.clear();
247
+ }
248
+
249
+ /**
250
+ * The base direction is an input to shaping, not just to painting: it sets
251
+ * the level every neutral character resolves against and the edge
252
+ * `textAlign: 'start'` means. So a paragraph whose direction moved is a
253
+ * paragraph that has to be laid out again, at the same cost as a font
254
+ * change.
255
+ */
256
+ _directionMoved() {
257
+ this._textContentChanged();
258
+ const owner = this._textBoxOwner();
259
+ if (owner) owner._invalidateLayout('direction');
260
+ else this.root?.invalidate(true, null, 'direction');
261
+ }
262
+
263
+ /** The node that owns the box this text flows in. A span has none of its
264
+ * own, so its geometry — and its damage — belong to the nearest ancestor
265
+ * with a yoga node. */
266
+ _textBoxOwner() {
267
+ let owner = this;
268
+ while (owner && !owner.yoga) owner = owner.parent;
269
+ return owner;
270
+ }
271
+
272
+ /**
273
+ * The type this text is set in moved, from its own style or from an
274
+ * ancestor's. The two costs differ by a layout pass.
275
+ *
276
+ * A re-measure has to *ask* for one. None of `fontSize`, `fontWeight`,
277
+ * `fontFamily` or `fontStyle` is a yoga property, so `applyLayoutStyle`
278
+ * sees nothing move, and none is a paint prop either, so the node
279
+ * contributes no damage — the frame is already decided by the time the
280
+ * layout is dropped. They are all inputs to the *measure function*, and the
281
+ * dirty flag `_textContentChanged` sets is only read by a layout pass:
282
+ * without asking for one the cleared layout is never rebuilt and the old
283
+ * glyphs stay on screen with nothing reporting an error.
284
+ */
285
+ _textStyleMoved(cost) {
286
+ const owner = this._textBoxOwner();
287
+ if (cost === TEXT_REMEASURE) {
288
+ this._textContentChanged();
289
+ if (owner) owner._invalidateLayout('text');
290
+ else this.root?.invalidate(true, null, 'text');
291
+ return;
292
+ }
293
+ // Only the ink or the glyph rounding. Both ride on the spans inside the
294
+ // cached layout, so it still has to go — but the box cannot have moved,
295
+ // and `false` here is the whole point: this is the path a `:hover`
296
+ // arrives by, once per pointer move, and a transitioned colour by, once
297
+ // per frame.
298
+ this._textPaintChanged();
299
+ this.root?.invalidate(false, owner ?? NO_DAMAGE, 'text');
300
+ }
301
+
302
+ applyProps(newProps, oldProps) {
303
+ const before = this.style;
304
+ super.applyProps(newProps, oldProps);
305
+ // The inherited half of the text style — the face, the size, the ink —
306
+ // travels through `_retarget` and lands in `_textStyleMoved`, whichever
307
+ // route it arrived by. What is left here is what only this node's own box
308
+ // cares about: how its lines are aligned, how tall they are, whether they
309
+ // wrap at all.
310
+ if (!localTextStyleChanged(this.style, before)) return;
311
+ this._textContentChanged();
312
+ const owner = this._textBoxOwner();
313
+ if (owner) owner._invalidateLayout('text');
314
+ else this.root?.invalidate(true, null, 'text');
315
+ }
316
+
317
+ /**
318
+ * The paragraph as a flat run list: one entry per chunk of text, carrying
319
+ * the style resolved where that chunk is written.
320
+ *
321
+ * A nested `<text>` is a span, and it inherits from the `<text>` around it
322
+ * by the same mechanism a `<text>` inherits from the `<box>` around it —
323
+ * `resolvedTextStyle()` walks the parents either way, so a span needs no
324
+ * inheritance rule of its own. It also has to *ask*, rather than be handed
325
+ * the answer: filling the cache here is what makes `:hover` on a span work,
326
+ * since an unresolved node is one `_retext` skips.
327
+ */
328
+ collectSpans(out) {
329
+ const style = this.resolvedTextStyle();
330
+ for (const child of this.children) {
331
+ if (child.kind === 'textchunk') {
332
+ out.push({
333
+ text: child.text,
334
+ family: style.family,
335
+ size: style.size,
336
+ weight: style.weight,
337
+ style: style.style,
338
+ variations: style.variations,
339
+ textRendering: style.textRendering,
340
+ color: style.color,
341
+ });
342
+ } else if (child.kind === 'text') {
343
+ child.collectSpans(out);
344
+ }
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /**
350
+ * `textOverflow: 'ellipsis'` — is this paragraph one that ends in a `…`
351
+ * when it does not fit, rather than one that is sliced?
352
+ *
353
+ * Read in four places, because eliding is not only a drawing decision: it
354
+ * changes how many lines there are, which width the paragraph is shaped
355
+ * against, and therefore what the node reports to layout.
356
+ */
357
+ _elides() {
358
+ return this.style.textOverflow === 'ellipsis';
359
+ }
360
+
361
+ /**
362
+ * How many lines are kept — CSS's `-webkit-line-clamp` under the name the
363
+ * platforms that got a clean shot at it chose. Unlimited by default.
364
+ *
365
+ * **`textOverflow: 'ellipsis'` on its own means one line.** ntk elides off
366
+ * a line *count* (`truncated = lineTokens.length > maxLines`), so an
367
+ * ellipsis with no cap can never fire: there is nothing over the cap to
368
+ * stand for. Leaving it inert would make `textOverflow: 'ellipsis'` a
369
+ * property that silently does nothing in the case it is most often
370
+ * written for — a name, a path, a status line — so the cap an author
371
+ * almost certainly meant is the default, and `maxLines` is how they say
372
+ * two or three instead.
373
+ *
374
+ * A cap below one keeps one: a `<text>` that renders nothing at all is
375
+ * conditional rendering, not a truncation setting, and it would look like
376
+ * a missing label rather than like a number.
377
+ */
378
+ _maxLines() {
379
+ const { maxLines } = this.style;
380
+ if (Number.isFinite(maxLines)) return Math.max(1, Math.floor(maxLines));
381
+ return this._elides() ? 1 : Infinity;
382
+ }
383
+
384
+ /**
385
+ * `textWrap: 'nowrap'` — CSS's, and the reason a table cell is a table cell
386
+ * rather than a paragraph.
387
+ *
388
+ * A `<text>` measures height-for-width: hand it a narrow box and it wraps
389
+ * to fit, which is right for prose and wrong for a row of a list. A cell is
390
+ * a fixed height, so a date that wraps to two lines is not a taller row —
391
+ * it is a line and a half of date with the rest sliced off, top and bottom,
392
+ * and the same is true of any name longer than its column. Measuring at
393
+ * unbounded width makes the overflow horizontal instead, which is what
394
+ * `overflow: 'hidden'` on the cell already knows how to deal with.
395
+ *
396
+ * **Unless it elides.** Then the unbounded measurement is exactly what has
397
+ * to go: at `maxWidth: Infinity` there is one line, one line is never over
398
+ * the cap, and nothing is ever cut — the single-line ellipsis, which is by
399
+ * a distance the common case, could not be spelled at all. So an eliding
400
+ * `nowrap` is shaped against the width on offer, and the two properties
401
+ * divide up cleanly: `textWrap` says the text does not wrap, `maxLines`
402
+ * says how much of it is kept, and the width is the box's either way.
403
+ *
404
+ * The visible consequence is in what the node reports back to layout. A
405
+ * clipping `nowrap` `<text>` measures its whole string at any offer, so
406
+ * its min-content floor is the full width and the box around it is pushed
407
+ * out to fit (and then clips). An eliding one measures inside the offer,
408
+ * so its floor is small and it gives way instead — which is the point: a
409
+ * column that cannot show a file name should show `Applicati…`, not force
410
+ * every other column narrower to avoid saying so.
411
+ */
412
+ _wrapWidth(maxWidth) {
413
+ if (this.style.textWrap !== 'nowrap') return maxWidth;
414
+ return this._elides() ? maxWidth : Infinity;
415
+ }
416
+
417
+ _layoutFor(maxWidth) {
418
+ const fonts = this.app?.fonts;
419
+ if (!fonts) return null; // mock container in tests: no text metrics
420
+ const maxLines = this._maxLines();
421
+ const overflow = this.style.textOverflow;
422
+ // Both truncation options are inputs to the shaping, so both belong in
423
+ // the key. They can only change with the style, which clears the whole
424
+ // map on its way past — but a cache keyed on less than it depends on is
425
+ // one refactor away from answering with the wrong paragraph, and the
426
+ // wrong paragraph here is glyphs on screen that no error mentions.
427
+ const key = `${maxWidth}|${maxLines}|${overflow ?? ''}`;
428
+ let layout = this._layouts.get(key);
429
+ if (!layout) {
430
+ const spans = this.collectSpans([]);
431
+ const base = this.resolvedTextStyle();
432
+ layout = fonts.layout(spans, base, {
433
+ maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
434
+ align: this.style.textAlign,
435
+ lineHeight: this.style.lineHeight,
436
+ maxLines: Number.isFinite(maxLines) ? maxLines : undefined,
437
+ // 'clip' is ntk's default, so an unset property and the CSS default
438
+ // are the same request rather than two paths through the layout.
439
+ overflow,
440
+ // The paragraph's **base** direction, which is not the same question
441
+ // as which script the characters are in. UAX#9 resolves a run of
442
+ // neutrals — `"(1) 12:30"`, a filename, a lone bracket — against the
443
+ // paragraph level, and the first-strong-character rule is only what
444
+ // to do when nobody said. So handing the box's direction down is what
445
+ // makes an Arabic paragraph parenthesise and punctuate correctly, and
446
+ // it is also what `textAlign: 'start'` resolves against: ntk aligns
447
+ // `start`/`end` to the base level, so a `<text>` with no strong
448
+ // characters at all lands on the right side of an RTL box.
449
+ direction: this.direction,
450
+ });
451
+ if (this._layouts.size > 32) this._layouts.clear();
452
+ this._layouts.set(key, layout);
453
+ }
454
+ return layout;
455
+ }
456
+
457
+ /**
458
+ * `textBoxTrim: 'cap-alphabetic'` — CSS's `text-box-trim: trim-both` with
459
+ * `text-box-edge: cap alphabetic`. How much of the line box to take off
460
+ * the top and the bottom so the box *is* the letters: from the capitals
461
+ * down to the last baseline.
462
+ *
463
+ * A line box is not the text you can see. It is the font's ascent plus
464
+ * descent plus line gap, and the space over a capital differs from the
465
+ * space under a baseline by `(ascent - capHeight) - descent` — a property
466
+ * of the typeface, which is why padding around an untrimmed label is only
467
+ * ever optically even by luck. `lineHeight` cannot fix it: it scales the
468
+ * box and the leading still splits evenly, so it moves both edges alike.
469
+ *
470
+ * Measured in the coordinates the layout is **drawn** in, not the ones it
471
+ * reports: `halfLeading` shifts it, and deriving the baseline from the
472
+ * metrics again would silently disagree the day that shift changes.
473
+ *
474
+ * The amounts are fractions of a pixel and stay that way — the glyphs are
475
+ * placed from them (`_placedLayout`). What must not stay fractional is the
476
+ * **box** they leave behind, which is why `measureContent` rounds the
477
+ * height it reports and this does not (issue #411).
478
+ *
479
+ * A trimmed label measures to the cap band, and a cap height is a fraction
480
+ * of the em — so before the rounding, a column of trimmed titles handed
481
+ * yoga three or four flex items whose main size had a fraction in it and
482
+ * whose content floors (#249) were that same fraction. Yoga freezes a line
483
+ * like that item by item and divides the overflow by a total shrink factor
484
+ * that should have cancelled to zero; a fraction that is not exact in
485
+ * binary leaves a rounding residue there instead, and dividing by it laid
486
+ * the section titles of `examples/configurator` out 5.6 billion pixels
487
+ * tall. See `writeFloors`, which is the other end of it.
488
+ */
489
+ _trim(layout) {
490
+ if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
491
+ const lines = layout?.lines;
492
+ if (!lines?.length) return null;
493
+ const base = this.resolvedTextStyle();
494
+ const font = this.app?.fonts?.match?.(base.family, {
495
+ weight: base.weight,
496
+ style: base.style,
497
+ });
498
+ const measured = font?.metrics?.(base.size)?.capHeight;
499
+ if (!measured) return null; // no metrics: leave the box alone
500
+ // Whole pixels: the trimmed box's top is the baseline less this, so a
501
+ // fractional cap height — 9.15px for a 13px face — puts the baseline
502
+ // between two rows, and the rasteriser lands the letters a row low on
503
+ // one backend and half-covers two rows on the other. Rounded, the
504
+ // baseline sits on a pixel wherever the box does.
505
+ const capHeight = Math.round(measured);
506
+ const shift = halfLeading(layout);
507
+ const firstBaseline = shift + lines[0].baseline;
508
+ const lastBaseline = shift + lines[lines.length - 1].baseline;
509
+ return {
510
+ top: Math.max(0, firstBaseline - capHeight),
511
+ bottom: Math.max(0, Math.ceil(layout.height) - lastBaseline),
512
+ };
513
+ }
514
+
515
+ /**
516
+ * The layout as it is on screen: the shaped paragraph, and where its box
517
+ * sits in the window. One place, because painting and every geometry
518
+ * question have to agree about it down to the trim — a caret answered from
519
+ * a differently-placed layout is a caret in the wrong place, and nothing
520
+ * about it would look like a bug in this function.
521
+ */
522
+ _placedLayout() {
523
+ const content = this.contentBox();
524
+ const layout = this._layoutFor(this._wrapWidth(content.width || Infinity));
525
+ if (!layout) return null;
526
+ // the box was shortened from the top, so the glyphs come up with it
527
+ const trim = this._trim(layout);
528
+ return {
529
+ layout,
530
+ x: content.x,
531
+ y: content.y + halfLeading(layout) - (trim ? trim.top : 0),
532
+ };
533
+ }
534
+
535
+ /** The paragraph as one string — what the indices below index into. A
536
+ * nested `<text>` is a span of this one, so its characters are in here too,
537
+ * at the position they are written at. */
538
+ textContent() {
539
+ return this.collectSpans([])
540
+ .map((span) => span.text)
541
+ .join('');
542
+ }
543
+
544
+ textIndexAt(x, y) {
545
+ const placed = this._placedLayout();
546
+ if (!placed) return 0;
547
+ return placed.layout.indexAt(x - placed.x, y - placed.y);
548
+ }
549
+
550
+ textCaretRect(index) {
551
+ const placed = this._placedLayout();
552
+ if (!placed) return null;
553
+ const caret = placed.layout.caretPosition(index);
554
+ return {
555
+ x: placed.x + caret.x,
556
+ y: placed.y + caret.y,
557
+ width: 0,
558
+ height: caret.height,
559
+ };
560
+ }
561
+
562
+ textRangeRects(start, end) {
563
+ const placed = this._placedLayout();
564
+ if (!placed) return [];
565
+ return rangeBands(placed.layout, this.textContent(), start, end).map(
566
+ (band) => ({
567
+ x: placed.x + band.x,
568
+ y: placed.y + band.y,
569
+ width: band.width,
570
+ height: band.height,
571
+ }),
572
+ );
573
+ }
574
+
575
+ paintContent(ctx) {
576
+ const placed = this._placedLayout();
577
+ if (!placed) return;
578
+ this._paintSelection(ctx);
579
+ if (this._paintsStrip()) {
580
+ this._paintStrip(ctx, placed);
581
+ return;
582
+ }
583
+ placed.layout.draw(ctx, placed.x, placed.y);
584
+ }
585
+
586
+ /**
587
+ * Whether this paragraph is too small to read, and is painted as a strip
588
+ * where its lines are instead of as glyphs (`_paintStrip`).
589
+ *
590
+ * A zoomed-out view — a minimap, a graph at a tenth of its size, a grid
591
+ * of five thousand cells — is a screen full of labels nobody can read,
592
+ * each of which costs a `CTLineDraw` or a glyph-run composite at exactly
593
+ * the price of a legible one. Below a legible size a label is a smudge
594
+ * of its ink, and a strip of that ink at the coverage of small text is
595
+ * the same smudge for one fill. The size is in logical pixels: what is
596
+ * legible is a physical question, and a 5px label is the same size on a
597
+ * 2x panel as on a 1x monitor. `textStripBelow` on `createRoot` moves
598
+ * the line, and `0` keeps glyphs at every size.
599
+ */
600
+ _paintsStrip() {
601
+ const below = textStripBelowFor(this.app);
602
+ return below > 0 && this.resolvedTextStyle().size / this.scale < below;
603
+ }
604
+
605
+ /**
606
+ * One rectangle per line, over the band the letters sit in — from a
607
+ * little above the x-height down past the baseline — in the ink at a
608
+ * coverage that reads the way small text does: solid ink would be a bar,
609
+ * and a paragraph is mostly white space at any size.
610
+ */
611
+ _paintStrip(ctx, { layout, x, y }) {
612
+ const lines = layout.lines;
613
+ if (!lines?.length) return;
614
+ const style = this.resolvedTextStyle();
615
+ const em = style.size;
616
+ const ink = cssColorStraight(style.color);
617
+ if (!ink) return;
618
+ const rects = [];
619
+ for (const line of lines) {
620
+ if (!(line.width > 0)) continue;
621
+ rects.push(
622
+ x + line.x,
623
+ y + line.baseline - em * STRIP_ABOVE_BASELINE,
624
+ line.width,
625
+ em * (STRIP_ABOVE_BASELINE + STRIP_BELOW_BASELINE),
626
+ );
627
+ }
628
+ if (!rects.length) return;
629
+ ctx.fillStyle = `rgba(${Math.round(ink[0] * 255)}, ${Math.round(ink[1] * 255)}, ${Math.round(ink[2] * 255)}, ${ink[3] * STRIP_COVERAGE})`;
630
+ ctx.fillRects(rects);
631
+ }
632
+
633
+ /** The band under the glyphs, when a document selection reaches this
634
+ * paragraph. Drawn from the same accessors a registered element would use,
635
+ * so the built-in and the custom surface cannot drift apart. */
636
+ _paintSelection(ctx) {
637
+ const range = this._selRange;
638
+ if (!range || range.end <= range.start) return;
639
+ const rects = [];
640
+ for (const r of this.textRangeRects(range.start, range.end)) {
641
+ rects.push(r.x, r.y, r.width, r.height);
642
+ }
643
+ if (!rects.length) return;
644
+ ctx.fillStyle = range.color;
645
+ // one Render.FillRectangles for the whole highlight, however many lines
646
+ // and however many direction changes it took (ntk >= 7.6)
647
+ ctx.fillRects(rects);
648
+ }
649
+ }