react-x11 2.35.0 → 2.37.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/package.json +1 -1
- package/src/appearance.js +48 -15
- package/src/appearancehooks.js +1 -1
- package/src/backend/context2d.js +94 -3
- package/src/cocoa/presenter.js +148 -13
- package/src/cocoa/promotion.js +370 -33
- package/src/cocoa/sprites.js +40 -1
- package/src/cocoa/window.js +4 -0
- package/src/components/ProgressBar.js +7 -3
- package/src/nodes/animation.js +171 -12
- package/src/nodes/scrollable.js +3 -0
- package/src/nodes/window/flush.js +9 -0
- package/src/testing/harness.js +148 -9
- package/src/testing/index.d.ts +46 -1
- package/src/testing/index.js +1 -0
- package/src/types/appearance.d.ts +2 -2
- package/src/types/system.d.ts +6 -0
package/src/nodes/animation.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
interpolate,
|
|
13
13
|
ease,
|
|
14
14
|
isLayoutProp,
|
|
15
|
+
TEXT_LAYOUT_PROPS,
|
|
15
16
|
} from '../styles.js';
|
|
16
17
|
import { GRID_CONTAINER_PROPS, GRID_ITEM_PROPS } from '../grid.js';
|
|
17
18
|
import { isPlaced } from '../layouts.js';
|
|
@@ -19,9 +20,19 @@ import { desktopSettings, watchDesktopSettings } from '../desktopsettings.js';
|
|
|
19
20
|
import { watchWindowState, windowStateSnapshot } from '../windowstate.js';
|
|
20
21
|
import { shadowExtentOf } from './boxpaint.js';
|
|
21
22
|
import { inThemeWalk } from './cascade.js';
|
|
22
|
-
import { insetRect } from './rects.js';
|
|
23
|
+
import { insetRect, intersectRects, outerPixels } from './rects.js';
|
|
23
24
|
import { shallowEqual } from './util.js';
|
|
24
25
|
|
|
26
|
+
/** How often a loop the clock runs is offered to a presenter again, at
|
|
27
|
+
* most (`_recheckLoops`): a check is a walk up the node's ancestors and
|
|
28
|
+
* along what is painted after it, and the loop is a frame every refresh
|
|
29
|
+
* for as long as it waits. */
|
|
30
|
+
const REOFFER_MS = 250;
|
|
31
|
+
|
|
32
|
+
/** The loopable properties that reach past the node's box without moving
|
|
33
|
+
* anything: a ring that grows draws further out than the node did. */
|
|
34
|
+
const REACH_PROPS = new Set(['outlineWidth', 'outlineOffset']);
|
|
35
|
+
|
|
25
36
|
/** Two values of a grid property that lay out the same: an inline
|
|
26
37
|
* `gridTemplateAreas` array is a new array every render. */
|
|
27
38
|
const sameGridValue = (a, b) =>
|
|
@@ -299,16 +310,53 @@ export class NodeAnimation {
|
|
|
299
310
|
}
|
|
300
311
|
|
|
301
312
|
/** The presenter could not run `entry` after all — the node turned into a
|
|
302
|
-
* raster between the swap and the frame
|
|
303
|
-
* the top
|
|
313
|
+
* raster between the swap and the frame, or its layer went. A transition
|
|
314
|
+
* starts again from the top, since the property's declared start is
|
|
315
|
+
* where its pixels still are. A loop goes on where it is in its cycle:
|
|
316
|
+
* the render server ran it from `start` (`LayerAnimations.apply`), or it
|
|
317
|
+
* never left the clock, so a spinner given back does not jump. */
|
|
304
318
|
_offloadDeclined(prop, entry) {
|
|
305
319
|
if (this._anim?.get(prop) !== entry || this.destroyed) return;
|
|
306
320
|
entry.offloaded = false;
|
|
307
|
-
entry.
|
|
321
|
+
entry.headStart = undefined;
|
|
322
|
+
if (entry.loop) entry.value = animationValueAt(entry, now() - entry.start);
|
|
323
|
+
else entry.start = now();
|
|
308
324
|
this.style = { ...this._targetStyle, ...this._animatedValues() };
|
|
309
325
|
this.root?._startAnimating(this);
|
|
310
326
|
}
|
|
311
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Loops the frame clock runs that the presenter would take now: turned
|
|
330
|
+
* down by a frame, or given back, by a scene that has changed since — an
|
|
331
|
+
* ancestor done fading, a sibling gone, a thumb scrolled away. Nothing
|
|
332
|
+
* about a loop would ever ask again, so the window asks (`_recheckLoops`),
|
|
333
|
+
* and only where the presenter says a frame would take it
|
|
334
|
+
* (`wouldAnimateNode`), so a loop it would turn down again costs no swap.
|
|
335
|
+
* Handed over where it is in its cycle (`headStart`).
|
|
336
|
+
*/
|
|
337
|
+
_offerLoopsAgain() {
|
|
338
|
+
const wnd = this.root?.window;
|
|
339
|
+
let asked = false;
|
|
340
|
+
let moved = false;
|
|
341
|
+
for (const [prop, a] of this._anim ?? []) {
|
|
342
|
+
if (!a.loop || a.offloaded) continue;
|
|
343
|
+
if (!asked) {
|
|
344
|
+
if (!wnd.wouldAnimateNode(this)) return;
|
|
345
|
+
asked = true;
|
|
346
|
+
}
|
|
347
|
+
a.headStart = now() - a.start;
|
|
348
|
+
if (this._offload(prop, a)) {
|
|
349
|
+
a.offloaded = true;
|
|
350
|
+
moved = true;
|
|
351
|
+
} else {
|
|
352
|
+
a.headStart = undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!moved) return;
|
|
356
|
+
this.style = { ...this._targetStyle, ...this._animatedValues() };
|
|
357
|
+
this._scheduleAnimationFrames();
|
|
358
|
+
}
|
|
359
|
+
|
|
312
360
|
/** Keep the frame clock running only for what the clock itself animates;
|
|
313
361
|
* an offloaded-only node needs one frame — the one that sends the model
|
|
314
362
|
* and the animation — and not a loop of them. */
|
|
@@ -383,13 +431,19 @@ export class NodeAnimation {
|
|
|
383
431
|
// re-rendered — which is the frame after every state change in the
|
|
384
432
|
// app.
|
|
385
433
|
if (current?.loop && sameAnimation(current, spec)) {
|
|
386
|
-
// A loop the clock
|
|
387
|
-
// one declared at mount runs from `_setRoot`, before
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
434
|
+
// A loop the clock runs moves over the first time a presenter
|
|
435
|
+
// takes it: one declared at mount runs from `_setRoot`, before
|
|
436
|
+
// `realize`, and one a frame turned down runs until the next swap
|
|
437
|
+
// offers it again (or `_offerLoopsAgain` does). It goes where it
|
|
438
|
+
// has got to in its cycle (`headStart`), so nothing jumps.
|
|
439
|
+
if (!current.offloaded) {
|
|
440
|
+
current.headStart = now() - current.start;
|
|
441
|
+
if (this._offload(spec.prop, current)) {
|
|
442
|
+
current.offloaded = true;
|
|
443
|
+
changed = true;
|
|
444
|
+
} else {
|
|
445
|
+
current.headStart = undefined;
|
|
446
|
+
}
|
|
393
447
|
}
|
|
394
448
|
continue;
|
|
395
449
|
}
|
|
@@ -443,7 +497,76 @@ export class NodeAnimation {
|
|
|
443
497
|
return false;
|
|
444
498
|
}
|
|
445
499
|
if (desktopSettings(root.app).animations === false) return false;
|
|
446
|
-
|
|
500
|
+
if (this._hiddenInTree()) return false;
|
|
501
|
+
// last, since it is the one that measures — and a hidden subtree's
|
|
502
|
+
// rects are wherever it was when it went
|
|
503
|
+
this._outOfSight = this._loopsOutOfSight();
|
|
504
|
+
return !this._outOfSight;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether nothing this node's loops draw can be seen: all of it is cut
|
|
509
|
+
* away by the boxes that clip it, or lies outside the window — a spinner
|
|
510
|
+
* in a row scrolled out of its list, a bar in a panel scrolled away.
|
|
511
|
+
* Judged against the arrangement the last layout pass left, and again
|
|
512
|
+
* after every pass that moved anything (`_recheckLoops`).
|
|
513
|
+
*
|
|
514
|
+
* Never from where a frame of the loop has the node *now*: a block that
|
|
515
|
+
* slides across a track is off it at the start of every crossing, and a
|
|
516
|
+
* loop stopped there would rest there, unseen, for good. What is judged is
|
|
517
|
+
* what holds every frame (`_loopReach`), and a loop whose frames can move
|
|
518
|
+
* what is around it is never judged out of sight at all.
|
|
519
|
+
*/
|
|
520
|
+
_loopsOutOfSight() {
|
|
521
|
+
const window = this.root?.abs;
|
|
522
|
+
if (!this._placed || !window) return false;
|
|
523
|
+
const reach = this._loopReach();
|
|
524
|
+
if (!reach) return false;
|
|
525
|
+
let out = reach.rect;
|
|
526
|
+
for (let n = reach.from.parent; n && out; n = n.parent) {
|
|
527
|
+
if (n.isWindow) break;
|
|
528
|
+
if (n.clipsChildren()) out = intersectRects(out, outerPixels(n.abs));
|
|
529
|
+
}
|
|
530
|
+
return (
|
|
531
|
+
!out ||
|
|
532
|
+
!intersectRects(out, {
|
|
533
|
+
x: 0,
|
|
534
|
+
y: 0,
|
|
535
|
+
width: window.width,
|
|
536
|
+
height: window.height,
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* What every frame of this node's loops stays inside, as `{ rect, from }`
|
|
543
|
+
* — `from` is the node whose clipping ancestors cut `rect` — or null when
|
|
544
|
+
* nothing short of the window holds it.
|
|
545
|
+
*
|
|
546
|
+
* A loop on a colour, an opacity or a radius moves nothing and grows
|
|
547
|
+
* nothing, so it draws where the node and what it holds already reach.
|
|
548
|
+
* One that grows the node's outline, or moves or sizes a node out of the
|
|
549
|
+
* flow, can put it anywhere in the nearest box that clips it — and nowhere
|
|
550
|
+
* else, since a node out of the flow sizes nothing around it; with no such
|
|
551
|
+
* box, anywhere in the window. One that moves or sizes a node in the flow
|
|
552
|
+
* moves its siblings and can resize every box above it, the clipping ones
|
|
553
|
+
* among them, so no box holds it.
|
|
554
|
+
*/
|
|
555
|
+
_loopReach() {
|
|
556
|
+
let inPlace = true;
|
|
557
|
+
for (const { prop } of this._loops ?? []) {
|
|
558
|
+
if (REACH_PROPS.has(prop)) {
|
|
559
|
+
inPlace = false;
|
|
560
|
+
} else if (isLayoutProp(prop) || TEXT_LAYOUT_PROPS.has(prop)) {
|
|
561
|
+
if (this.style?.position !== 'absolute') return null;
|
|
562
|
+
inPlace = false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (inPlace) return { rect: this.paintBounds(), from: this };
|
|
566
|
+
for (let n = this.parent; n && !n.isWindow; n = n.parent) {
|
|
567
|
+
if (n.clipsChildren()) return { rect: outerPixels(n.abs), from: n };
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
447
570
|
}
|
|
448
571
|
|
|
449
572
|
/** Whether anything between this node and its window has taken it off the
|
|
@@ -579,6 +702,42 @@ export class WindowAnimation {
|
|
|
579
702
|
for (const node of [...this._loopNodes]) node._updateLoops();
|
|
580
703
|
}
|
|
581
704
|
|
|
705
|
+
/**
|
|
706
|
+
* After the frame's layout and placements, before the presenter's word.
|
|
707
|
+
*
|
|
708
|
+
* Where a pass moved things (`moved`), a loop it put out of sight stops,
|
|
709
|
+
* and one it brought back runs again (`_loopsOutOfSight`) — from the top,
|
|
710
|
+
* as every stop here resumes, since nobody saw where it had got to. Only
|
|
711
|
+
* the loops whose answer changed are asked again, so a pass that moved
|
|
712
|
+
* nothing of theirs costs a walk up from each.
|
|
713
|
+
*
|
|
714
|
+
* And where a presenter can run a loop in the render server, a loop in
|
|
715
|
+
* sight that the clock runs is offered to it again (`_offerLoopsAgain`),
|
|
716
|
+
* each at most every `REOFFER_MS`: what turned it down can go without a
|
|
717
|
+
* layout pass — an ancestor's fade arriving is a paint — and the loop is
|
|
718
|
+
* a frame every refresh until it is taken.
|
|
719
|
+
*/
|
|
720
|
+
_recheckLoops(moved) {
|
|
721
|
+
const offer = typeof this.window?.wouldAnimateNode === 'function';
|
|
722
|
+
if (!moved && !offer) return;
|
|
723
|
+
const t = offer ? now() : 0;
|
|
724
|
+
for (const node of [...this._loopNodes]) {
|
|
725
|
+
if (moved) {
|
|
726
|
+
const out = node._loopsOutOfSight();
|
|
727
|
+
if (out !== (node._outOfSight === true)) {
|
|
728
|
+
node._outOfSight = out;
|
|
729
|
+
node._updateLoops();
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (!offer || node._outOfSight === true || node.destroyed) continue;
|
|
734
|
+
if (!(t - (node._offeredAt ?? -Infinity) >= REOFFER_MS)) continue;
|
|
735
|
+
if (!node.root?._animating.has(node)) continue; // the clock runs none
|
|
736
|
+
node._offeredAt = t;
|
|
737
|
+
node._offerLoopsAgain();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
582
741
|
/** A node in this window started a transition. */
|
|
583
742
|
_startAnimating(node) {
|
|
584
743
|
this._animating.add(node);
|
package/src/nodes/scrollable.js
CHANGED
|
@@ -950,6 +950,9 @@ export const Scrollable = (Base) =>
|
|
|
950
950
|
* its own route, since it paints through `_paintRegion` and never
|
|
951
951
|
* through `Node.paint`. */
|
|
952
952
|
_paintScrollbars(ctx) {
|
|
953
|
+
// on layers of their own, over the layers of the rows they are drawn
|
|
954
|
+
// over (src/cocoa/promotion.js `_syncThumbs`)
|
|
955
|
+
if (this._thumbsLifted) return;
|
|
953
956
|
for (const bar of this._scrollbars()) {
|
|
954
957
|
paintScrollbarThumb(ctx, bar, this.props.scrollbarColor);
|
|
955
958
|
}
|
|
@@ -286,9 +286,18 @@ export class WindowFlush {
|
|
|
286
286
|
}
|
|
287
287
|
// An animated placement asked for this frame and nothing laid out: its
|
|
288
288
|
// pass runs on its own, against the arrangement the last one left
|
|
289
|
+
let placed = false;
|
|
289
290
|
if (!layoutRan && this._placementsDue && this._placeNodes()) {
|
|
290
291
|
this.needsPaint = true;
|
|
292
|
+
placed = true;
|
|
291
293
|
}
|
|
294
|
+
// A loop this arrangement put out of sight stops, and one it brought
|
|
295
|
+
// back runs again; one the clock runs is offered to the presenter
|
|
296
|
+
// again (`_recheckLoops`). After the pass and the placements, so it is
|
|
297
|
+
// judged where things landed, and before the presenter's word, so a
|
|
298
|
+
// loop handed to it, or taken back, is in this frame. Nothing moves
|
|
299
|
+
// anything but these two passes, so sight is judged only after one.
|
|
300
|
+
if (this._loopNodes.size) this._recheckLoops(layoutRan || placed);
|
|
292
301
|
// A presenter compositing part of the tree on layers of its own — the
|
|
293
302
|
// surface presenter's promoted nodes (src/cocoa/promotion.js) — gets
|
|
294
303
|
// its word in here: after layout, so it sees where everything landed,
|
package/src/testing/harness.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { createRoot } from '../Reconciler.js';
|
|
12
12
|
import { setAnimationClock } from '../nodes/animation.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
pinnedAppearanceForTests,
|
|
15
|
+
setAppearanceForTests,
|
|
16
|
+
} from '../appearance.js';
|
|
14
17
|
|
|
15
18
|
// A test process must not register with the desktop's live AT-SPI registry —
|
|
16
19
|
// a suite on a developer desktop would otherwise parade hundreds of phantom
|
|
@@ -140,10 +143,26 @@ export async function renderX11(element, options = {}) {
|
|
|
140
143
|
wrap = element?.type !== 'window',
|
|
141
144
|
title = 'react-x11 test',
|
|
142
145
|
colorScheme = 'light',
|
|
146
|
+
appearance = null,
|
|
143
147
|
a11y = false,
|
|
144
148
|
...rootOptions
|
|
145
149
|
} = options;
|
|
146
150
|
|
|
151
|
+
// Checked before anything is created, so a mistake costs no server
|
|
152
|
+
const pin = appearancePin(colorScheme, appearance);
|
|
153
|
+
if (setBeforeMount) {
|
|
154
|
+
const values = setBeforeMount;
|
|
155
|
+
setBeforeMount = null;
|
|
156
|
+
throw new Error(
|
|
157
|
+
'react-x11/test: setAppearance() was called before renderX11(), which ' +
|
|
158
|
+
'pins the appearance as it mounts — the values it set would be ' +
|
|
159
|
+
'replaced without a word. Pass them to the render instead:\n\n' +
|
|
160
|
+
` await renderX11(<App />, { appearance: ${JSON.stringify(values)} });\n\n` +
|
|
161
|
+
'setAppearance() is for changing them once the tree is mounted. ' +
|
|
162
|
+
'See docs/testing.md#the-desktops-appearance.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
let server = null;
|
|
148
167
|
let app = options.app ?? null;
|
|
149
168
|
if (!app) {
|
|
@@ -161,14 +180,9 @@ export async function renderX11(element, options = {}) {
|
|
|
161
180
|
// on a light desktop, red on a dark one, with nothing in the test saying
|
|
162
181
|
// so. After the app exists, so it wins over `createMockApp`'s own pin.
|
|
163
182
|
// `colorScheme: 'dark'` renders the other palette; `'system'` releases the
|
|
164
|
-
// pin, for a test that is *about* what the desktop reports
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
? null
|
|
168
|
-
: colorScheme === 'dark'
|
|
169
|
-
? { colorScheme: 'dark' }
|
|
170
|
-
: {},
|
|
171
|
-
);
|
|
183
|
+
// pin, for a test that is *about* what the desktop reports; `appearance`
|
|
184
|
+
// pins the rest of what `useSystemAppearance()` reports.
|
|
185
|
+
setAppearanceForTests(pin);
|
|
172
186
|
|
|
173
187
|
const root = await createRoot({ app, ...rootOptions });
|
|
174
188
|
|
|
@@ -374,6 +388,131 @@ export async function cleanup() {
|
|
|
374
388
|
// change, every mounted root repaints on one, and a repaint scheduled onto
|
|
375
389
|
// a connection that is about to close lands after it has.
|
|
376
390
|
setAppearanceForTests(null);
|
|
391
|
+
setBeforeMount = null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- the desktop's appearance ----------------------------------------------
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What a test may pin, and what each value may be — the fields
|
|
398
|
+
* `useSystemAppearance()` reports, less `source`, which reads `'test'` for as
|
|
399
|
+
* long as anything is pinned.
|
|
400
|
+
*
|
|
401
|
+
* Checked rather than passed through, because the store takes anything: a
|
|
402
|
+
* `reduceMotion: true` would pin nothing, render the component the way it
|
|
403
|
+
* always does, and pass a test that was meant to prove the opposite.
|
|
404
|
+
*/
|
|
405
|
+
const APPEARANCE = {
|
|
406
|
+
colorScheme: [
|
|
407
|
+
(v) => v === 'light' || v === 'dark' || v === 'no-preference',
|
|
408
|
+
"'light', 'dark' or 'no-preference'",
|
|
409
|
+
],
|
|
410
|
+
accent: [colourOrNull, "a CSS colour such as '#ed5b00', or null"],
|
|
411
|
+
accentText: [colourOrNull, "a CSS colour such as '#ffffff', or null"],
|
|
412
|
+
selection: [colourOrNull, "a CSS colour such as '#c96003', or null"],
|
|
413
|
+
palette: [
|
|
414
|
+
(v) => v === null || (typeof v === 'object' && !Array.isArray(v)),
|
|
415
|
+
'an object of palette tokens, or null',
|
|
416
|
+
],
|
|
417
|
+
contrast: [(v) => v === 'normal' || v === 'high', "'normal' or 'high'"],
|
|
418
|
+
reducedMotion: [(v) => typeof v === 'boolean', 'true or false'],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function colourOrNull(value) {
|
|
422
|
+
return value === null || (typeof value === 'string' && value !== '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function checkAppearance(values, where) {
|
|
426
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
|
427
|
+
throw new TypeError(
|
|
428
|
+
`react-x11/test: ${where} takes an object of appearance values, such ` +
|
|
429
|
+
`as { reducedMotion: true } — got ${String(values)}.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
for (const [key, value] of Object.entries(values)) {
|
|
433
|
+
if (key === 'source') {
|
|
434
|
+
throw new TypeError(
|
|
435
|
+
`react-x11/test: ${where} cannot set \`source\` — a pinned ` +
|
|
436
|
+
"appearance reports 'test', which is how a component can tell " +
|
|
437
|
+
'one from a desktop that answered.',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const rule = APPEARANCE[key];
|
|
441
|
+
if (!rule) {
|
|
442
|
+
throw new TypeError(
|
|
443
|
+
`react-x11/test: ${where} has no appearance value \`${key}\`. ` +
|
|
444
|
+
`The values are ${Object.keys(APPEARANCE).join(', ')} — the ` +
|
|
445
|
+
'fields useSystemAppearance() reports.',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (!rule[0](value)) {
|
|
449
|
+
throw new TypeError(
|
|
450
|
+
`react-x11/test: ${where}: \`${key}\` is ${rule[1]}, not ` +
|
|
451
|
+
`${JSON.stringify(value) ?? String(value)}.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return values;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `renderX11`'s two appearance options → the pin, or null to release. */
|
|
459
|
+
function appearancePin(colorScheme, appearance) {
|
|
460
|
+
if (colorScheme === 'system') {
|
|
461
|
+
if (appearance) {
|
|
462
|
+
throw new TypeError(
|
|
463
|
+
"react-x11/test: renderX11 was given colorScheme: 'system', which " +
|
|
464
|
+
'follows the real desktop, and `appearance`, which pins it. Pass ' +
|
|
465
|
+
'one: pin `colorScheme` inside `appearance`, or drop `appearance` ' +
|
|
466
|
+
'to follow the desktop.',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (colorScheme !== 'light' && colorScheme !== 'dark') {
|
|
472
|
+
throw new TypeError(
|
|
473
|
+
"react-x11/test: renderX11's colorScheme is 'light' (the default), " +
|
|
474
|
+
`'dark' or 'system', not ${JSON.stringify(colorScheme)}.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
// Light is pinned as "the desktop said nothing", which resolves to the
|
|
479
|
+
// light palette and is what the mock app pins too
|
|
480
|
+
...(colorScheme === 'dark' ? { colorScheme: 'dark' } : {}),
|
|
481
|
+
...(appearance ? checkAppearance(appearance, 'renderX11 appearance') : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Values set with nothing mounted, which the next `renderX11` would lose. */
|
|
486
|
+
let setBeforeMount = null;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Change the pinned appearance under a mounted tree, and flush what it
|
|
490
|
+
* changed — the re-render of everything reading `useSystemAppearance()`, the
|
|
491
|
+
* palette's repaint, and the frame — the way `userEvent` flushes an input.
|
|
492
|
+
*
|
|
493
|
+
* Merged over what is pinned: `setAppearance({ reducedMotion: true })` under
|
|
494
|
+
* a `colorScheme: 'dark'` render stays dark. Where nothing is pinned
|
|
495
|
+
* (`colorScheme: 'system'`), it pins these values over the defaults rather
|
|
496
|
+
* than over this machine's answer, so the result is the test's alone.
|
|
497
|
+
*
|
|
498
|
+
* ```js
|
|
499
|
+
* await renderX11(<Spinner />, { appearance: { reducedMotion: true } });
|
|
500
|
+
* await setAppearance({ reducedMotion: false });
|
|
501
|
+
* ```
|
|
502
|
+
*
|
|
503
|
+
* The appearance is the desktop's, so it is one for the process: every
|
|
504
|
+
* mounted root sees the change, as every window would. `cleanup()` releases
|
|
505
|
+
* it, and a pinned value is never written to the cache the next run of a
|
|
506
|
+
* real app reads.
|
|
507
|
+
*/
|
|
508
|
+
export async function setAppearance(values) {
|
|
509
|
+
checkAppearance(values, 'setAppearance()');
|
|
510
|
+
// `source` comes along and does not matter: the pin overwrites it
|
|
511
|
+
const next = { ...pinnedAppearanceForTests(), ...values };
|
|
512
|
+
if (mounted.size === 0) setBeforeMount = { ...setBeforeMount, ...values };
|
|
513
|
+
await act(() => {
|
|
514
|
+
setAppearanceForTests(next);
|
|
515
|
+
});
|
|
377
516
|
}
|
|
378
517
|
|
|
379
518
|
// --- the animation clock ---------------------------------------------------
|
package/src/testing/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
import type { DrawnNode, NtkApp, NtkWindow } from '../types/nodes.js';
|
|
8
|
+
import type { SystemAppearance } from '../types/appearance.js';
|
|
8
9
|
import type { ModifierName } from '../keysyms.js';
|
|
9
10
|
|
|
10
11
|
export * from '../keysyms.js';
|
|
@@ -29,6 +30,14 @@ export interface TestServer {
|
|
|
29
30
|
/** ntk's 2d context. Pixel helpers take it; `renderX11` hands it back. */
|
|
30
31
|
export type TestContext2D = unknown;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The desktop's appearance as a test pins it: any of the values
|
|
35
|
+
* `useSystemAppearance()` reports, except `source`, which reads `'test'` for
|
|
36
|
+
* as long as anything is pinned. A key outside these throws, so a typo cannot
|
|
37
|
+
* pin nothing and pass.
|
|
38
|
+
*/
|
|
39
|
+
export type PinnedAppearance = Partial<Omit<SystemAppearance, 'source'>>;
|
|
40
|
+
|
|
32
41
|
export interface RenderX11Options {
|
|
33
42
|
/** Window size (default 640×480). */
|
|
34
43
|
width?: number;
|
|
@@ -59,6 +68,21 @@ export interface RenderX11Options {
|
|
|
59
68
|
title?: string;
|
|
60
69
|
/** Render into a connection you already have. */
|
|
61
70
|
app?: NtkApp;
|
|
71
|
+
/**
|
|
72
|
+
* The desktop's colour scheme, **pinned** so a pixel assertion means the
|
|
73
|
+
* same on every machine. `'light'` (the default) and `'dark'` pick the
|
|
74
|
+
* built-in palette; `'system'` releases the pin, for a test that is about
|
|
75
|
+
* what the real desktop reports.
|
|
76
|
+
*/
|
|
77
|
+
colorScheme?: 'light' | 'dark' | 'system';
|
|
78
|
+
/**
|
|
79
|
+
* The rest of the desktop's appearance, pinned from the first render:
|
|
80
|
+
* reduced motion, contrast, the accent. Merged over `colorScheme`, so
|
|
81
|
+
* `appearance.colorScheme` wins where both name one; it cannot be combined
|
|
82
|
+
* with `colorScheme: 'system'`. To change it while mounted, use
|
|
83
|
+
* `setAppearance()`.
|
|
84
|
+
*/
|
|
85
|
+
appearance?: PinnedAppearance;
|
|
62
86
|
/**
|
|
63
87
|
* Install the assistive-technology spy before the mount and hand it back
|
|
64
88
|
* as `at`: an in-process log of everything a screen reader would have
|
|
@@ -288,9 +312,30 @@ export function renderX11(
|
|
|
288
312
|
*/
|
|
289
313
|
export function act(fn?: () => unknown): Promise<void>;
|
|
290
314
|
|
|
291
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* Unmount everything, restore the animation clock, release the pinned
|
|
317
|
+
* appearance, close every server.
|
|
318
|
+
*/
|
|
292
319
|
export function cleanup(): Promise<void>;
|
|
293
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Change the pinned appearance under a mounted tree and flush what it moved:
|
|
323
|
+
* every component reading `useSystemAppearance()` re-renders, the palette
|
|
324
|
+
* repaints, and the frame lands — the way `userEvent` flushes an input.
|
|
325
|
+
*
|
|
326
|
+
* Merged over what is pinned, so `setAppearance({ reducedMotion: true })`
|
|
327
|
+
* under a `colorScheme: 'dark'` render stays dark. The appearance is the
|
|
328
|
+
* desktop's and so one per process: every mounted root sees the change.
|
|
329
|
+
* Calling it before `renderX11()` is an error at that render, which pins as
|
|
330
|
+
* it mounts — pass `appearance` to the render instead.
|
|
331
|
+
*
|
|
332
|
+
* ```ts
|
|
333
|
+
* await renderX11(<Spinner />, { appearance: { reducedMotion: true } });
|
|
334
|
+
* await setAppearance({ reducedMotion: false });
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
338
|
+
|
|
294
339
|
/** Drain in-flight requests on a connection. */
|
|
295
340
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
296
341
|
|
package/src/testing/index.js
CHANGED
|
@@ -20,10 +20,10 @@ export type ColorSchemePreference = 'system' | 'light' | 'dark';
|
|
|
20
20
|
*
|
|
21
21
|
* `'cache'` is the answer this machine gave last time, read off disk before
|
|
22
22
|
* the first render so it does not start from the defaults. A real rung
|
|
23
|
-
* replaces it a moment later.
|
|
23
|
+
* replaces it a moment later. `'test'` is a value `react-x11/test` pinned.
|
|
24
24
|
*/
|
|
25
25
|
export type AppearanceSource =
|
|
26
|
-
'portal' | 'xsettings' | 'macos' | 'cache' | null;
|
|
26
|
+
'portal' | 'xsettings' | 'macos' | 'windows' | 'cache' | 'test' | null;
|
|
27
27
|
|
|
28
28
|
export interface SystemAppearance {
|
|
29
29
|
readonly colorScheme: ColorScheme;
|
package/src/types/system.d.ts
CHANGED
|
@@ -247,6 +247,12 @@ export interface DesktopSettings {
|
|
|
247
247
|
readonly doubleClickDistance: number;
|
|
248
248
|
/** How far a press moves before it is a drag rather than a click. */
|
|
249
249
|
readonly dragThreshold: number;
|
|
250
|
+
/** **False means hold still** — the desktop asked for less motion:
|
|
251
|
+
* `Gtk/EnableAnimations` off over XSETTINGS, reduce motion on macOS.
|
|
252
|
+
* Core's loops do not start then (docs/styling.md, "Loops"); an element
|
|
253
|
+
* that animates on a timer of its own holds still too, and draws its
|
|
254
|
+
* resting frame. True wherever the desktop said nothing. */
|
|
255
|
+
readonly animations: boolean;
|
|
250
256
|
/** `'xsettings'`; `'macos'` on the Cocoa backend, where only `animations`
|
|
251
257
|
* comes from the system (reduce motion) and the rest are the defaults; or
|
|
252
258
|
* null where no settings daemon answered and these are the renderer's own
|