react-x11 2.16.0 → 2.17.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 (62) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/acceleratorhooks.js +40 -6
  6. package/src/anchor.js +20 -2
  7. package/src/appcontext.js +8 -0
  8. package/src/appearance.js +36 -0
  9. package/src/{cocoa → backend}/context2d.js +27 -7
  10. package/src/capabilities.js +99 -1
  11. package/src/cocoa/app.js +204 -6
  12. package/src/cocoa/fonts.js +1 -1
  13. package/src/cocoa/overlay.js +2 -2
  14. package/src/cocoa/panewindow.js +2 -2
  15. package/src/cocoa/presenter.js +2 -2
  16. package/src/cocoa/surface.js +3 -3
  17. package/src/cocoa/window.js +2 -2
  18. package/src/events.js +21 -0
  19. package/src/foreignnodes.js +8 -3
  20. package/src/frame/index.js +30 -4
  21. package/src/glnodes.js +12 -1
  22. package/src/idle.js +59 -1
  23. package/src/index.d.ts +51 -1
  24. package/src/index.js +30 -3
  25. package/src/keysymchars.js +47 -0
  26. package/src/keysyms.d.ts +19 -1
  27. package/src/keysyms.js +107 -8
  28. package/src/launcher.js +17 -8
  29. package/src/launcherhooks.js +24 -10
  30. package/src/node.d.ts +1 -1
  31. package/src/nodes/cascade.js +9 -0
  32. package/src/nodes/node.js +6 -1
  33. package/src/nodes/window/hints.js +21 -2
  34. package/src/nodes/window/window.js +2 -2
  35. package/src/notifications.js +39 -14
  36. package/src/screens.js +159 -24
  37. package/src/taskbarhooks.js +164 -0
  38. package/src/transfer.js +20 -1
  39. package/src/trayhooks.js +1 -1
  40. package/src/types/capabilities.d.ts +32 -3
  41. package/src/types/elements.d.ts +23 -1
  42. package/src/types/events.d.ts +21 -0
  43. package/src/types/filedialog.d.ts +3 -1
  44. package/src/types/launcher.d.ts +20 -6
  45. package/src/types/taskbar.d.ts +79 -0
  46. package/src/wayland/context2d.js +1 -1
  47. package/src/wayland/xkb.js +170 -59
  48. package/src/win32/a11y.js +604 -0
  49. package/src/win32/app.js +768 -0
  50. package/src/win32/bezels.js +158 -0
  51. package/src/win32/dnd.js +283 -0
  52. package/src/win32/fonts.js +497 -0
  53. package/src/win32/glarea.js +548 -0
  54. package/src/win32/ime.js +267 -0
  55. package/src/win32/keymap.js +116 -0
  56. package/src/win32/native.js +54 -0
  57. package/src/win32/panehost.js +106 -0
  58. package/src/win32/panewindow.js +343 -0
  59. package/src/win32/shell.js +426 -0
  60. package/src/win32/surface.js +192 -0
  61. package/src/win32/window.js +659 -0
  62. package/src/windowid.js +128 -20
@@ -0,0 +1,497 @@
1
+ // The Windows text engine: DirectWrite behind the same `app.fonts` contract
2
+ // ntk's FontManager answers on X11 and CoreText answers on macOS. The renderer
3
+ // touches exactly this surface (docs/windows.md §"Text"):
4
+ //
5
+ // fonts.layout(spans, base, { maxWidth, align, lineHeight, maxLines,
6
+ // overflow, direction })
7
+ // -> { width, height, lines, draw(ctx, x, y),
8
+ // indexAt(x, y), caretPosition(cp) }
9
+ // fonts.match(family, { weight, style }) -> face
10
+ // fonts.fallbackFor(codepoint, family, { weight, style }) -> face | null
11
+ //
12
+ // Index spaces, because two meet here, exactly as they do in the Cocoa engine:
13
+ // `lines[].start/end` are UTF-16 code units — what `rangeBands` in
14
+ // nodes/text.js compares against — while `caretPosition()` takes and
15
+ // `indexAt()` returns code points, which is what the selection and the caret
16
+ // speak. DirectWrite is UTF-16 end to end, like CoreText, so the conversion
17
+ // happens at this boundary and nowhere else.
18
+ import { cssColorStraight } from 'ntk';
19
+
20
+ // REACT_X11_WIN32_DEBUG=1 reports the size each paragraph is shaped at, which
21
+ // is how to tell a scale that never reached the text engine from one that did.
22
+ const DEBUG = process.env.REACT_X11_WIN32_DEBUG === '1';
23
+
24
+ // A span's vocabulary is **ntk's TextLayout's, not CSS's**: `family`, `size`,
25
+ // `weight`, `style` — not `fontFamily`, `fontSize` and the rest. That is what
26
+ // `collectSpans` and `resolvedTextStyle` in nodes/text.js produce, and what
27
+ // the CoreText engine reads.
28
+ //
29
+ // Getting it wrong is silent and looks like something else entirely: every
30
+ // paragraph falls through to this default, so the whole app renders at one
31
+ // size whatever it asked for, and on a 150% display it reads as "the scale
32
+ // never reached the text engine" when the scale was never the problem. 14 is
33
+ // the same floor the Cocoa engine uses.
34
+ const DEFAULT_SIZE = 14;
35
+
36
+ // The size a coverage question is asked at. A cmap does not depend on one, so
37
+ // every `hasGlyph`/`glyphIdFor` shares a single handle per face rather than
38
+ // resolving one per size the caller happens to be drawing at.
39
+ const PROBE_SIZE = 16;
40
+
41
+ /** The CSS generics, which name no face on their own. */
42
+ const GENERIC_FAMILIES = new Set([
43
+ 'sans-serif',
44
+ 'serif',
45
+ 'monospace',
46
+ 'cursive',
47
+ 'system-ui',
48
+ 'ui-sans-serif',
49
+ 'ui-monospace',
50
+ ]);
51
+ function sizeOf(value) {
52
+ return typeof value === 'number' && value > 0 ? value : DEFAULT_SIZE;
53
+ }
54
+
55
+ // DirectWrite takes a numeric weight; CSS lets a style say the word.
56
+ function weightOf(value) {
57
+ if (typeof value === 'number') return value;
58
+ if (value === 'bold') return 700;
59
+ if (value === 'bolder') return 800;
60
+ if (value === 'lighter') return 300;
61
+ const parsed = Number(value);
62
+ return Number.isFinite(parsed) ? parsed : 400;
63
+ }
64
+
65
+ // A font stack resolves to the first family this machine actually has, which
66
+ // is the job fontconfig does on X11 and the system collection does here. The
67
+ // generic names are left for the bridge, which knows what Segoe UI is.
68
+ const GENERIC = new Set([
69
+ 'sans-serif',
70
+ 'serif',
71
+ 'monospace',
72
+ 'system-ui',
73
+ 'cursive',
74
+ ]);
75
+ function familyOf(native, stack) {
76
+ if (!stack) return 'sans-serif';
77
+ for (const raw of String(stack).split(',')) {
78
+ const name = raw.trim().replace(/^["']|["']$/g, '');
79
+ if (!name) continue;
80
+ if (GENERIC.has(name)) return name;
81
+ if (native.fontExists(name)) return name;
82
+ }
83
+ return 'sans-serif';
84
+ }
85
+
86
+ function colorOf(value) {
87
+ if (!value) return null;
88
+ try {
89
+ const [r, g, b, a] = cssColorStraight(value);
90
+ return { r, g, b, a };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Code-unit offset of each code point, so the two index spaces can be
97
+ * converted without walking the string twice per query. */
98
+ function codeUnitOffsets(text) {
99
+ const offsets = [];
100
+ for (let i = 0; i < text.length;) {
101
+ offsets.push(i);
102
+ i += text.codePointAt(i) > 0xffff ? 2 : 1;
103
+ }
104
+ offsets.push(text.length);
105
+ return offsets;
106
+ }
107
+
108
+ class Win32TextLayout {
109
+ constructor(manager, handle, text, metrics) {
110
+ this._manager = manager;
111
+ this._native = manager._native;
112
+ // BackendContext2D reads this when it draws a layout, which is what makes
113
+ // the name part of the bridge contract rather than ours.
114
+ this._handle = handle;
115
+ this._text = text;
116
+ this._metrics = metrics;
117
+ this._offsets = null;
118
+ // Tells BackendContext2D that the ink comes from the context's own fill
119
+ // rather than from inside the layout — so a <text> whose colour is a
120
+ // gradient reaches drawLayoutGradient.
121
+ this._contextInk = true;
122
+ }
123
+
124
+ get width() {
125
+ return this._metrics.width;
126
+ }
127
+
128
+ get height() {
129
+ return this._metrics.height;
130
+ }
131
+
132
+ get lines() {
133
+ return this._metrics.lines;
134
+ }
135
+
136
+ /** The min-content width, which yoga's floors pass asks for with a width
137
+ * offer of zero. DirectWrite answers it natively (`DetermineMinWidth`),
138
+ * where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
139
+ * opportunities itself. */
140
+ get minWidth() {
141
+ return this._metrics.minWidth;
142
+ }
143
+
144
+ draw(ctx, x, y) {
145
+ ctx._drawLayout(this, x, y);
146
+ }
147
+
148
+ indexAt(x, y) {
149
+ const unit = this._native.layoutIndexAt(this._handle, x, y);
150
+ return this._cpOf(unit);
151
+ }
152
+
153
+ caretPosition(cp) {
154
+ return this._native.layoutCaret(this._handle, this._cuOf(cp));
155
+ }
156
+
157
+ _cuOf(cp) {
158
+ this._offsets ??= codeUnitOffsets(this._text);
159
+ const at = Math.max(0, Math.min(this._offsets.length - 1, cp));
160
+ return this._offsets[at];
161
+ }
162
+
163
+ _cpOf(unit) {
164
+ this._offsets ??= codeUnitOffsets(this._text);
165
+ // The last offset not past `unit` — a binary search would be the same
166
+ // answer, and paragraphs here are short enough that it would not pay.
167
+ let cp = 0;
168
+ while (cp + 1 < this._offsets.length && this._offsets[cp + 1] <= unit) cp++;
169
+ return cp;
170
+ }
171
+
172
+ destroy() {
173
+ if (this._handle == null) return;
174
+ this._native.layoutRelease(this._handle);
175
+ this._handle = null;
176
+ }
177
+ }
178
+
179
+ /** A face, as far as a renderer that positions glyphs itself reads one.
180
+ * Size is deferred the way ntk's is. */
181
+ class Win32Face {
182
+ constructor(manager, family, { weight = 400, style = 'normal' } = {}) {
183
+ this._manager = manager;
184
+ this.family = family;
185
+ this.weight = weightOf(weight);
186
+ this.style = style;
187
+ this.italic = style === 'italic' || style === 'oblique';
188
+ }
189
+
190
+ metrics(size) {
191
+ return this._manager._native.fontMetrics(
192
+ this.family,
193
+ size,
194
+ this.weight,
195
+ this.italic,
196
+ );
197
+ }
198
+
199
+ /**
200
+ * The bridge's handle for this face at a pixel size — a resolved
201
+ * IDWriteFontFace and the em size to draw it at, which is the pair
202
+ * DirectWrite's DrawGlyphRun takes and the pair CoreText folds into one
203
+ * CTFont. Cached on the manager, so the same face at the same size is one
204
+ * object across a frame and `drawGlyphs` can batch by it.
205
+ */
206
+ _handle(size) {
207
+ return this._manager._handleFor(this, size);
208
+ }
209
+
210
+ /**
211
+ * Coverage. A code point (ntk's contract) or a string — the two forms
212
+ * `hasGlyph` has been asked in.
213
+ */
214
+ hasGlyph(codepoint) {
215
+ if (typeof codepoint === 'number')
216
+ return this.glyphIdFor(codepoint) !== null;
217
+ const handle = this._handle(PROBE_SIZE);
218
+ if (!handle) return false;
219
+ return this._manager._native.fontHasGlyph(handle, String(codepoint));
220
+ }
221
+
222
+ /**
223
+ * Glyph id for a code point, or `null` when this face does not map it —
224
+ * the lookup twin of `hasGlyph` (ntk#254). A cmap lookup only: no shaping,
225
+ * so text that needs ligatures or marks goes through the layout path, and
226
+ * `hasGlyphRuns` on the renderer's side is the gate that decides.
227
+ *
228
+ * The size does not matter to a cmap, so this asks at one fixed size and
229
+ * the answers are shared rather than looked up per size.
230
+ */
231
+ glyphIdFor(codepoint) {
232
+ if (typeof codepoint !== 'number') return null;
233
+ const handle = this._handle(PROBE_SIZE);
234
+ if (!handle) return null;
235
+ return this._manager._native.fontGlyphForCodepoint(handle, codepoint);
236
+ }
237
+
238
+ /** Nominal (unshaped) horizontal advance of a glyph id, in pixels at
239
+ * `size`. */
240
+ advanceOf(glyphId, size) {
241
+ const handle = this._handle(size);
242
+ if (!handle) return 0;
243
+ const advances = this._manager._native.fontGlyphAdvances(handle, [glyphId]);
244
+ return advances?.[0] ?? 0;
245
+ }
246
+ }
247
+
248
+ export class Win32FontManager {
249
+ constructor(native) {
250
+ this._native = native;
251
+ this._faces = new Map();
252
+ // family|weight|italic|size -> the bridge's handle, or null when there is
253
+ // no such face
254
+ this._handles = new Map();
255
+ }
256
+
257
+ /**
258
+ * A face at a size, as the bridge's handle. Cached on the four things that
259
+ * pick it, because a terminal asks for the same handful of faces on every
260
+ * frame — and because `BackendContext2D.drawGlyphs` groups a frame's glyphs
261
+ * by handle identity, so two asks for the same face must answer the same
262
+ * number or a line of text goes out as one call per glyph.
263
+ */
264
+ _handleFor(face, size) {
265
+ const px = Math.max(1, Math.round((size ?? 0) * 64)) / 64;
266
+ const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${px}`;
267
+ let handle = this._handles.get(key);
268
+ if (handle === undefined) {
269
+ handle =
270
+ this._native.fontHandle(face.family, px, face.weight, face.italic) ||
271
+ null;
272
+ this._handles.set(key, handle);
273
+ }
274
+ return handle;
275
+ }
276
+
277
+ /**
278
+ * The handle a glyph run draws with (`BackendContext2D.drawGlyphs`): a face
279
+ * this engine made, or one of ntk's `Font` objects, which carries the family
280
+ * it was opened as — `loadFont()` registered the file with DirectWrite under
281
+ * that name, so asking for it by name reaches the same face.
282
+ */
283
+ _runHandle(font, size) {
284
+ if (font instanceof Win32Face) return font._handle(size);
285
+ const family = font?.familyName ?? font?.family ?? font?.postscriptName;
286
+ if (!family) return null;
287
+ return this._handleFor(
288
+ {
289
+ family: String(family),
290
+ weight: weightOf(font.weight ?? 400),
291
+ italic: font.italic === true || font.style === 'italic',
292
+ },
293
+ size,
294
+ );
295
+ }
296
+
297
+ /**
298
+ * A font the app ships rather than one the system has — `loadFont()`'s
299
+ * backend half. DirectWrite keeps app-supplied faces in a collection of
300
+ * their own, and the bridge names that collection for exactly the families
301
+ * that came from it, so afterwards the font is asked for by name like any
302
+ * other.
303
+ *
304
+ * `null` on purpose: src/fonts.js keeps the fontkit face it already opened
305
+ * as the handle — that is the one whose metrics an app can read — and what
306
+ * draws is resolved by family name against the collection above.
307
+ */
308
+ load(source, opts = {}) {
309
+ let data = source;
310
+ if (typeof source === 'string') {
311
+ // A path goes to DirectWrite as a path: it maps the file itself, and
312
+ // the face outlives anything this process would have to hold.
313
+ data = source;
314
+ } else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
315
+ data = source;
316
+ } else {
317
+ throw new Error(
318
+ 'react-x11: loadFont — expected a file path or font bytes, got ' +
319
+ typeof source,
320
+ );
321
+ }
322
+ // The name to reach it by, which `loadFont` has already decided — the
323
+ // file's own family, or one the caller asked for. A `postscriptName`
324
+ // alongside it narrows the registration to that one face, which is what
325
+ // naming a single face of a family (`Bahnschrift-Light`) means: without
326
+ // it the name would reach every face in the file and the weight would be
327
+ // whatever matching landed on.
328
+ const loaded = this._native.fontLoad(data, {
329
+ family: opts.family || undefined,
330
+ postscriptName: opts.postscriptName || undefined,
331
+ });
332
+ if (!loaded) {
333
+ throw new Error(
334
+ 'react-x11: loadFont — DirectWrite could not read the font' +
335
+ (typeof source === 'string' ? ` in ${source}` : ' data') +
336
+ '. It reads .ttf, .otf and .ttc; a .woff or .woff2 has to be ' +
337
+ 'unwrapped first.',
338
+ );
339
+ }
340
+ // Faces matched before this one existed were matched against a smaller
341
+ // set of fonts, and one of them may have fallen back to what this
342
+ // replaces.
343
+ this._faces.clear();
344
+ this._handles.clear();
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * The catalogue seam ntk exposes as `fonts.source` — what the fonts app
350
+ * browses. On X that is fontconfig; here it is DirectWrite's collections,
351
+ * the app's own faces before the system's.
352
+ *
353
+ * Pattern syntax: the family, with fontconfig's `:modifiers` tolerated and
354
+ * ignored (`Consolas:bold`, `:lang=ru` — the part after the colon is
355
+ * fontconfig vocabulary DirectWrite does not speak). A query that is only
356
+ * a modifier has no family in it and lists the catalogue instead, which is
357
+ * what the app does with `:lang=ja` on a backend that cannot answer it.
358
+ */
359
+ get source() {
360
+ return (this._source ??= {
361
+ matchSortedAsync: async ({ family } = {}) => {
362
+ const pattern = String(family ?? '').trim();
363
+ let name = pattern.split(':')[0].trim();
364
+ if (name && GENERIC_FAMILIES.has(name.toLowerCase())) {
365
+ // Rendering resolves the generics itself (ResolveFamily in the
366
+ // bridge); the catalogue wants a family it can actually enumerate,
367
+ // and these are the faces that resolution lands on.
368
+ name =
369
+ {
370
+ serif: 'Times New Roman',
371
+ monospace: 'Consolas',
372
+ 'ui-monospace': 'Consolas',
373
+ cursive: 'Segoe Script',
374
+ }[name.toLowerCase()] ?? 'Segoe UI';
375
+ }
376
+ const rows = this._native.listFonts(
377
+ name ? { family: name } : { limit: 400 },
378
+ );
379
+ return rows
380
+ .filter((row) => row.path)
381
+ .map((row) => ({
382
+ path: row.path,
383
+ postscriptName: row.postscriptName,
384
+ family: row.family,
385
+ style: row.style,
386
+ // fontconfig's charset, which DirectWrite does not expose as a
387
+ // string. Empty rather than wrong: the app shows it when it has
388
+ // one and says nothing when it does not.
389
+ charset: '',
390
+ }));
391
+ },
392
+ });
393
+ }
394
+
395
+ /**
396
+ * One IDWriteTextLayout over the joined string, with the spans as formatting
397
+ * ranges. That is what makes a paragraph of mixed <text> chunks a single
398
+ * layout rather than one per chunk — and therefore what makes line breaking
399
+ * work *across* them, which is the whole reason the contract takes spans
400
+ * rather than a string.
401
+ */
402
+ layout(spans, base = {}, options = {}) {
403
+ const list =
404
+ typeof spans === 'string' ? [{ text: spans, ...base }] : (spans ?? []);
405
+ let text = '';
406
+ const ranges = [];
407
+ for (const span of list) {
408
+ const piece = String(span?.text ?? '');
409
+ if (!piece) continue;
410
+ const start = text.length;
411
+ text += piece;
412
+ const ink = colorOf(span.color ?? base.color);
413
+ ranges.push({
414
+ start,
415
+ length: piece.length,
416
+ family: familyOf(this._native, span.family ?? base.family),
417
+ size: sizeOf(span.size ?? base.size),
418
+ weight: weightOf(span.weight ?? base.weight ?? 400),
419
+ italic: (span.style ?? base.style) === 'italic',
420
+ // A variable face's axes. Passed per span as well as on the base,
421
+ // because a span carries its own and a paragraph is one layout.
422
+ variations: span.variations ?? base.variations ?? undefined,
423
+ ...(ink ? ink : {}),
424
+ });
425
+ }
426
+
427
+ const handle = this._native.layoutCreate(
428
+ text,
429
+ {
430
+ family: familyOf(this._native, base.family),
431
+ size: sizeOf(base.size),
432
+ weight: weightOf(base.weight ?? 400),
433
+ italic: base.style === 'italic',
434
+ variations: base.variations ?? undefined,
435
+ maxWidth: options.maxWidth,
436
+ align: options.align,
437
+ lineHeight: options.lineHeight,
438
+ maxLines: options.maxLines,
439
+ rtl: options.direction === 'rtl',
440
+ },
441
+ ranges,
442
+ );
443
+
444
+ const raw = this._native.layoutMetrics(handle);
445
+ if (DEBUG) {
446
+ // The family is in here because the one way this goes quietly wrong is
447
+ // `familyOf` not recognising a name and answering `sans-serif`: the
448
+ // text still draws, in the wrong face, at the wrong metrics, and the
449
+ // only tell is a width that does not match the face the app thinks it
450
+ // asked for.
451
+ console.error(
452
+ `[win32] layout "${text.slice(0, 24)}" ${raw.width}x${raw.height} ` +
453
+ `family=${JSON.stringify(ranges[0]?.family)} size=${base.size} ` +
454
+ `vars=${JSON.stringify(base.variations ?? null)}`,
455
+ );
456
+ }
457
+
458
+ // `descent` is what halfLeading() in nodes/text.js subtracts to recreate
459
+ // CSS half-leading, and DirectWrite reports a baseline and a height per
460
+ // line rather than a descent.
461
+ const lines = (raw.lines ?? []).map((line) => ({
462
+ ...line,
463
+ descent: Math.max(0, line.height - line.baseline),
464
+ // `rangeBands` walks a line's runs to build a selection highlight, and
465
+ // reads each one's direction off a nested `run` object — ntk's shape.
466
+ // A line with no runs is not an empty line here, it is a crash:
467
+ // `line.runs is not iterable`.
468
+ runs: (line.runs ?? []).map((run) => ({
469
+ ...run,
470
+ run: { direction: run.rtl ? 'rtl' : 'ltr' },
471
+ })),
472
+ }));
473
+ return new Win32TextLayout(this, handle, text, { ...raw, lines });
474
+ }
475
+
476
+ match(family, options = {}) {
477
+ const name = familyOf(this._native, family);
478
+ const key = `${name}|${weightOf(options.weight)}|${options.style ?? 'normal'}`;
479
+ let face = this._faces.get(key);
480
+ if (!face) {
481
+ face = new Win32Face(this, name, options);
482
+ this._faces.set(key, face);
483
+ }
484
+ return face;
485
+ }
486
+
487
+ /**
488
+ * The system font fallback. DirectWrite's own
489
+ * `IDWriteFontFallback::MapCharacters` is the right answer and is not bound
490
+ * yet, so this reports that it has none rather than guessing a family —
491
+ * a wrong face is worse than tofu, because nothing downstream can tell it
492
+ * went wrong.
493
+ */
494
+ fallbackFor() {
495
+ return null;
496
+ }
497
+ }