react-x11 2.15.3 → 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.
- package/README.md +37 -0
- package/package.json +3 -3
- package/src/Reconciler.js +85 -22
- package/src/anchor.js +60 -18
- package/src/capabilities.js +29 -4
- package/src/cocoa/app.js +15 -9
- 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.js +3 -0
- 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/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/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +14 -2
package/src/statusnotifier.js
CHANGED
|
@@ -37,14 +37,55 @@
|
|
|
37
37
|
//
|
|
38
38
|
// The protocol carries far less about a click than AppKit does. There is no
|
|
39
39
|
// click count, no modifier state, and no item rectangle — `Activate(x, y)`
|
|
40
|
-
// gives
|
|
40
|
+
// gives a position and nothing else. Those fields are reported as
|
|
41
41
|
// `0`/`false` rather than guessed at, and `docs/desktop.md` says so, because a
|
|
42
42
|
// tray menu that only opens on shift-click is an app built on a field this
|
|
43
43
|
// rung cannot fill. The menu is the portable interaction; `onClick` is the
|
|
44
44
|
// one that degrades.
|
|
45
|
+
//
|
|
46
|
+
// ## The position has no unit
|
|
47
|
+
//
|
|
48
|
+
// The spec says "screen coordinates" and stops, which on a scaled display is
|
|
49
|
+
// two different numbers — and the hosts split between them:
|
|
50
|
+
//
|
|
51
|
+
// - **X root coordinates, device pixels.** Plasma since 5.27, on purpose:
|
|
52
|
+
// its xembed-sni-proxy synthesises X clicks from them. GNOME's
|
|
53
|
+
// AppIndicator extension passes its stage coordinates through, which are
|
|
54
|
+
// device pixels on X11 and in Wayland's physical layout, the default up
|
|
55
|
+
// to GNOME 49.
|
|
56
|
+
// - **The host's own logical pixels.** xfce4-panel, Budgie and LXQt pass
|
|
57
|
+
// their toolkit's root position through, and Cinnamon sends the icon's
|
|
58
|
+
// corner over its UI scale. GNOME's stage is in logical pixels from 50,
|
|
59
|
+
// and before it on Fedora and Debian 13, which switch the layout on
|
|
60
|
+
// downstream. So did Plasma before 5.27.
|
|
61
|
+
// - **Not a screen position.** MATE sends the item's corner in its applet's
|
|
62
|
+
// own window, and snixembed's `Activate` is always `(0, 0)`.
|
|
63
|
+
//
|
|
64
|
+
// Dividing by the scale is right for the first group and halves every click
|
|
65
|
+
// in the second; passing the numbers through is the opposite. So a click is
|
|
66
|
+
// read both ways and the screen picks (`clickReadings`, `clickPoint`):
|
|
67
|
+
//
|
|
68
|
+
// 1. **A reading no monitor holds is not one.** A tray is on the screen,
|
|
69
|
+
// and a device-pixel host's item at the right or the bottom of it —
|
|
70
|
+
// where panels put the tray — is off the screen when read as logical
|
|
71
|
+
// pixels. The common device-pixel click costs nothing more.
|
|
72
|
+
// 2. **Then the pointer.** A host that sends a position sends the
|
|
73
|
+
// pointer's or its icon's, so one `QueryPointer` names the reading the
|
|
74
|
+
// click was at.
|
|
75
|
+
// 3. **Otherwise the numbers as sent.** The pointer is somewhere else
|
|
76
|
+
// because the click was a key, or the connection is XWayland's, which
|
|
77
|
+
// is told nothing of a pointer over a Wayland panel. As sent is what the
|
|
78
|
+
// second group needs, and the first group's usual trays were settled at
|
|
79
|
+
// step 1.
|
|
80
|
+
//
|
|
81
|
+
// What that leaves wrong is a device-pixel host under XWayland whose tray
|
|
82
|
+
// still lands on a monitor read as logical pixels: in the top-left quarter
|
|
83
|
+
// of the screen, or on a desk where it falls on another monitor.
|
|
45
84
|
|
|
46
85
|
import { loadTransport, sessionBus } from './bus.js';
|
|
47
86
|
import { DbusMenuExport } from './dbusmenuexport.js';
|
|
87
|
+
import { scaleOf } from './scale.js';
|
|
88
|
+
import { screensSnapshot } from './screens.js';
|
|
48
89
|
|
|
49
90
|
export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
|
|
50
91
|
export const WATCHER_PATH = '/StatusNotifierWatcher';
|
|
@@ -91,6 +132,43 @@ export function toPixmapArray(image) {
|
|
|
91
132
|
return [[width, height, out]];
|
|
92
133
|
}
|
|
93
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How far from the pointer a click's point may be and still be the click, in
|
|
137
|
+
* logical pixels. A host that sends the pointer is within a pixel or two of
|
|
138
|
+
* it, and Cinnamon, which sends its icon's corner, within the icon. A point
|
|
139
|
+
* further off is taken as sent — which for Cinnamon, a logical-pixel host,
|
|
140
|
+
* is right anyway.
|
|
141
|
+
*/
|
|
142
|
+
const CLICK_REACH = 64;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A host's `(x, y)` read both ways — as X root coordinates, and as the host's
|
|
146
|
+
* own logical pixels — keeping the readings some monitor holds. Each is the
|
|
147
|
+
* point on the screen it names, in device pixels, and what it means in
|
|
148
|
+
* logical ones. See "The position has no unit" in the header.
|
|
149
|
+
*
|
|
150
|
+
* `screens` are device-pixel rects, as `screensSnapshot(app).screens` holds
|
|
151
|
+
* them; with none known both readings stand, and with neither on a monitor
|
|
152
|
+
* the host sent something that is not a position.
|
|
153
|
+
*/
|
|
154
|
+
function clickReadings(x, y, scale, screens = []) {
|
|
155
|
+
const readings = [
|
|
156
|
+
{ device: { x, y }, logical: { x: x / scale, y: y / scale } },
|
|
157
|
+
{ device: { x: x * scale, y: y * scale }, logical: { x, y } },
|
|
158
|
+
];
|
|
159
|
+
if (!screens.length) return readings;
|
|
160
|
+
// Edges count: a host that sends an icon's far corner can land on one.
|
|
161
|
+
return readings.filter(({ device: p }) =>
|
|
162
|
+
screens.some(
|
|
163
|
+
(m) =>
|
|
164
|
+
p.x >= m.x &&
|
|
165
|
+
p.y >= m.y &&
|
|
166
|
+
p.x <= m.x + m.width &&
|
|
167
|
+
p.y <= m.y + m.height,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
94
172
|
/** `visible: false` is `Passive`, which is how the spec spells "hidden". */
|
|
95
173
|
function statusOf(options) {
|
|
96
174
|
if (options?.visible === false) return 'Passive';
|
|
@@ -129,8 +207,13 @@ function iconOf(icon, decode) {
|
|
|
129
207
|
* in quick succession cannot put two registrations in flight.
|
|
130
208
|
*/
|
|
131
209
|
export class StatusNotifierItem {
|
|
132
|
-
constructor({ getOptions, appId, decodeIcon, onError, slot } = {}) {
|
|
210
|
+
constructor({ getOptions, app, appId, decodeIcon, onError, slot } = {}) {
|
|
133
211
|
this.getOptions = getOptions ?? (() => null);
|
|
212
|
+
// The display the tray is on — not for the protocol, which is D-Bus
|
|
213
|
+
// only, but for a click's position: its scale, its monitors and its
|
|
214
|
+
// pointer are what put the host's numbers into logical pixels. Null is
|
|
215
|
+
// no display to ask, and a click's numbers are then passed as sent.
|
|
216
|
+
this.app = app ?? null;
|
|
134
217
|
this.appId = appId ?? 'react-x11';
|
|
135
218
|
this.decodeIcon = decodeIcon ?? (() => null);
|
|
136
219
|
this.onError = onError ?? (() => {});
|
|
@@ -453,27 +536,91 @@ export class StatusNotifierItem {
|
|
|
453
536
|
if (this.menu) this.menu.update(next.menu ?? []);
|
|
454
537
|
}
|
|
455
538
|
|
|
539
|
+
// ------------------------------------------------------------------ click
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A click's `(x, y)` in logical screen pixels — the unit a `<popup>`'s
|
|
543
|
+
* `x`/`y` and `anchor={{ rect }}` take. Read both ways and settled by the
|
|
544
|
+
* monitors, then the pointer, then as sent: the header has why.
|
|
545
|
+
*/
|
|
546
|
+
async clickPoint(x, y) {
|
|
547
|
+
const scale = scaleOf(this.app);
|
|
548
|
+
if (scale === 1) return { x, y };
|
|
549
|
+
const readings = clickReadings(
|
|
550
|
+
x,
|
|
551
|
+
y,
|
|
552
|
+
scale,
|
|
553
|
+
screensSnapshot(this.app).screens,
|
|
554
|
+
);
|
|
555
|
+
if (readings.length === 1) return readings[0].logical;
|
|
556
|
+
const pointer = readings.length ? await this.queryPointer() : null;
|
|
557
|
+
if (!pointer) return { x, y };
|
|
558
|
+
let near = null;
|
|
559
|
+
let nearest = CLICK_REACH * scale;
|
|
560
|
+
for (const reading of readings) {
|
|
561
|
+
const d = Math.hypot(
|
|
562
|
+
reading.device.x - pointer.x,
|
|
563
|
+
reading.device.y - pointer.y,
|
|
564
|
+
);
|
|
565
|
+
if (d <= nearest) {
|
|
566
|
+
near = reading;
|
|
567
|
+
nearest = d;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return near?.logical ?? { x, y };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Where the pointer is on the X screen, in device pixels, or null where
|
|
575
|
+
* nothing can say: a backend with no X server behind it, a failed request,
|
|
576
|
+
* a pointer on another screen.
|
|
577
|
+
*/
|
|
578
|
+
queryPointer() {
|
|
579
|
+
const X = this.app?.X;
|
|
580
|
+
const root = X?.display?.screen?.[0]?.root;
|
|
581
|
+
if (typeof X?.QueryPointer !== 'function' || root == null) {
|
|
582
|
+
return Promise.resolve(null);
|
|
583
|
+
}
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
try {
|
|
586
|
+
X.QueryPointer(root, (err, reply) =>
|
|
587
|
+
resolve(
|
|
588
|
+
err || !reply?.sameScreen
|
|
589
|
+
? null
|
|
590
|
+
: { x: reply.rootX, y: reply.rootY },
|
|
591
|
+
),
|
|
592
|
+
);
|
|
593
|
+
} catch {
|
|
594
|
+
resolve(null);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
456
599
|
// --------------------------------------------------------------- protocol
|
|
457
600
|
|
|
458
601
|
defineItem(dbus) {
|
|
459
602
|
const opts = () => this.options;
|
|
460
603
|
const icon = () => iconOf(opts().icon, this.decodeIcon);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
604
|
+
// No click count, no modifiers, no item rect: the protocol has none of
|
|
605
|
+
// them. Reported as zero rather than invented — see the header. The
|
|
606
|
+
// position it does have is put into logical pixels first, and the call is
|
|
607
|
+
// answered once the app has had the click, so a throw in `onClick` still
|
|
608
|
+
// reaches the host as the error it was.
|
|
609
|
+
const click = (button) => (args) =>
|
|
610
|
+
this.clickPoint(args?.x ?? 0, args?.y ?? 0).then(({ x, y }) => {
|
|
611
|
+
opts().onClick?.({
|
|
612
|
+
button,
|
|
613
|
+
x,
|
|
614
|
+
y,
|
|
615
|
+
width: 0,
|
|
616
|
+
height: 0,
|
|
617
|
+
clickCount: 1,
|
|
618
|
+
shift: false,
|
|
619
|
+
control: false,
|
|
620
|
+
option: false,
|
|
621
|
+
command: false,
|
|
622
|
+
});
|
|
475
623
|
});
|
|
476
|
-
};
|
|
477
624
|
|
|
478
625
|
return dbus.defineInterface({
|
|
479
626
|
name: ITEM_IFACE,
|
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
|
/**
|