react-x11 2.15.2 → 2.16.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 (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
package/src/styles.js CHANGED
@@ -247,6 +247,12 @@ const PAINT_PROPS = new Set([
247
247
  'outlineWidth',
248
248
  'outlineColor',
249
249
  'outlineOffset',
250
+ // How opaque the node is **with everything in it**: the subtree is drawn
251
+ // once and composited at this alpha (`NodePaint._paintGroup`), so a card, its
252
+ // border, its icon and its text fade as one — the thing a colour's own
253
+ // alpha cannot do for more than one flat fill (#587). A number from 0 to 1,
254
+ // which transitions and loops like any other.
255
+ 'opacity',
250
256
  ]);
251
257
 
252
258
  // Text style props. All affect measurement except color.
@@ -275,8 +281,105 @@ export const TEXT_LAYOUT_PROPS = new Set([
275
281
  // fits on a line, so it is a measurement input like the rest of this set.
276
282
  'textOverflow',
277
283
  'maxLines',
284
+ // CSS's letter-spacing, in px: added after every character, the last on a
285
+ // line included, which is how both text engines add it (#588)
286
+ 'letterSpacing',
287
+ // Figures and the rest of the OpenType features. `fontVariantNumeric` is
288
+ // CSS's keywords for the numeric ones — `tabular-nums` for a readout that
289
+ // holds its width while it changes — and `fontFeatureSettings` names any
290
+ // feature by tag, winning over the keyword where the two meet, as in CSS
291
+ 'fontVariantNumeric',
292
+ 'fontFeatureSettings',
278
293
  ]);
279
294
 
295
+ /**
296
+ * `fontVariantNumeric` keywords → the OpenType feature each one turns on, and
297
+ * the group it belongs to. CSS allows several keywords at once but one from
298
+ * each group, since the two in a group contradict each other: `'tabular-nums
299
+ * slashed-zero'` is a value, `'lining-nums oldstyle-nums'` is not.
300
+ */
301
+ const NUMERIC_VARIANTS = {
302
+ 'lining-nums': ['lnum', 'figures'],
303
+ 'oldstyle-nums': ['onum', 'figures'],
304
+ 'proportional-nums': ['pnum', 'spacing'],
305
+ 'tabular-nums': ['tnum', 'spacing'],
306
+ 'diagonal-fractions': ['frac', 'fractions'],
307
+ 'stacked-fractions': ['afrc', 'fractions'],
308
+ ordinal: ['ordn', 'ordinal'],
309
+ 'slashed-zero': ['zero', 'slashed-zero'],
310
+ };
311
+
312
+ /** The feature tags a `fontVariantNumeric` value turns on, or null when the
313
+ * value is not one CSS would accept. `'normal'` turns none on. */
314
+ function numericVariantTags(value) {
315
+ if (typeof value !== 'string') return null;
316
+ const words = value.trim().split(/\s+/);
317
+ if (words.length === 1 && words[0] === 'normal') return [];
318
+ const tags = [];
319
+ const groups = new Set();
320
+ for (const word of words) {
321
+ const [tag, group] = NUMERIC_VARIANTS[word] ?? [];
322
+ if (!tag || groups.has(group)) return null;
323
+ groups.add(group);
324
+ tags.push(tag);
325
+ }
326
+ return tags;
327
+ }
328
+
329
+ /** An OpenType feature tag: four printable ASCII characters. */
330
+ const FEATURE_TAG = /^[\x20-\x7e]{4}$/;
331
+
332
+ /** Whether `value` is a `fontFeatureSettings`: an array of tags to turn on,
333
+ * or an object of tag → on or off, or the number of the alternate a feature
334
+ * picks. */
335
+ function isFeatureSettings(value) {
336
+ if (Array.isArray(value)) {
337
+ return value.every(
338
+ (tag) => typeof tag === 'string' && FEATURE_TAG.test(tag),
339
+ );
340
+ }
341
+ if (!value || typeof value !== 'object') return false;
342
+ return Object.entries(value).every(
343
+ ([tag, v]) =>
344
+ FEATURE_TAG.test(tag) &&
345
+ (typeof v === 'boolean' || (Number.isInteger(v) && v >= 0)),
346
+ );
347
+ }
348
+
349
+ // the resolved `features` for one (variant, settings) pair, kept so that the
350
+ // same pair is the same object every time: a cascade compares two resolved
351
+ // styles with `!==` (`resolvedTextDelta`), and a fresh object per node would
352
+ // read as a change and re-measure text that did not move
353
+ const resolvedFeatures = new Map();
354
+
355
+ /**
356
+ * What the text engines are handed: every feature the two properties set, as
357
+ * tag → value (1 on, 0 off, or the alternate), `fontFeatureSettings` over
358
+ * the keywords. `undefined` when neither says anything, which is nearly
359
+ * every node.
360
+ */
361
+ export function featuresOf(variantNumeric, featureSettings) {
362
+ if (variantNumeric === undefined && featureSettings === undefined) {
363
+ return undefined;
364
+ }
365
+ const key = `${variantNumeric ?? ''}\u0000${JSON.stringify(featureSettings ?? null)}`;
366
+ if (resolvedFeatures.has(key)) return resolvedFeatures.get(key);
367
+ const features = {};
368
+ for (const tag of numericVariantTags(variantNumeric) ?? []) features[tag] = 1;
369
+ if (Array.isArray(featureSettings)) {
370
+ for (const tag of featureSettings) features[tag] = 1;
371
+ } else if (featureSettings) {
372
+ for (const [tag, value] of Object.entries(featureSettings)) {
373
+ features[tag] = value === true ? 1 : value === false ? 0 : value;
374
+ }
375
+ }
376
+ const out =
377
+ Object.keys(features).length > 0 ? Object.freeze(features) : undefined;
378
+ if (resolvedFeatures.size > 256) resolvedFeatures.clear();
379
+ resolvedFeatures.set(key, out);
380
+ return out;
381
+ }
382
+
280
383
  /**
281
384
  * Text style props that change how the text is **drawn** and provably not
282
385
  * where any of it lands. They still invalidate the cached layout — the value
@@ -298,7 +401,8 @@ export const TEXT_PAINT_PROPS = new Set(['textRendering']);
298
401
  * dims the labels under it the way it would in CSS.
299
402
  *
300
403
  * This is CSS's inherited set narrowed to what a *descendant* can act on: the
301
- * face, the size, the ink and the glyph rounding. `textAlign`, `lineHeight`,
404
+ * face, the size, the ink, the glyph rounding, the spacing between letters
405
+ * and the OpenType features. `textAlign`, `lineHeight`,
302
406
  * `textWrap`, `textOverflow`, `maxLines` and `textBoxTrim` stay out even
303
407
  * though CSS inherits the first two — here they are read by the node that
304
408
  * owns the **box** the text flows in, and a box is not something a descendant
@@ -313,6 +417,9 @@ export const INHERITED_TEXT_PROPS = new Set([
313
417
  'fontVariationSettings',
314
418
  'textRendering',
315
419
  'color',
420
+ 'letterSpacing',
421
+ 'fontVariantNumeric',
422
+ 'fontFeatureSettings',
316
423
  ]);
317
424
 
318
425
  /**
@@ -340,7 +447,7 @@ export function localTextStyleChanged(style, before) {
340
447
  export function inheritedTextChanged(style, before) {
341
448
  if (style === before) return false;
342
449
  for (const key of INHERITED_TEXT_PROPS) {
343
- if (key === 'fontVariationSettings') {
450
+ if (key === 'fontVariationSettings' || key === 'fontFeatureSettings') {
344
451
  if (!axesEqual(style[key], before[key])) return true;
345
452
  } else if (style[key] !== before[key]) return true;
346
453
  }
@@ -370,7 +477,9 @@ export function resolvedTextDelta(a, b) {
370
477
  a.size !== b.size ||
371
478
  a.weight !== b.weight ||
372
479
  a.style !== b.style ||
373
- !axesEqual(a.variations, b.variations)
480
+ !axesEqual(a.variations, b.variations) ||
481
+ a.letterSpacing !== b.letterSpacing ||
482
+ a.features !== b.features
374
483
  ) {
375
484
  return TEXT_REMEASURE;
376
485
  }
@@ -746,6 +855,64 @@ const GRID_VALUES = new Set([...GRID_CONTAINER_PROPS, ...GRID_ITEM_PROPS]);
746
855
  * grammar is concerned), so this checks the shape and never the colours.
747
856
  */
748
857
  function validateValue(key, value, where) {
858
+ if (key === 'letterSpacing') {
859
+ if (
860
+ value != null &&
861
+ !(typeof value === 'number' && Number.isFinite(value)) &&
862
+ !isToken(value)
863
+ ) {
864
+ throw new Error(
865
+ `react-x11: invalid letterSpacing ${JSON.stringify(value)} in ${where} ` +
866
+ '(expected a number of pixels, like letterSpacing: 1.5, or a $token)',
867
+ );
868
+ }
869
+ return;
870
+ }
871
+ if (key === 'fontVariantNumeric') {
872
+ if (
873
+ value != null &&
874
+ numericVariantTags(value) === null &&
875
+ !isToken(value)
876
+ ) {
877
+ throw new Error(
878
+ `react-x11: invalid fontVariantNumeric ${JSON.stringify(value)} in ` +
879
+ `${where} (expected 'normal', or keywords from ` +
880
+ `${Object.keys(NUMERIC_VARIANTS).join(', ')} with at most one of ` +
881
+ 'lining/oldstyle, proportional/tabular and diagonal/stacked, like ' +
882
+ "'tabular-nums' or 'tabular-nums slashed-zero')",
883
+ );
884
+ }
885
+ return;
886
+ }
887
+ if (key === 'fontFeatureSettings') {
888
+ if (value != null && !isFeatureSettings(value) && !isToken(value)) {
889
+ throw new Error(
890
+ `react-x11: invalid fontFeatureSettings ${JSON.stringify(value)} in ` +
891
+ `${where} (expected four-letter OpenType tags — an array of tags to ` +
892
+ "turn on, ['tnum'], or an object of tag to on/off or an alternate, " +
893
+ '{ tnum: true, liga: false, salt: 2 })',
894
+ );
895
+ }
896
+ return;
897
+ }
898
+ if (key === 'opacity') {
899
+ // unset, the way a conditional writes it: `opacity: dim ? 0.5 : undefined`
900
+ if (value == null) return;
901
+ // A string that is not a token is the one mistake worth stopping for:
902
+ // `'50%'` or `'0.5'` would otherwise compare as not-below-1 and paint
903
+ // the node fully opaque, silently. A number outside 0..1 is clamped
904
+ // where it is painted, as CSS clamps it.
905
+ if (
906
+ !(typeof value === 'number' && !Number.isNaN(value)) &&
907
+ !isToken(value)
908
+ ) {
909
+ throw new Error(
910
+ `react-x11: invalid opacity ${JSON.stringify(value)} in ${where} ` +
911
+ '(expected a number from 0 to 1 — opacity: 0.5 is half — or a $token)',
912
+ );
913
+ }
914
+ return;
915
+ }
749
916
  const grid = GRID_VALUES.has(key);
750
917
  if (!grid && key !== 'backgroundImage' && key !== 'boxShadow') return;
751
918
  try {
@@ -962,6 +1129,9 @@ const NOT_ANIMATABLE = new Set([
962
1129
  'fontFamily',
963
1130
  'fontWeight',
964
1131
  'fontStyle',
1132
+ // a keyword and a bag of tags: there is no halfway figure
1133
+ 'fontVariantNumeric',
1134
+ 'fontFeatureSettings',
965
1135
  'textAlign',
966
1136
  // nothing is drawn from it, so there is no frame in which a halfway value
967
1137
  // would be visible — and it may be an object, which does not lerp
@@ -1056,6 +1226,9 @@ const parsedAnimations = new WeakMap();
1056
1226
  * a thing that does not move: a loop nobody wrote a stop for is exactly the
1057
1227
  * feature where silence is unreadable.
1058
1228
  */
1229
+ /** What one property's loop may say. */
1230
+ const LOOP_OPTIONS = ['from', 'to', 'duration', 'easing', 'alternate', 'delay'];
1231
+
1059
1232
  function parseAnimation(spec, where) {
1060
1233
  if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
1061
1234
  throw new Error(
@@ -1077,15 +1250,15 @@ function parseAnimation(spec, where) {
1077
1250
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
1078
1251
  throw new Error(
1079
1252
  `react-x11: invalid animation for ${at} — expected ` +
1080
- '{ from?, to, duration, easing?, alternate? }, got ' +
1253
+ '{ from?, to, duration, easing?, alternate?, delay? }, got ' +
1081
1254
  JSON.stringify(entry),
1082
1255
  );
1083
1256
  }
1084
1257
  for (const key of Object.keys(entry)) {
1085
- if (!['from', 'to', 'duration', 'easing', 'alternate'].includes(key)) {
1258
+ if (!LOOP_OPTIONS.includes(key)) {
1086
1259
  throw new Error(
1087
1260
  `react-x11: unknown animation option "${key}" for ${at} ` +
1088
- '(expected from, to, duration, easing, alternate)',
1261
+ `(expected ${LOOP_OPTIONS.join(', ')})`,
1089
1262
  );
1090
1263
  }
1091
1264
  }
@@ -1109,6 +1282,14 @@ function parseAnimation(spec, where) {
1109
1282
  `${at} (expected one of ${EASING_NAMES.join(', ')})`,
1110
1283
  );
1111
1284
  }
1285
+ const delay = entry.delay ?? 0;
1286
+ if (typeof delay !== 'number' || !Number.isFinite(delay)) {
1287
+ throw new Error(
1288
+ `react-x11: animation for ${at} needs "delay" in ms — before the ` +
1289
+ 'loop starts, or negative to start it that far in — got ' +
1290
+ JSON.stringify(entry.delay),
1291
+ );
1292
+ }
1112
1293
  entries.push({
1113
1294
  prop,
1114
1295
  from: entry.from,
@@ -1117,6 +1298,7 @@ function parseAnimation(spec, where) {
1117
1298
  easing,
1118
1299
  ease: EASINGS[easing],
1119
1300
  alternate: Boolean(entry.alternate),
1301
+ delay,
1120
1302
  });
1121
1303
  }
1122
1304
  return entries;
@@ -1184,7 +1366,8 @@ export function sameAnimation(a, b) {
1184
1366
  a.to === b.to &&
1185
1367
  a.duration === b.duration &&
1186
1368
  a.easing === b.easing &&
1187
- a.alternate === b.alternate
1369
+ a.alternate === b.alternate &&
1370
+ a.delay === b.delay
1188
1371
  );
1189
1372
  }
1190
1373
 
@@ -1193,9 +1376,14 @@ export function sameAnimation(a, b) {
1193
1376
  * the looping: the phase comes from a modulo of the elapsed time rather than
1194
1377
  * from a per-cycle restart, so a bar that has been spinning for an hour is
1195
1378
  * exactly where the clock says and no rounding has accumulated.
1379
+ *
1380
+ * The delay is where the loop's own time starts: `from` until a positive one
1381
+ * has passed, and already that far in for a negative one — CSS's
1382
+ * `animation-delay` with `animation-fill-mode: backwards`, which is also
1383
+ * what Core Animation shows for the same declaration.
1196
1384
  */
1197
1385
  export function animationValueAt(spec, elapsed) {
1198
- const cycles = Math.max(0, elapsed) / spec.duration;
1386
+ const cycles = Math.max(0, elapsed - spec.delay) / spec.duration;
1199
1387
  let t = cycles % 1;
1200
1388
  if (spec.alternate && Math.floor(cycles) % 2 === 1) t = 1 - t;
1201
1389
  return interpolate(spec.from, spec.to, spec.ease(t)) ?? spec.from;
@@ -1670,6 +1858,7 @@ export const SCALED_LENGTH_PROPS = [
1670
1858
  'outlineWidth',
1671
1859
  'outlineOffset',
1672
1860
  'fontSize',
1861
+ 'letterSpacing',
1673
1862
  ];
1674
1863
 
1675
1864
  const SCALED_LENGTHS = new Set(SCALED_LENGTH_PROPS);
@@ -1834,6 +2023,9 @@ export function paintPropsChanged(props, oldProps = {}) {
1834
2023
 
1835
2024
  /** Resolved text style (TextLayout base style) from props + inherited. */
1836
2025
  export function textStyleFrom(props, inherited) {
2026
+ const variantNumeric = props.fontVariantNumeric ?? inherited.variantNumeric;
2027
+ const featureSettings =
2028
+ props.fontFeatureSettings ?? inherited.featureSettings;
1837
2029
  return {
1838
2030
  family: props.fontFamily ?? inherited.family,
1839
2031
  size: props.fontSize ?? inherited.size,
@@ -1844,6 +2036,14 @@ export function textStyleFrom(props, inherited) {
1844
2036
  variations: props.fontVariationSettings ?? inherited.variations,
1845
2037
  textRendering: props.textRendering ?? inherited.textRendering,
1846
2038
  color: props.color ?? inherited.color,
2039
+ letterSpacing: props.letterSpacing ?? inherited.letterSpacing,
2040
+ // Inherited apart, as CSS inherits the two properties apart — a child's
2041
+ // `fontVariantNumeric` replaces the keyword above it and leaves an
2042
+ // inherited `fontFeatureSettings` alone — and resolved together into what
2043
+ // the engines read.
2044
+ variantNumeric,
2045
+ featureSettings,
2046
+ features: featuresOf(variantNumeric, featureSettings),
1847
2047
  };
1848
2048
  }
1849
2049
 
@@ -1865,6 +2065,10 @@ export const DEFAULT_TEXT_STYLE = {
1865
2065
  variations: undefined,
1866
2066
  textRendering: undefined,
1867
2067
  color: 'black',
2068
+ letterSpacing: undefined,
2069
+ variantNumeric: undefined,
2070
+ featureSettings: undefined,
2071
+ features: undefined,
1868
2072
  };
1869
2073
 
1870
2074
  /**
package/src/symbols.js ADDED
@@ -0,0 +1,200 @@
1
+ // Symbols by name: the platform's own icons, drawn in the text colour inside
2
+ // a window (#591) — the names `useTray`'s `icon` and a menu item's `iconName`
3
+ // already take, and nothing that draws in a window did.
4
+ //
5
+ // Two providers, one per icon system, behind the same two questions, how
6
+ // big and draw it here:
7
+ //
8
+ // - **SF Symbols** on the Cocoa backend, which brings its own
9
+ // (`app.symbols`, src/cocoa/symbols.js): a symbol is a template, drawn in
10
+ // the fill colour at the weight and point size of the text beside it.
11
+ // - **The freedesktop icon theme** everywhere else (src/icontheme.js): the
12
+ // user's theme, looked up at the size the text calls for. A `-symbolic` icon
13
+ // is preferred and drawn the same way, its shape in the text colour; an icon
14
+ // that is only drawn in its own colours is shown as it is.
15
+ //
16
+ // A name one system does not have simply is not there, and takes no room —
17
+ // the same bargain a tray icon's name makes — so an app that runs on both
18
+ // picks its names by platform, as it does for the tray.
19
+
20
+ import { cssColorStraight, decodeImage, SvgView } from 'ntk';
21
+ import * as nodeFs from 'node:fs';
22
+
23
+ import { IconTheme } from './icontheme.js';
24
+ import { Surface } from './ntk.js';
25
+ import { xsettings } from './xsettings.js';
26
+
27
+ /** A `fontWeight` as the number both icon systems configure with. */
28
+ export function symbolWeight(weight) {
29
+ if (typeof weight === 'number') return weight;
30
+ if (weight === 'bold') return 700;
31
+ return 400;
32
+ }
33
+
34
+ const warned = new Set();
35
+ /** A development warning, once per message. */
36
+ export function warnOnce(message) {
37
+ if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
38
+ warned.add(message);
39
+ console.warn(message);
40
+ }
41
+
42
+ // --- the freedesktop icon theme -----------------------------------------------
43
+
44
+ /**
45
+ * The size a theme is asked for beside text of `pointSize`: 16 for 14px text,
46
+ * which is the pairing every toolkit's menus and toolbars are drawn at, and in
47
+ * proportion from there. An icon theme's icons are square.
48
+ */
49
+ export const iconSizeFor = (pointSize) =>
50
+ Math.max(1, Math.round((pointSize * 8) / 7));
51
+
52
+ /**
53
+ * The user's icon theme on this connection: what the settings daemon says
54
+ * (`Net/IconThemeName`, which GNOME and Xfce write), and failing that
55
+ * Adwaita, which `hicolor` then backs up. An explicit `theme` from the test
56
+ * seam wins.
57
+ */
58
+ export function iconThemeName(app) {
59
+ const named = xsettings(app)?.get?.('Net/IconThemeName');
60
+ return typeof named === 'string' && named ? named : 'Adwaita';
61
+ }
62
+
63
+ class FreedesktopSymbols {
64
+ constructor(app, { theme, baseDirs, pixmapDirs, fs = nodeFs } = {}) {
65
+ this.app = app;
66
+ this.fs = fs;
67
+ this.theme = new IconTheme({
68
+ theme: theme ?? iconThemeName(app),
69
+ baseDirs,
70
+ pixmapDirs,
71
+ fs,
72
+ });
73
+ this.documents = new Map(); // path -> SvgView | Image | null
74
+ // path|size|colour -> Surface, least recently drawn first
75
+ this.rasters = new Map();
76
+ }
77
+
78
+ /** `{ path, symbolic }` for a name at a logical size, or null. A symbolic
79
+ * variant is preferred: it is the one that takes the text colour. */
80
+ _resolve(name, pointSize, scale) {
81
+ const size = iconSizeFor(pointSize);
82
+ const whole = Math.max(1, Math.round(scale));
83
+ const symbolic = name.endsWith('-symbolic');
84
+ for (const candidate of symbolic ? [name] : [`${name}-symbolic`, name]) {
85
+ const path = this.theme.find(candidate, size, whole);
86
+ if (path) return { path, symbolic: candidate.endsWith('-symbolic') };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ size(name, options) {
92
+ if (!this._resolve(name, options.pointSize, options.displayScale)) {
93
+ return null;
94
+ }
95
+ const side = iconSizeFor(options.pointSize);
96
+ return { width: side, height: side };
97
+ }
98
+
99
+ draw(ctx, name, rect, options) {
100
+ const found = this._resolve(name, options.pointSize, options.displayScale);
101
+ if (!found) return false;
102
+ // square, centred in the box, on whole device pixels
103
+ const side = Math.max(1, Math.floor(Math.min(rect.width, rect.height)));
104
+ const x = Math.round(rect.x + (rect.width - side) / 2);
105
+ const y = Math.round(rect.y + (rect.height - side) / 2);
106
+ const raster = this._raster(found, side, options.color);
107
+ if (!raster) return false;
108
+ ctx.drawImage(raster, x, y, side, side);
109
+ return true;
110
+ }
111
+
112
+ /** The icon drawn at `side` device pixels — in `color` for a symbolic one —
113
+ * kept for the next paint. */
114
+ _raster(found, side, color) {
115
+ const ink = found.symbolic
116
+ ? (cssColorStraight(color) ?? [0, 0, 0, 1])
117
+ : null;
118
+ const key = `${found.path}\u0000${side}\u0000${ink?.join(',') ?? ''}`;
119
+ const kept = this.rasters.get(key);
120
+ if (kept) {
121
+ this.rasters.delete(key);
122
+ this.rasters.set(key, kept);
123
+ return kept;
124
+ }
125
+ const document = this._document(found.path);
126
+ if (!document) return null;
127
+ let surface;
128
+ try {
129
+ surface = new Surface(this.app, { width: side, height: side });
130
+ } catch {
131
+ return null; // no offscreen surfaces here: the headless mock
132
+ }
133
+ surface.render((sctx) => {
134
+ sctx.clearRect(0, 0, side, side);
135
+ if (document instanceof SvgView) document.draw(sctx, 0, 0, side, side);
136
+ else sctx.drawImage(document, 0, 0, side, side);
137
+ if (ink) {
138
+ // the shape's coverage, in the text colour: what AppKit does with a
139
+ // template, and what GTK does with a symbolic icon
140
+ sctx.globalCompositeOperation = 'source-in';
141
+ sctx.fillStyle = `rgba(${Math.round(ink[0] * 255)}, ${Math.round(ink[1] * 255)}, ${Math.round(ink[2] * 255)}, ${ink[3]})`;
142
+ sctx.fillRect(0, 0, side, side);
143
+ }
144
+ });
145
+ this.rasters.set(key, surface);
146
+ if (this.rasters.size > 64) {
147
+ const [oldest, evicted] = this.rasters.entries().next().value;
148
+ this.rasters.delete(oldest);
149
+ evicted.destroy?.();
150
+ }
151
+ return surface;
152
+ }
153
+
154
+ _document(path) {
155
+ if (this.documents.has(path)) return this.documents.get(path);
156
+ let document = null;
157
+ try {
158
+ const bytes = this.fs.readFileSync(path);
159
+ if (path.endsWith('.svg')) {
160
+ document = new SvgView(null);
161
+ document.setSvg(bytes.toString('utf8'));
162
+ } else {
163
+ document = decodeImage(bytes);
164
+ }
165
+ } catch (err) {
166
+ warnOnce(`react-x11: the icon ${path} did not load: ${err.message}`);
167
+ }
168
+ this.documents.set(path, document);
169
+ return document;
170
+ }
171
+
172
+ destroy() {
173
+ for (const surface of this.rasters.values()) surface.destroy?.();
174
+ this.rasters.clear();
175
+ }
176
+ }
177
+
178
+ // --- per app --------------------------------------------------------------------
179
+
180
+ const providers = new WeakMap();
181
+
182
+ /**
183
+ * The symbol provider for a connection: the one the app brings, which is the
184
+ * Cocoa app's SF Symbols, or the freedesktop icon theme, made on first use.
185
+ */
186
+ export function symbolsFor(app) {
187
+ let provider = providers.get(app);
188
+ if (!provider) {
189
+ provider = app?.symbols ?? new FreedesktopSymbols(app);
190
+ providers.set(app, provider);
191
+ }
192
+ return provider;
193
+ }
194
+
195
+ /** Look icons up in a theme of the test's own, at base directories it names. */
196
+ export function setIconThemeForTests(app, options) {
197
+ providers.get(app)?.destroy?.();
198
+ if (options == null) providers.delete(app);
199
+ else providers.set(app, new FreedesktopSymbols(app, options));
200
+ }
@@ -288,6 +288,16 @@ export function createMockApp() {
288
288
  wnd.grabbed = false;
289
289
  wnd.calls.push(['ungrabPointer']);
290
290
  },
291
+ // …and a popup that takes the keyboard holds this one
292
+ grabKeyboard(options, cb) {
293
+ wnd.keyboardGrabbed = true;
294
+ wnd.calls.push(['grabKeyboard']);
295
+ cb?.(null, 0);
296
+ },
297
+ ungrabKeyboard() {
298
+ wnd.keyboardGrabbed = false;
299
+ wnd.calls.push(['ungrabKeyboard']);
300
+ },
291
301
  // ntk >= 7.5.0. `xi2: 'auto'` creates a window on core events and
292
302
  // calls this the first time it is scrolled, so the call is the
293
303
  // observable half of the upgrade — `wnd.xi2Selected` is what a test