react-x11 2.15.3 → 2.16.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/README.md +37 -0
- package/package.json +3 -3
- package/src/Reconciler.js +85 -22
- package/src/acceleratorhooks.js +40 -6
- package/src/anchor.js +79 -19
- package/src/capabilities.js +29 -4
- package/src/cocoa/app.js +211 -11
- package/src/cocoa/context2d.js +23 -0
- package/src/cocoa/fonts.js +78 -0
- package/src/cocoa/presenter.js +17 -0
- package/src/cocoa/promotion.js +20 -0
- package/src/cocoa/relaunch.js +8 -3
- package/src/cocoa/symbols.js +64 -0
- package/src/cocoa/threaded.js +24 -4
- package/src/cocoa/window.js +362 -139
- package/src/components/ProgressBar.js +1 -1
- package/src/components/Slider.js +72 -39
- package/src/components/anchor.js +7 -2
- package/src/components/index.js +1 -0
- package/src/components/theme.js +32 -28
- package/src/desktopcapabilityhooks.js +29 -6
- package/src/filedialoghooks.js +3 -5
- package/src/frame/childmain.js +8 -20
- package/src/frame/env.js +2 -10
- package/src/icontheme.js +240 -0
- package/src/imagesource.js +83 -1
- package/src/index.d.ts +10 -1
- package/src/index.js +3 -0
- package/src/keysymchars.js +47 -0
- package/src/keysyms.d.ts +19 -1
- package/src/keysyms.js +107 -8
- package/src/node.d.ts +7 -0
- package/src/nodes/animation.js +17 -47
- package/src/nodes/cascade.js +17 -2
- package/src/nodes/image.js +63 -1
- package/src/nodes/kinds.js +12 -0
- package/src/nodes/layout.js +5 -1
- package/src/nodes/node.js +17 -3
- package/src/nodes/paint.js +117 -0
- package/src/nodes/scope.js +259 -0
- package/src/nodes/scrollable.js +53 -6
- package/src/nodes/text.js +2 -0
- package/src/nodes/textarea.js +1 -1
- package/src/nodes/textinput.js +1 -1
- package/src/nodes/window/anchoring.js +45 -18
- package/src/nodes/window/flush.js +6 -5
- package/src/nodes/window/popup.js +10 -0
- package/src/nodes/window/size.js +40 -2
- package/src/nodes/window/window.js +41 -14
- package/src/registry.js +2 -1
- package/src/screens.js +159 -24
- package/src/settings.js +332 -0
- package/src/statusnotifier.js +164 -17
- package/src/styles.js +212 -8
- package/src/symbols.js +200 -0
- package/src/testing/mock-app.js +10 -0
- package/src/trayhooks.js +21 -5
- package/src/types/capabilities.d.ts +13 -1
- package/src/types/components.d.ts +33 -0
- package/src/types/elements.d.ts +57 -6
- package/src/types/events.d.ts +5 -0
- package/src/types/filedialog.d.ts +3 -1
- package/src/types/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +14 -2
- package/src/wayland/xkb.js +170 -59
- package/src/windowid.js +62 -20
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
|
|
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 (!
|
|
1258
|
+
if (!LOOP_OPTIONS.includes(key)) {
|
|
1086
1259
|
throw new Error(
|
|
1087
1260
|
`react-x11: unknown animation option "${key}" for ${at} ` +
|
|
1088
|
-
|
|
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
|
+
}
|
package/src/testing/mock-app.js
CHANGED
|
@@ -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
|
package/src/trayhooks.js
CHANGED
|
@@ -74,9 +74,10 @@ import { StatusNotifierItem, allocateItemSlot } from './statusnotifier.js';
|
|
|
74
74
|
*
|
|
75
75
|
* With `menu`, a click opens it — the same `items` vocabulary `MenuBar` and
|
|
76
76
|
* `useDockMenu` take, an item's `onSelect` firing when picked. Without one,
|
|
77
|
-
* `onClick` is called with the button and
|
|
78
|
-
*
|
|
79
|
-
* while mounted; the item is
|
|
77
|
+
* `onClick` is called with the button and where the click was, in logical
|
|
78
|
+
* screen pixels — with the item's rect, where the backend knows it. `null`
|
|
79
|
+
* means no item. Every field follows its value while mounted; the item is
|
|
80
|
+
* removed on unmount.
|
|
80
81
|
*
|
|
81
82
|
* `available` is whether this backend has a tray at all, and it **settles**:
|
|
82
83
|
* false on the first frame, true once a tray has been found. Branch on it for
|
|
@@ -89,6 +90,8 @@ export function useTray(options) {
|
|
|
89
90
|
const itemRef = useRef(null);
|
|
90
91
|
const [rect] = useState(null);
|
|
91
92
|
const [remote, setRemote] = useState(false);
|
|
93
|
+
// whether the freedesktop registration has answered, either way
|
|
94
|
+
const [answered, setAnswered] = useState(false);
|
|
92
95
|
const [error, setError] = useState(null);
|
|
93
96
|
// The feature vocabulary for whichever rung answered. Probed once per
|
|
94
97
|
// backend rather than per render — it describes the mechanism, not the
|
|
@@ -166,6 +169,10 @@ export function useTray(options) {
|
|
|
166
169
|
const appId = currentRegistration()?.appId ?? 'react-x11';
|
|
167
170
|
const item = new StatusNotifierItem({
|
|
168
171
|
getOptions: () => live.current,
|
|
172
|
+
// The display a click's position is read against: the host sends it in
|
|
173
|
+
// a unit of its own choosing — see "The position has no unit" in
|
|
174
|
+
// statusnotifier.js.
|
|
175
|
+
app,
|
|
169
176
|
appId,
|
|
170
177
|
slot: slotRef.current,
|
|
171
178
|
decodeIcon: decodeIconBytes,
|
|
@@ -180,16 +187,19 @@ export function useTray(options) {
|
|
|
180
187
|
sniRef.current = item;
|
|
181
188
|
previous.current = { ...live.current };
|
|
182
189
|
void item.start().then((ok) => {
|
|
183
|
-
if (
|
|
190
|
+
if (cancelled) return;
|
|
191
|
+
setRemote(ok);
|
|
192
|
+
setAnswered(true);
|
|
184
193
|
});
|
|
185
194
|
return () => {
|
|
186
195
|
cancelled = true;
|
|
187
196
|
sniRef.current = null;
|
|
188
197
|
setRemote(false);
|
|
198
|
+
setAnswered(false);
|
|
189
199
|
setError(null);
|
|
190
200
|
void item.stop();
|
|
191
201
|
};
|
|
192
|
-
}, [native, options == null]);
|
|
202
|
+
}, [app, native, options == null]);
|
|
193
203
|
|
|
194
204
|
// The item reads its fields through `getOptions`, so a render only has to
|
|
195
205
|
// say *which* of them moved — see `StatusNotifierItem.update`.
|
|
@@ -215,6 +225,12 @@ export function useTray(options) {
|
|
|
215
225
|
const backend = native ? 'cocoa' : remote ? caps.backend : null;
|
|
216
226
|
return {
|
|
217
227
|
available: native || remote,
|
|
228
|
+
// Whether `available` is an answer yet: at once on the Cocoa rung and for
|
|
229
|
+
// no item at all, and on the freedesktop one once the host has taken or
|
|
230
|
+
// refused the registration — so an app whose whole UI is its tray can
|
|
231
|
+
// render nothing until then, rather than a fallback window that flashes
|
|
232
|
+
// up and away on every start.
|
|
233
|
+
settled: native || !options || answered,
|
|
218
234
|
backend,
|
|
219
235
|
features: native || remote ? caps.features : NO_CAPABILITY.features,
|
|
220
236
|
error,
|
|
@@ -124,4 +124,16 @@ export declare function desktopCapability<N extends DesktopCapabilityName>(
|
|
|
124
124
|
*/
|
|
125
125
|
export declare function useDesktopCapability<N extends DesktopCapabilityName>(
|
|
126
126
|
name: N,
|
|
127
|
-
):
|
|
127
|
+
): DesktopCapabilityState<N>;
|
|
128
|
+
|
|
129
|
+
/** {@link useDesktopCapability}'s answer, and whether it is one yet. */
|
|
130
|
+
export type DesktopCapabilityState<N extends DesktopCapabilityName> =
|
|
131
|
+
DesktopCapabilityFor<N> & {
|
|
132
|
+
/**
|
|
133
|
+
* False until the first probe answers, and true from then on, re-probes
|
|
134
|
+
* included — so an app can hold its fallback back rather than flash it.
|
|
135
|
+
* True on the first frame where nothing had to be asked: the tray and
|
|
136
|
+
* the Dock tile on macOS.
|
|
137
|
+
*/
|
|
138
|
+
settled: boolean;
|
|
139
|
+
};
|