react-x11 2.40.0 → 2.41.1
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/package.json +4 -4
- package/src/cocoa/app.js +19 -8
- package/src/cocoa/keymap.js +69 -12
- package/src/cocoa/panehost.js +92 -19
- package/src/cocoa/paneplayer.js +101 -24
- package/src/cocoa/panewindow.js +325 -44
- package/src/cocoa/window.js +9 -1
- package/src/events.js +12 -0
- package/src/frame/index.js +63 -17
- package/src/nodes/textarea.js +85 -15
- package/src/nodes/textinput.js +14 -3
- package/src/nodes/textkeys.js +322 -0
- package/src/offscreen.js +8 -7
- package/src/types/events.d.ts +6 -2
- package/src/win32/fonts.js +635 -47
- package/src/win32/panewindow.js +25 -2
package/src/win32/fonts.js
CHANGED
|
@@ -72,15 +72,77 @@ const GENERIC = new Set([
|
|
|
72
72
|
'system-ui',
|
|
73
73
|
'cursive',
|
|
74
74
|
]);
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
//
|
|
76
|
+
// Two more ways a name reaches a face, both what Chrome does on Windows:
|
|
77
|
+
//
|
|
78
|
+
// - **A face of a family filed under another name.** DirectWrite groups
|
|
79
|
+
// faces by weight, width and slope, so "Arial Black", "Segoe UI Light" and
|
|
80
|
+
// "Franklin Gothic Medium" are faces of "Arial", "Segoe UI" and "Franklin
|
|
81
|
+
// Gothic", and no family by those names exists. The bridge looks such a
|
|
82
|
+
// name up as GDI does (`fontFamilyOf`) and answers the family and the
|
|
83
|
+
// face's weight, width and slope, which then stand in for the request's
|
|
84
|
+
// weight and width, as Blink has them (`font_cache_skia_win.cc`, the weight
|
|
85
|
+
// and width suffixes): `font-weight: bold` on "Arial Black" is still Arial
|
|
86
|
+
// Black. A slope is kept from either.
|
|
87
|
+
// - **Blink's alternate names** (`AlternateFamilyName`, font_cache.cc):
|
|
88
|
+
// Courier and Courier New, Times and Times New Roman, Arial and Helvetica,
|
|
89
|
+
// each tried for the other where it is missing. Windows has a Courier only
|
|
90
|
+
// as a bitmap font DirectWrite does not read, and no Times or Helvetica,
|
|
91
|
+
// and these three head a good many stacks on the web.
|
|
92
|
+
const ALTERNATES = new Map([
|
|
93
|
+
['courier', 'Courier New'],
|
|
94
|
+
['courier new', 'Courier'],
|
|
95
|
+
['times', 'Times New Roman'],
|
|
96
|
+
['times new roman', 'Times'],
|
|
97
|
+
['arial', 'Helvetica'],
|
|
98
|
+
['helvetica', 'Arial'],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A stack resolved: `{ family, face }`, the family the bridge is asked for
|
|
103
|
+
* and, where the name reached a face of a family by another name, `face`:
|
|
104
|
+
* its `weight`, `stretch` and `italic`. `face` is null otherwise.
|
|
105
|
+
*/
|
|
106
|
+
function resolveStack(native, stack) {
|
|
107
|
+
if (!stack) return { family: 'sans-serif', face: null };
|
|
77
108
|
for (const raw of String(stack).split(',')) {
|
|
78
109
|
const name = raw.trim().replace(/^["']|["']$/g, '');
|
|
79
110
|
if (!name) continue;
|
|
80
|
-
if (GENERIC.has(name)) return name;
|
|
81
|
-
if (native.fontExists(name)) return name;
|
|
111
|
+
if (GENERIC.has(name)) return { family: name, face: null };
|
|
112
|
+
if (native.fontExists(name)) return { family: name, face: null };
|
|
113
|
+
const face = native.fontFamilyOf?.(name);
|
|
114
|
+
if (face?.family) {
|
|
115
|
+
return {
|
|
116
|
+
family: face.family,
|
|
117
|
+
face: {
|
|
118
|
+
weight: weightOf(face.weight),
|
|
119
|
+
stretch: face.stretch,
|
|
120
|
+
italic: face.italic === true,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const alternate = ALTERNATES.get(name.toLowerCase());
|
|
125
|
+
if (alternate && native.fontExists(alternate)) {
|
|
126
|
+
return { family: alternate, face: null };
|
|
127
|
+
}
|
|
82
128
|
}
|
|
83
|
-
return 'sans-serif';
|
|
129
|
+
return { family: 'sans-serif', face: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a span or a base asks for, with its stack resolved: the family,
|
|
133
|
+
* weight, width and slope the bridge is handed. */
|
|
134
|
+
function requestOf(resolved, weight, style) {
|
|
135
|
+
const italic = style === 'italic' || style === 'oblique';
|
|
136
|
+
const { family, face } = resolved;
|
|
137
|
+
if (!face) {
|
|
138
|
+
return { family, weight: weightOf(weight ?? 400), stretch: 5, italic };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
family,
|
|
142
|
+
weight: face.weight,
|
|
143
|
+
stretch: face.stretch,
|
|
144
|
+
italic: italic || face.italic,
|
|
145
|
+
};
|
|
84
146
|
}
|
|
85
147
|
|
|
86
148
|
function colorOf(value) {
|
|
@@ -93,6 +155,112 @@ function colorOf(value) {
|
|
|
93
155
|
}
|
|
94
156
|
}
|
|
95
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A span's letter spacing and OpenType features, as the bridge takes them:
|
|
160
|
+
* `letterSpacing` in pixels after each character, `features` by tag. Letter
|
|
161
|
+
* spacing turns the optional ligatures off, as ntk and the Cocoa engine have
|
|
162
|
+
* it, so the three agree on what a spaced word is made of. Left out, a
|
|
163
|
+
* `letter-spacing` was set tight on Windows, and <Html>, which justifies a
|
|
164
|
+
* line through letter spacing where an engine cannot, laid a justified
|
|
165
|
+
* line's words over each other.
|
|
166
|
+
*/
|
|
167
|
+
const OPTIONAL_LIGATURES_OFF = Object.freeze({
|
|
168
|
+
liga: 0,
|
|
169
|
+
clig: 0,
|
|
170
|
+
dlig: 0,
|
|
171
|
+
hlig: 0,
|
|
172
|
+
});
|
|
173
|
+
function spacingOf(span, base) {
|
|
174
|
+
const raw = span.letterSpacing ?? base.letterSpacing;
|
|
175
|
+
const spacing = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
|
176
|
+
const own = span.features ?? base.features;
|
|
177
|
+
const features = spacing
|
|
178
|
+
? own
|
|
179
|
+
? { ...OPTIONAL_LIGATURES_OFF, ...own }
|
|
180
|
+
: OPTIONAL_LIGATURES_OFF
|
|
181
|
+
: own;
|
|
182
|
+
return {
|
|
183
|
+
...(spacing ? { letterSpacing: spacing } : {}),
|
|
184
|
+
...(features && typeof features === 'object' ? { features } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What an eliding paragraph ends in. */
|
|
189
|
+
const ELLIPSIS = '…';
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The spans' text over `[from, to)` in code units, each span cut where the
|
|
193
|
+
* range cuts it and a span the range takes whole kept as the caller's own
|
|
194
|
+
* object; `mark` goes on the end of the last, in its style.
|
|
195
|
+
*/
|
|
196
|
+
function spansBetween(spans, from, to, mark = '') {
|
|
197
|
+
const out = [];
|
|
198
|
+
let at = 0;
|
|
199
|
+
for (const span of spans) {
|
|
200
|
+
const text = String(span.text);
|
|
201
|
+
const start = at;
|
|
202
|
+
at += text.length;
|
|
203
|
+
if (at <= from) continue;
|
|
204
|
+
if (start >= to) break;
|
|
205
|
+
const piece = text.slice(Math.max(0, from - start), to - start);
|
|
206
|
+
if (piece) out.push(piece === text ? span : { ...span, text: piece });
|
|
207
|
+
}
|
|
208
|
+
if (mark) {
|
|
209
|
+
if (out.length) {
|
|
210
|
+
const last = out[out.length - 1];
|
|
211
|
+
out[out.length - 1] = { ...last, text: String(last.text) + mark };
|
|
212
|
+
} else if (spans.length) {
|
|
213
|
+
out.push({ ...spans[0], text: mark });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A line's width without the white space it ends on, from where the layout
|
|
221
|
+
* puts the caret before that white space. Left to right only: on a line a
|
|
222
|
+
* right-to-left run is on, where the space goes is the bidi algorithm's,
|
|
223
|
+
* and the line keeps the bridge's width.
|
|
224
|
+
*/
|
|
225
|
+
function inkWidth(native, handle, text, line) {
|
|
226
|
+
const end = trimmedEnd(text, line.start, line.end);
|
|
227
|
+
if (end === line.end) return line.width;
|
|
228
|
+
if (end === line.start) return 0;
|
|
229
|
+
if ((line.runs ?? []).some((run) => run.rtl)) return line.width;
|
|
230
|
+
const caret = native.layoutCaret(handle, end);
|
|
231
|
+
if (!caret || !Number.isFinite(caret.x)) return line.width;
|
|
232
|
+
return Math.max(0, Math.min(line.width, caret.x - line.x));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The spans up to code unit `to`, with `mark` after them. */
|
|
236
|
+
function spansTo(spans, to, mark = '') {
|
|
237
|
+
return spansBetween(spans, 0, to, mark);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The white space a line may end on and leaves out of its width: the
|
|
241
|
+
* spaces a line breaks at, and the line separator <Html> asks for a break
|
|
242
|
+
* with — not a no-break space, which is part of the line as a letter is. */
|
|
243
|
+
const BREAKING_SPACE =
|
|
244
|
+
/[\t\n\v\f\r \u1680\u2000-\u200a\u2028\u2029\u205f\u3000]/;
|
|
245
|
+
|
|
246
|
+
/** `end` moved back over the white space before it, not past `start`. */
|
|
247
|
+
function trimmedEnd(text, start, end) {
|
|
248
|
+
let at = end;
|
|
249
|
+
while (at > start && BREAKING_SPACE.test(text[at - 1])) at--;
|
|
250
|
+
return at;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A code-unit offset not inside a surrogate pair: `at`, or one before. */
|
|
254
|
+
function pointAtOrBefore(text, at) {
|
|
255
|
+
const unit = text.charCodeAt(at - 1);
|
|
256
|
+
return unit >= 0xd800 && unit <= 0xdbff ? at - 1 : at;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The code-unit offset one code point before `at`. */
|
|
260
|
+
function previousPoint(text, at) {
|
|
261
|
+
return Math.max(0, pointAtOrBefore(text, at - 1));
|
|
262
|
+
}
|
|
263
|
+
|
|
96
264
|
/** Code-unit offset of each code point, so the two index spaces can be
|
|
97
265
|
* converted without walking the string twice per query. */
|
|
98
266
|
function codeUnitOffsets(text) {
|
|
@@ -119,6 +287,107 @@ class Win32TextLayout {
|
|
|
119
287
|
// rather than from inside the layout — so a <text> whose colour is a
|
|
120
288
|
// gradient reaches drawLayoutGradient.
|
|
121
289
|
this._contextInk = true;
|
|
290
|
+
// ntk's: whether a `maxLines` cap dropped any of the text
|
|
291
|
+
this.truncated = false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Hang each run off the span it was laid out from, as ntk's runs hang:
|
|
296
|
+
* `span` is the caller's own object, markers and all — what a painter
|
|
297
|
+
* reads a link's underline or a code chip's background off — and `run`
|
|
298
|
+
* the face and size it was set in, and its direction. The bridge makes a
|
|
299
|
+
* run of each stretch of one direction, a link beside plain text in
|
|
300
|
+
* one, so a run is cut where two spans meet, at the offset the layout
|
|
301
|
+
* puts that index at. Without it a document drew no underline, chip or
|
|
302
|
+
* highlight on Windows and could find no link under the pointer.
|
|
303
|
+
*
|
|
304
|
+
* `laid` is what was laid out — `sources` cut at a cap, an ellipsis on
|
|
305
|
+
* the last — span for span, so the offsets are its and the objects the
|
|
306
|
+
* caller's.
|
|
307
|
+
*/
|
|
308
|
+
_hangRuns(sources, laid, runOf) {
|
|
309
|
+
const starts = [];
|
|
310
|
+
let at = 0;
|
|
311
|
+
for (const span of laid) {
|
|
312
|
+
starts.push(at);
|
|
313
|
+
at += String(span.text).length;
|
|
314
|
+
}
|
|
315
|
+
for (const line of this.lines) {
|
|
316
|
+
const runs = [];
|
|
317
|
+
for (const r of line.runs) {
|
|
318
|
+
this._cutRun(
|
|
319
|
+
r,
|
|
320
|
+
line,
|
|
321
|
+
starts,
|
|
322
|
+
(i, rtl) => {
|
|
323
|
+
const span = sources[i];
|
|
324
|
+
return { span, run: runOf(span, rtl) };
|
|
325
|
+
},
|
|
326
|
+
runs,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
line.runs = runs;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_cutRun(r, line, starts, hangOf, out) {
|
|
334
|
+
if (!starts.length) {
|
|
335
|
+
out.push(r);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// the span holding the run's first unit
|
|
339
|
+
let lo = 0;
|
|
340
|
+
let hi = starts.length - 1;
|
|
341
|
+
while (lo < hi) {
|
|
342
|
+
const mid = (lo + hi + 1) >> 1;
|
|
343
|
+
if (starts[mid] <= r.start) lo = mid;
|
|
344
|
+
else hi = mid - 1;
|
|
345
|
+
}
|
|
346
|
+
const hang = (run, source) => {
|
|
347
|
+
const { span, run: set } = hangOf(source, run.rtl);
|
|
348
|
+
run.span = span;
|
|
349
|
+
run.run = set;
|
|
350
|
+
return run;
|
|
351
|
+
};
|
|
352
|
+
let next = lo + 1;
|
|
353
|
+
if (next >= starts.length || starts[next] >= r.end) {
|
|
354
|
+
out.push(hang(r, lo));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// where each span that starts inside the run begins, from the line's
|
|
358
|
+
// origin, as the run's own `x` is measured
|
|
359
|
+
const edges = [r.start];
|
|
360
|
+
const at = [r.rtl ? r.x + r.width : r.x];
|
|
361
|
+
const sources = [lo];
|
|
362
|
+
for (; next < starts.length && starts[next] < r.end; next += 1) {
|
|
363
|
+
edges.push(starts[next]);
|
|
364
|
+
const caret = this._native.layoutCaret(this._handle, starts[next]);
|
|
365
|
+
at.push((caret?.x ?? 0) - line.x);
|
|
366
|
+
sources.push(next);
|
|
367
|
+
}
|
|
368
|
+
edges.push(r.end);
|
|
369
|
+
at.push(r.rtl ? r.x : r.x + r.width);
|
|
370
|
+
const pieces = [];
|
|
371
|
+
for (let i = 0; i < sources.length; i += 1) {
|
|
372
|
+
const a = at[i];
|
|
373
|
+
const b = at[i + 1];
|
|
374
|
+
pieces.push(
|
|
375
|
+
hang(
|
|
376
|
+
{
|
|
377
|
+
...r,
|
|
378
|
+
start: edges[i],
|
|
379
|
+
end: edges[i + 1],
|
|
380
|
+
x: Math.min(a, b),
|
|
381
|
+
width: Math.abs(b - a),
|
|
382
|
+
},
|
|
383
|
+
sources[i],
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// a line's runs go left to right, and a right-to-left run's first span
|
|
388
|
+
// is its rightmost piece
|
|
389
|
+
if (r.rtl) pieces.reverse();
|
|
390
|
+
out.push(...pieces);
|
|
122
391
|
}
|
|
123
392
|
|
|
124
393
|
get width() {
|
|
@@ -138,20 +407,68 @@ class Win32TextLayout {
|
|
|
138
407
|
* where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
|
|
139
408
|
* opportunities itself. */
|
|
140
409
|
get minWidth() {
|
|
410
|
+
// asked of the bridge the first time, where it left it out of the
|
|
411
|
+
// metrics: the line breaker over the whole text again, which a layout
|
|
412
|
+
// made to be drawn is never asked for
|
|
413
|
+
if (this._metrics.minWidth === undefined) {
|
|
414
|
+
this._metrics.minWidth =
|
|
415
|
+
this._handle != null && this._native.layoutMinWidth
|
|
416
|
+
? (this._native.layoutMinWidth(this._handle) ?? 0)
|
|
417
|
+
: 0;
|
|
418
|
+
}
|
|
141
419
|
return this._metrics.minWidth;
|
|
142
420
|
}
|
|
143
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Keep the first `cap` lines of what was laid out: they are all this
|
|
424
|
+
* layout reports, answers for and draws (`_keptEnd`, `_keptHeight`). A
|
|
425
|
+
* capped paragraph is laid out past its cap to find where the cap falls,
|
|
426
|
+
* and keeping that layout's first lines is the same picture as laying the
|
|
427
|
+
* kept text out again, for one native layout where it took two.
|
|
428
|
+
*/
|
|
429
|
+
_keep(cap) {
|
|
430
|
+
const lines = this._metrics.lines.slice(0, cap);
|
|
431
|
+
const last = lines[lines.length - 1];
|
|
432
|
+
if (!last) return;
|
|
433
|
+
let width = 0;
|
|
434
|
+
for (const line of lines)
|
|
435
|
+
width = Math.max(width, Math.ceil(line.width - 1e-3));
|
|
436
|
+
this._metrics = {
|
|
437
|
+
...this._metrics,
|
|
438
|
+
lines,
|
|
439
|
+
width,
|
|
440
|
+
height: Math.ceil(last.y + last.height),
|
|
441
|
+
minWidth: undefined,
|
|
442
|
+
};
|
|
443
|
+
this._keptEnd = last.end;
|
|
444
|
+
this._keptHeight = last.y + last.height;
|
|
445
|
+
}
|
|
446
|
+
|
|
144
447
|
draw(ctx, x, y) {
|
|
448
|
+
if (this._keptHeight === undefined) {
|
|
449
|
+
ctx._drawLayout(this, x, y);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// the lines past the cap are in the layout, and stay out of the picture
|
|
453
|
+
ctx.save();
|
|
454
|
+
ctx.beginPath();
|
|
455
|
+
ctx.rect(x - 1e4, y, 2e4 + this._metrics.width, this._keptHeight);
|
|
456
|
+
ctx.clip();
|
|
145
457
|
ctx._drawLayout(this, x, y);
|
|
458
|
+
ctx.restore();
|
|
146
459
|
}
|
|
147
460
|
|
|
148
461
|
indexAt(x, y) {
|
|
149
462
|
const unit = this._native.layoutIndexAt(this._handle, x, y);
|
|
150
|
-
return this._cpOf(unit);
|
|
463
|
+
return this._cpOf(Math.min(unit, this._keptEnd ?? unit));
|
|
151
464
|
}
|
|
152
465
|
|
|
153
466
|
caretPosition(cp) {
|
|
154
|
-
|
|
467
|
+
const unit = this._cuOf(cp);
|
|
468
|
+
return this._native.layoutCaret(
|
|
469
|
+
this._handle,
|
|
470
|
+
Math.min(unit, this._keptEnd ?? unit),
|
|
471
|
+
);
|
|
155
472
|
}
|
|
156
473
|
|
|
157
474
|
/**
|
|
@@ -198,12 +515,19 @@ class Win32TextLayout {
|
|
|
198
515
|
/** A face, as far as a renderer that positions glyphs itself reads one.
|
|
199
516
|
* Size is deferred the way ntk's is. */
|
|
200
517
|
class Win32Face {
|
|
201
|
-
constructor(
|
|
518
|
+
constructor(
|
|
519
|
+
manager,
|
|
520
|
+
family,
|
|
521
|
+
{ weight = 400, style = 'normal', stretch = 5 } = {},
|
|
522
|
+
) {
|
|
202
523
|
this._manager = manager;
|
|
203
524
|
this.family = family;
|
|
204
525
|
this.weight = weightOf(weight);
|
|
205
526
|
this.style = style;
|
|
206
527
|
this.italic = style === 'italic' || style === 'oblique';
|
|
528
|
+
// DirectWrite's width, 1–9 with 5 normal: a face such as Arial Narrow is
|
|
529
|
+
// reached through it (`resolveStack`)
|
|
530
|
+
this.stretch = stretch;
|
|
207
531
|
}
|
|
208
532
|
|
|
209
533
|
/**
|
|
@@ -221,7 +545,15 @@ class Win32Face {
|
|
|
221
545
|
size,
|
|
222
546
|
this.weight,
|
|
223
547
|
this.italic,
|
|
548
|
+
this.stretch,
|
|
224
549
|
);
|
|
550
|
+
// ntk's `lineHeight` beside DirectWrite's names, as the Cocoa engine
|
|
551
|
+
// gives CoreText's, so a caller written against ntk reads a finite
|
|
552
|
+
// line height: <Html>'s `line-height: normal` read `NaN` here, and a
|
|
553
|
+
// `NaN` in a block's height culled the whole document from the paint.
|
|
554
|
+
if (m && typeof m === 'object' && !Number.isFinite(m.lineHeight)) {
|
|
555
|
+
m = { ...m, lineHeight: m.ascent + m.descent + (m.lineGap || 0) };
|
|
556
|
+
}
|
|
225
557
|
this._metrics.set(size, m);
|
|
226
558
|
}
|
|
227
559
|
return m;
|
|
@@ -283,6 +615,20 @@ export class Win32FontManager {
|
|
|
283
615
|
// family|weight|italic|size -> the bridge's handle, or null when there is
|
|
284
616
|
// no such face
|
|
285
617
|
this._handles = new Map();
|
|
618
|
+
// what this bridge's layouts take, asked once (`textFeatures`, from
|
|
619
|
+
// @windowkit/win32 0.0.9): an older one answers nothing and takes neither
|
|
620
|
+
this._features = native.textFeatures?.() ?? {};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Which of ntk's `justify` modes `layout` takes. DirectWrite justifies
|
|
625
|
+
* every line but a paragraph's last, which is `justify: true`, and has no
|
|
626
|
+
* way to justify a last line: `'rest'` says so, and a caller justifies the
|
|
627
|
+
* other two itself. <Html>, which spaced every justified line through
|
|
628
|
+
* letter spacing on this engine, lays a justified paragraph out once.
|
|
629
|
+
*/
|
|
630
|
+
get justifies() {
|
|
631
|
+
return this._features.justify === true ? 'rest' : false;
|
|
286
632
|
}
|
|
287
633
|
|
|
288
634
|
/**
|
|
@@ -294,12 +640,18 @@ export class Win32FontManager {
|
|
|
294
640
|
*/
|
|
295
641
|
_handleFor(face, size) {
|
|
296
642
|
const px = Math.max(1, Math.round((size ?? 0) * 64)) / 64;
|
|
297
|
-
const
|
|
643
|
+
const stretch = face.stretch ?? 5;
|
|
644
|
+
const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${stretch}|${px}`;
|
|
298
645
|
let handle = this._handles.get(key);
|
|
299
646
|
if (handle === undefined) {
|
|
300
647
|
handle =
|
|
301
|
-
this._native.fontHandle(
|
|
302
|
-
|
|
648
|
+
this._native.fontHandle(
|
|
649
|
+
face.family,
|
|
650
|
+
px,
|
|
651
|
+
face.weight,
|
|
652
|
+
face.italic,
|
|
653
|
+
stretch,
|
|
654
|
+
) || null;
|
|
303
655
|
this._handles.set(key, handle);
|
|
304
656
|
}
|
|
305
657
|
return handle;
|
|
@@ -370,9 +722,10 @@ export class Win32FontManager {
|
|
|
370
722
|
}
|
|
371
723
|
// Faces matched before this one existed were matched against a smaller
|
|
372
724
|
// set of fonts, and one of them may have fallen back to what this
|
|
373
|
-
// replaces.
|
|
725
|
+
// replaces — a stack resolved past its name among them.
|
|
374
726
|
this._faces.clear();
|
|
375
727
|
this._handles.clear();
|
|
728
|
+
this._stacks?.clear();
|
|
376
729
|
return null;
|
|
377
730
|
}
|
|
378
731
|
|
|
@@ -429,10 +782,185 @@ export class Win32FontManager {
|
|
|
429
782
|
* layout rather than one per chunk — and therefore what makes line breaking
|
|
430
783
|
* work *across* them, which is the whole reason the contract takes spans
|
|
431
784
|
* rather than a string.
|
|
785
|
+
*
|
|
786
|
+
* The cap and the ellipsis are ntk's and are worked out here, since the
|
|
787
|
+
* bridge has neither: the paragraph is broken at the width, the lines past
|
|
788
|
+
* `maxLines` are dropped (`truncated` says whether any were), and an
|
|
789
|
+
* eliding one ends its last line in `…`, as much of the line kept as fits
|
|
790
|
+
* beside it. The bridge's own `maxLines` is never asked: it reads 1 as "do
|
|
791
|
+
* not wrap", so a one-line `<text>` came back as the whole paragraph on
|
|
792
|
+
* one line, as wide as all of it and with no ellipsis, and every paragraph
|
|
793
|
+
* a caller laid out a line at a time through it overran its box.
|
|
432
794
|
*/
|
|
433
795
|
layout(spans, base = {}, options = {}) {
|
|
434
796
|
const list =
|
|
435
797
|
typeof spans === 'string' ? [{ text: spans, ...base }] : (spans ?? []);
|
|
798
|
+
// the caller's spans with text, which the runs hang off (`_hangRuns`)
|
|
799
|
+
const sources = list.filter((span) => String(span?.text ?? ''));
|
|
800
|
+
const { maxWidth, maxLines, overflow } = options;
|
|
801
|
+
const cap = Number.isFinite(maxLines)
|
|
802
|
+
? Math.max(1, Math.floor(maxLines))
|
|
803
|
+
: Infinity;
|
|
804
|
+
const elides = overflow === 'ellipsis' && cap < Infinity;
|
|
805
|
+
// A width offer of zero is yoga's min-content question (nodes/window/
|
|
806
|
+
// floors.js), which the bridge answered with a layout built unbounded —
|
|
807
|
+
// the whole paragraph on one line — so no `<text>` in a flex row could
|
|
808
|
+
// give way below its full length. Its `minWidth` is DirectWrite's own
|
|
809
|
+
// answer (`DetermineMinWidth`), and the paragraph laid out at it is the
|
|
810
|
+
// floor. An eliding line's floor is the mark alone, as the Cocoa engine
|
|
811
|
+
// has it (react-x11#512): it gives way, and says so with `…`.
|
|
812
|
+
if (Number.isFinite(maxWidth) && maxWidth <= 0) {
|
|
813
|
+
const open = { ...options, maxWidth: undefined };
|
|
814
|
+
if (elides && cap === 1 && sources.length) {
|
|
815
|
+
return this._laidOut([{ ...sources[0], text: ELLIPSIS }], base, open);
|
|
816
|
+
}
|
|
817
|
+
const wide = this._laidOut(sources, base, open);
|
|
818
|
+
const least = wide.minWidth;
|
|
819
|
+
if (!(least > 0) || least >= wide.width) return wide;
|
|
820
|
+
wide.destroy();
|
|
821
|
+
return this._laidOut(sources, base, { ...options, maxWidth: least });
|
|
822
|
+
}
|
|
823
|
+
let laid = sources;
|
|
824
|
+
let layout =
|
|
825
|
+
this._prefixLaidOut(sources, base, options, cap) ??
|
|
826
|
+
this._laidOut(laid, base, options);
|
|
827
|
+
// With no width the bridge lays a paragraph out in a box a million
|
|
828
|
+
// pixels wide, and aligns it there: a centred line's `x` was half a
|
|
829
|
+
// million, an RTL one's a million, and <Html> measured a centred
|
|
830
|
+
// heading in a shrink-to-fit box as wide as all the room it had. ntk
|
|
831
|
+
// aligns the lines of an unbounded paragraph within its widest, and so
|
|
832
|
+
// does a paragraph laid out again at that width.
|
|
833
|
+
if (
|
|
834
|
+
!(maxWidth > 0 && Number.isFinite(maxWidth)) &&
|
|
835
|
+
layout.width > 0 &&
|
|
836
|
+
layout.lines.some((line) => line.x > 0.5)
|
|
837
|
+
) {
|
|
838
|
+
const widest = layout.width;
|
|
839
|
+
layout.destroy();
|
|
840
|
+
layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
|
|
841
|
+
}
|
|
842
|
+
layout.truncated = layout.lines.length > cap;
|
|
843
|
+
if (layout.truncated && !elides) {
|
|
844
|
+
// what was laid out, a prefix or the whole, keeps its first lines
|
|
845
|
+
laid = spansTo(sources, layout._text.length);
|
|
846
|
+
layout._keep(cap);
|
|
847
|
+
} else if (layout.truncated) {
|
|
848
|
+
const last = layout.lines[cap - 1];
|
|
849
|
+
const text = layout._text;
|
|
850
|
+
layout.destroy();
|
|
851
|
+
let end = last.end;
|
|
852
|
+
if (elides) {
|
|
853
|
+
end = this._ellipsisAt(sources, base, options, text, last.start, end);
|
|
854
|
+
}
|
|
855
|
+
laid = spansTo(sources, end, elides ? ELLIPSIS : '');
|
|
856
|
+
layout = this._laidOut(laid, base, options);
|
|
857
|
+
// where the mark wrapped after all — a width rounded the other way
|
|
858
|
+
// than the probe's — the line gives up a character more, a few times
|
|
859
|
+
for (let i = 0; elides && layout.lines.length > cap && i < 4; i++) {
|
|
860
|
+
end = trimmedEnd(text, last.start, previousPoint(text, end));
|
|
861
|
+
laid = spansTo(sources, end, ELLIPSIS);
|
|
862
|
+
layout.destroy();
|
|
863
|
+
layout = this._laidOut(laid, base, options);
|
|
864
|
+
}
|
|
865
|
+
layout.truncated = true;
|
|
866
|
+
}
|
|
867
|
+
layout._hangRuns(sources, laid, this._runOf(base));
|
|
868
|
+
return layout;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* A capped paragraph laid out only as far as the cap needs: a prefix of
|
|
873
|
+
* its text that breaks into more lines than `cap`, or null where the
|
|
874
|
+
* whole text is no longer than a prefix would be. The first lines of a
|
|
875
|
+
* prefix are the whole text's first lines wherever it reaches past them —
|
|
876
|
+
* a line's break waits only on the word after it, which then starts a
|
|
877
|
+
* line of the prefix — and the prefix ends at a break, its last word
|
|
878
|
+
* whole. <Html> lays a paragraph beside a float out a line at a time,
|
|
879
|
+
* each `maxLines: 1` over the rest of the paragraph; laid out whole, each
|
|
880
|
+
* line cost the whole rest of it, twice, and a paragraph cost the square
|
|
881
|
+
* of its length.
|
|
882
|
+
*/
|
|
883
|
+
_prefixLaidOut(sources, base, options, cap) {
|
|
884
|
+
const { maxWidth } = options;
|
|
885
|
+
if (!(cap < Infinity) || !(maxWidth > 0) || !Number.isFinite(maxWidth)) {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const text = sources.map((span) => String(span.text)).join('');
|
|
889
|
+
let size = Infinity;
|
|
890
|
+
for (const span of sources)
|
|
891
|
+
size = Math.min(size, sizeOf(span.size ?? base.size));
|
|
892
|
+
// a third of an em a character is narrower than nearly any text is set
|
|
893
|
+
// in, so a prefix that long nearly always reaches past the lines wanted;
|
|
894
|
+
// where it does not, one twice as long is tried
|
|
895
|
+
let take = Math.ceil(maxWidth / (Math.max(1, size) * 0.3)) * cap + 16;
|
|
896
|
+
while (take < text.length) {
|
|
897
|
+
let end = take;
|
|
898
|
+
while (end < text.length && !BREAKING_SPACE.test(text[end])) end++;
|
|
899
|
+
if (end >= text.length) return null;
|
|
900
|
+
const layout = this._laidOut(spansTo(sources, end), base, options);
|
|
901
|
+
if (layout.lines.length > cap) return layout;
|
|
902
|
+
layout.destroy();
|
|
903
|
+
take *= 2;
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Where the last line kept of an eliding paragraph ends: as far into
|
|
910
|
+
* `[start, end)` as fits beside the mark in `maxWidth`, white space before
|
|
911
|
+
* the mark left out. Measured as that line alone, unwrapped.
|
|
912
|
+
*/
|
|
913
|
+
_ellipsisAt(sources, base, options, text, start, end) {
|
|
914
|
+
const room = options.maxWidth;
|
|
915
|
+
if (!(room > 0) || !Number.isFinite(room)) return end;
|
|
916
|
+
const open = { ...options, maxWidth: undefined, align: undefined };
|
|
917
|
+
const fits = (at) => {
|
|
918
|
+
const probe = this._laidOut(
|
|
919
|
+
spansBetween(sources, start, trimmedEnd(text, start, at), ELLIPSIS),
|
|
920
|
+
base,
|
|
921
|
+
open,
|
|
922
|
+
);
|
|
923
|
+
const width = probe.width;
|
|
924
|
+
probe.destroy();
|
|
925
|
+
return width <= room;
|
|
926
|
+
};
|
|
927
|
+
if (fits(end)) return trimmedEnd(text, start, end);
|
|
928
|
+
let lo = start;
|
|
929
|
+
let hi = end;
|
|
930
|
+
while (hi - lo > 1) {
|
|
931
|
+
const mid = pointAtOrBefore(text, (lo + hi) >> 1);
|
|
932
|
+
if (mid <= lo) break;
|
|
933
|
+
if (fits(mid)) lo = mid;
|
|
934
|
+
else hi = mid;
|
|
935
|
+
}
|
|
936
|
+
return trimmedEnd(text, start, lo);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** What a run is set in — its face, size and direction, ntk's `run` — by
|
|
940
|
+
* the span it was laid out from, made once a span and direction. */
|
|
941
|
+
_runOf(base) {
|
|
942
|
+
const made = new Map();
|
|
943
|
+
return (span, rtl) => {
|
|
944
|
+
let byDirection = made.get(span);
|
|
945
|
+
if (!byDirection) {
|
|
946
|
+
byDirection = [null, null];
|
|
947
|
+
made.set(span, byDirection);
|
|
948
|
+
}
|
|
949
|
+
const at = rtl ? 1 : 0;
|
|
950
|
+
byDirection[at] ??= {
|
|
951
|
+
font: this.match(span.family ?? base.family, {
|
|
952
|
+
weight: span.weight ?? base.weight,
|
|
953
|
+
style: span.style ?? base.style,
|
|
954
|
+
}),
|
|
955
|
+
size: sizeOf(span.size ?? base.size),
|
|
956
|
+
direction: rtl ? 'rtl' : 'ltr',
|
|
957
|
+
};
|
|
958
|
+
return byDirection[at];
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** The bridge's layout of `list`, as it lays it out: no cap, no mark. */
|
|
963
|
+
_laidOut(list, base, options) {
|
|
436
964
|
let text = '';
|
|
437
965
|
const ranges = [];
|
|
438
966
|
for (const span of list) {
|
|
@@ -441,41 +969,55 @@ export class Win32FontManager {
|
|
|
441
969
|
const start = text.length;
|
|
442
970
|
text += piece;
|
|
443
971
|
const ink = colorOf(span.color ?? base.color);
|
|
972
|
+
const set = requestOf(
|
|
973
|
+
this._stack(span.family ?? base.family),
|
|
974
|
+
span.weight ?? base.weight,
|
|
975
|
+
span.style ?? base.style,
|
|
976
|
+
);
|
|
444
977
|
ranges.push({
|
|
445
978
|
start,
|
|
446
979
|
length: piece.length,
|
|
447
|
-
family:
|
|
980
|
+
family: set.family,
|
|
448
981
|
size: sizeOf(span.size ?? base.size),
|
|
449
|
-
weight:
|
|
450
|
-
italic:
|
|
982
|
+
weight: set.weight,
|
|
983
|
+
italic: set.italic,
|
|
984
|
+
stretch: set.stretch,
|
|
451
985
|
// A variable face's axes. Passed per span as well as on the base,
|
|
452
986
|
// because a span carries its own and a paragraph is one layout.
|
|
453
987
|
variations: span.variations ?? base.variations ?? undefined,
|
|
988
|
+
...spacingOf(span, base),
|
|
454
989
|
...(ink ? ink : {}),
|
|
455
990
|
});
|
|
456
991
|
}
|
|
457
992
|
|
|
993
|
+
const own = requestOf(this._stack(base.family), base.weight, base.style);
|
|
458
994
|
const handle = this._native.layoutCreate(
|
|
459
995
|
text,
|
|
460
996
|
{
|
|
461
|
-
family:
|
|
997
|
+
family: own.family,
|
|
462
998
|
size: sizeOf(base.size),
|
|
463
|
-
weight:
|
|
464
|
-
italic:
|
|
999
|
+
weight: own.weight,
|
|
1000
|
+
italic: own.italic,
|
|
1001
|
+
stretch: own.stretch,
|
|
465
1002
|
variations: base.variations ?? undefined,
|
|
466
1003
|
maxWidth: options.maxWidth,
|
|
467
1004
|
align: options.align,
|
|
468
1005
|
lineHeight: options.lineHeight,
|
|
469
|
-
maxLines: options.maxLines,
|
|
470
1006
|
rtl: options.direction === 'rtl',
|
|
1007
|
+
// the one mode DirectWrite has (`justifies`); 'last' and 'all' are
|
|
1008
|
+
// the caller's
|
|
1009
|
+
justify: options.justify === true || undefined,
|
|
471
1010
|
},
|
|
472
1011
|
ranges,
|
|
473
1012
|
);
|
|
474
1013
|
|
|
475
|
-
const raw = this._native.layoutMetrics(
|
|
1014
|
+
const raw = this._native.layoutMetrics(
|
|
1015
|
+
handle,
|
|
1016
|
+
this._features.lazyMinWidth ? { minWidth: false } : undefined,
|
|
1017
|
+
);
|
|
476
1018
|
if (DEBUG) {
|
|
477
1019
|
// The family is in here because the one way this goes quietly wrong is
|
|
478
|
-
// `
|
|
1020
|
+
// `resolveStack` not recognising a name and answering `sans-serif`: the
|
|
479
1021
|
// text still draws, in the wrong face, at the wrong metrics, and the
|
|
480
1022
|
// only tell is a width that does not match the face the app thinks it
|
|
481
1023
|
// asked for.
|
|
@@ -486,37 +1028,83 @@ export class Win32FontManager {
|
|
|
486
1028
|
);
|
|
487
1029
|
}
|
|
488
1030
|
|
|
489
|
-
// Every engine answers a descent per line — a field and the
|
|
490
|
-
// measure a line's ink as
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1031
|
+
// Every engine answers an ascent and a descent per line — a field and the
|
|
1032
|
+
// edit menu measure a line's ink as their sum — and the bridge measures
|
|
1033
|
+
// both off the faces on the line. A line it measured nothing on (an
|
|
1034
|
+
// empty one) takes them from its baseline. Worked out from the baseline
|
|
1035
|
+
// always, as they were before the bridge said, a `lineHeight` put the
|
|
1036
|
+
// descent below the line box: the bridge sets that baseline at 0.8 of
|
|
1037
|
+
// the face's, and a line at `lineHeight: 1` read as shorter than its
|
|
1038
|
+
// own strut, so <Html> laid every paragraph out a line at a time.
|
|
1039
|
+
//
|
|
1040
|
+
// And a baseline is ntk's: from the top of the layout. DirectWrite's is
|
|
1041
|
+
// from the top of its line, so every line but the first was read as
|
|
1042
|
+
// though its baseline were on the first — <Html> drew a link's
|
|
1043
|
+
// underline on the line above the link.
|
|
1044
|
+
//
|
|
1045
|
+
// A line's `width` is ntk's too: without the white space it ends on,
|
|
1046
|
+
// which its `advance` keeps. The bridge measures the line's runs, the
|
|
1047
|
+
// space a wrap ended it on among them, so a line that fitted read as
|
|
1048
|
+
// wider than its box: <Html> took each line beside a float for one that
|
|
1049
|
+
// did not fit there and moved it below the float, a line at a time.
|
|
1050
|
+
const lines = (raw.lines ?? []).map((line) => {
|
|
1051
|
+
const measured =
|
|
1052
|
+
line.ascent > 0 && Number.isFinite(line.descent) && line.descent >= 0;
|
|
1053
|
+
return {
|
|
1054
|
+
...line,
|
|
1055
|
+
width: inkWidth(this._native, handle, text, line),
|
|
1056
|
+
advance: line.width,
|
|
1057
|
+
baseline: line.y + line.baseline,
|
|
1058
|
+
ascent: measured ? line.ascent : line.baseline,
|
|
1059
|
+
descent: measured
|
|
1060
|
+
? line.descent
|
|
1061
|
+
: Math.max(0, line.height - line.baseline),
|
|
1062
|
+
// `rangeBands` walks a line's runs to build a selection highlight,
|
|
1063
|
+
// and reads each one's direction off a nested `run` object — ntk's
|
|
1064
|
+
// shape. A line with no runs is not an empty line here, it is a
|
|
1065
|
+
// crash: `line.runs is not iterable`.
|
|
1066
|
+
runs: (line.runs ?? []).map((run) => ({
|
|
1067
|
+
...run,
|
|
1068
|
+
run: { direction: run.rtl ? 'rtl' : 'ltr' },
|
|
1069
|
+
})),
|
|
1070
|
+
};
|
|
1071
|
+
});
|
|
1072
|
+
// DirectWrite's width leaves out a no-break space a line ends on, as it
|
|
1073
|
+
// leaves out a space, where ntk's counts it as the letter it is to a
|
|
1074
|
+
// line: a lone one measured nothing, and <Html>, which spaces an inline
|
|
1075
|
+
// box's edges with one, made every edge a space too wide. The lines'
|
|
1076
|
+
// own widths keep it, in the whole pixels the bridge answers in.
|
|
1077
|
+
let width = raw.width;
|
|
1078
|
+
for (const line of lines) {
|
|
1079
|
+
width = Math.max(width, Math.ceil(line.width - 1e-3));
|
|
1080
|
+
}
|
|
1081
|
+
return new Win32TextLayout(this, handle, text, { ...raw, width, lines });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** A stack resolved once (`resolveStack`): a named family is a
|
|
1085
|
+
* DirectWrite lookup, and every layout asks for every span. */
|
|
1086
|
+
_stack(stack) {
|
|
1087
|
+
this._stacks ??= new Map();
|
|
1088
|
+
const key = stack ?? '';
|
|
1089
|
+
let resolved = this._stacks.get(key);
|
|
1090
|
+
if (resolved === undefined) {
|
|
1091
|
+
resolved = resolveStack(this._native, stack);
|
|
1092
|
+
this._stacks.set(key, resolved);
|
|
1093
|
+
}
|
|
1094
|
+
return resolved;
|
|
505
1095
|
}
|
|
506
1096
|
|
|
507
1097
|
match(family, options = {}) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
let name = this._families.get(family);
|
|
512
|
-
if (name === undefined) {
|
|
513
|
-
name = familyOf(this._native, family);
|
|
514
|
-
this._families.set(family, name);
|
|
515
|
-
}
|
|
516
|
-
const key = `${name}|${weightOf(options.weight)}|${options.style ?? 'normal'}`;
|
|
1098
|
+
const set = requestOf(this._stack(family), options.weight, options.style);
|
|
1099
|
+
const style = set.italic ? 'italic' : 'normal';
|
|
1100
|
+
const key = `${set.family}|${set.weight}|${style}|${set.stretch}`;
|
|
517
1101
|
let face = this._faces.get(key);
|
|
518
1102
|
if (!face) {
|
|
519
|
-
face = new Win32Face(this,
|
|
1103
|
+
face = new Win32Face(this, set.family, {
|
|
1104
|
+
weight: set.weight,
|
|
1105
|
+
style,
|
|
1106
|
+
stretch: set.stretch,
|
|
1107
|
+
});
|
|
520
1108
|
this._faces.set(key, face);
|
|
521
1109
|
}
|
|
522
1110
|
return face;
|