react-x11 2.10.2 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -129
- package/package.json +10 -3
- package/src/Reconciler.js +15 -17
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +304 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +20 -3
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +4 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/screencolor.js +62 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/errors.js +46 -0
- package/src/events.js +6 -6
- package/src/foreignnodes.js +3 -2
- package/src/frames.js +2 -2
- package/src/glnodes.js +1 -1
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -0
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +21 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +4 -2
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +334 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +945 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/screencolor.js +212 -38
- package/src/screencolorhooks.js +6 -2
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +11 -1
- package/src/types/nodes.d.ts +33 -5
- package/src/types/screencolor.d.ts +20 -14
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- 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
|
+
}
|