react-x11 2.18.3 → 2.18.5
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/Reconciler.js +8 -1
- package/src/appcontext.js +10 -5
- package/src/backend/context2d.js +65 -18
- package/src/glnodes.js +34 -3
- package/src/globalmenu.js +104 -8
- package/src/gloverlay.js +118 -31
- package/src/index.d.ts +4 -1
- package/src/nodes/invalidate.js +10 -2
- package/src/nodes/node.js +15 -3
- package/src/nodes/scrollblit.js +12 -2
- package/src/nodes/window/flush.js +20 -3
- package/src/statusnotifier.js +49 -7
- package/src/svgnodes.js +76 -15
- package/src/types/elements.d.ts +6 -5
- package/src/win32/a11y.js +66 -6
- package/src/win32/app.js +83 -9
- package/src/win32/fonts.js +26 -7
- package/src/win32/overlay.js +244 -0
- package/src/win32/panewindow.js +6 -0
- package/src/win32/window.js +71 -1
package/src/nodes/node.js
CHANGED
|
@@ -605,6 +605,18 @@ export class Node {
|
|
|
605
605
|
// which is what keeps a commit from widening the region to every node
|
|
606
606
|
// it touched.
|
|
607
607
|
if (layoutChanged) {
|
|
608
|
+
const moved = onlyInsetsChanged(style, prevStyle);
|
|
609
|
+
// An absolutely positioned box of a set size that moved and changed
|
|
610
|
+
// nothing else is out of its ancestors' flow and sized by its width
|
|
611
|
+
// and height rather than its insets, so no content floor anywhere
|
|
612
|
+
// moved with it (src/nodes/invalidate.js, `'position'`).
|
|
613
|
+
const reason =
|
|
614
|
+
moved &&
|
|
615
|
+
style.position === 'absolute' &&
|
|
616
|
+
typeof style.width === 'number' &&
|
|
617
|
+
typeof style.height === 'number'
|
|
618
|
+
? 'position'
|
|
619
|
+
: 'props';
|
|
608
620
|
// A child of a `<glarea>` that only moved (issue #644): its pixels are
|
|
609
621
|
// on a pane, one shift away, and the layout pass reports the move to
|
|
610
622
|
// the overlay rather than claiming it (src/glnodes.js). A claim of the
|
|
@@ -613,12 +625,12 @@ export class Node {
|
|
|
613
625
|
// whenever the overlay cannot move the pixels.
|
|
614
626
|
if (
|
|
615
627
|
this.parent?.isGlArea &&
|
|
616
|
-
|
|
628
|
+
moved &&
|
|
617
629
|
!this.paintChanged(newProps, prev)
|
|
618
630
|
) {
|
|
619
|
-
this.invalidate(true, NO_DAMAGE,
|
|
631
|
+
this.invalidate(true, NO_DAMAGE, reason);
|
|
620
632
|
} else {
|
|
621
|
-
this._invalidateLayout(
|
|
633
|
+
this._invalidateLayout(reason);
|
|
622
634
|
}
|
|
623
635
|
} else {
|
|
624
636
|
// The style half is asked here rather than inside `paintChanged`, and
|
package/src/nodes/scrollblit.js
CHANGED
|
@@ -970,12 +970,22 @@ export class WindowScrollBlit {
|
|
|
970
970
|
if (ancestors.has(child)) {
|
|
971
971
|
// on the path down: its solid background under the viewport is
|
|
972
972
|
// translation-invariant, its border ring and corners are not.
|
|
973
|
-
// The widest side is conservative for a non-uniform border
|
|
973
|
+
// The widest side is conservative for a non-uniform border.
|
|
974
|
+
//
|
|
975
|
+
// Rounded, because that is where the ring ends for what is
|
|
976
|
+
// inside it: at a fractional scale a 1px border is 1.25 or 1.5
|
|
977
|
+
// device pixels, and layout puts the children at the rounded
|
|
978
|
+
// inner edge — so the column the ring reaches a quarter of the
|
|
979
|
+
// way into is a child's, painted over whole. Tested exactly, that
|
|
980
|
+
// column refused every blit under a bordered box at 125%, the
|
|
981
|
+
// scale most Windows laptops run at.
|
|
974
982
|
const bw = resolveBorderWidths(
|
|
975
983
|
child.style ?? EMPTY_STYLE,
|
|
976
984
|
child.direction,
|
|
977
985
|
);
|
|
978
|
-
const ring = Math.
|
|
986
|
+
const ring = Math.round(
|
|
987
|
+
Math.max(bw.top, bw.right, bw.bottom, bw.left),
|
|
988
|
+
);
|
|
979
989
|
if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
|
|
980
990
|
return false;
|
|
981
991
|
}
|
|
@@ -326,9 +326,26 @@ export class WindowFlush {
|
|
|
326
326
|
// list was still taken (its bookkeeping is what keeps the two paths one
|
|
327
327
|
// code) and is simply not consumed; the presenter diffs at the layer.
|
|
328
328
|
if (typeof this.window.presentFrame === 'function') {
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
// A presenter whose frame is the ordinary paint walk — a pass per
|
|
330
|
+
// damage rect into a context, the Windows window's — paints through
|
|
331
|
+
// the paint cache as an ntk window does, and says so
|
|
332
|
+
// (`usesPaintCache`). Without it every `<canvas cacheKey>` drew
|
|
333
|
+
// afresh in every pass that reached it, and a shadow, which is only
|
|
334
|
+
// ever baked into the cache (issue #413), was not drawn at all. The
|
|
335
|
+
// retained layer presenter paints nodes onto layers of their own and
|
|
336
|
+
// keeps to itself.
|
|
337
|
+
const cache = this.window.usesPaintCache
|
|
338
|
+
? (this._paintCache ??= paintCacheFor(this.app))
|
|
339
|
+
: null;
|
|
340
|
+
cache?.beginFrame();
|
|
341
|
+
try {
|
|
342
|
+
// the panes are no presenter's: they paint from their damage either
|
|
343
|
+
// way
|
|
344
|
+
if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
|
|
345
|
+
this.window.presentFrame(this, damage);
|
|
346
|
+
} finally {
|
|
347
|
+
cache?.endFrame();
|
|
348
|
+
}
|
|
332
349
|
this.app._reactX11Startup?.painted();
|
|
333
350
|
return true;
|
|
334
351
|
}
|
package/src/statusnotifier.js
CHANGED
|
@@ -101,6 +101,13 @@ const PIXMAP_SIGNATURE = 'a(iiay)';
|
|
|
101
101
|
* the *same* tray icon and must come back on the same path — see `stop()`. */
|
|
102
102
|
let nextItemIndex = 1;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Per slot, the moment it is free: settles once every item that has started
|
|
106
|
+
* on it so far has stopped. The next item to start on the slot waits for it —
|
|
107
|
+
* see `start()`.
|
|
108
|
+
*/
|
|
109
|
+
const vacancies = new Map();
|
|
110
|
+
|
|
104
111
|
/** Claim a tray slot. `useTray()` takes one per hook instance, for its life. */
|
|
105
112
|
export function allocateItemSlot() {
|
|
106
113
|
return nextItemIndex++;
|
|
@@ -232,6 +239,11 @@ export class StatusNotifierItem {
|
|
|
232
239
|
/** Set while withdrawing, so `Status` reads `Passive` on the way out —
|
|
233
240
|
* see `announcePassive()`. */
|
|
234
241
|
this.withdrawing = false;
|
|
242
|
+
/** Settles once `stop()` has finished, which is what the next item on
|
|
243
|
+
* this slot waits for — see `start()`. */
|
|
244
|
+
this.gone = new Promise((resolve) => {
|
|
245
|
+
this.markGone = resolve;
|
|
246
|
+
});
|
|
235
247
|
|
|
236
248
|
this.ref = null;
|
|
237
249
|
this.dbus = null;
|
|
@@ -252,6 +264,24 @@ export class StatusNotifierItem {
|
|
|
252
264
|
// ------------------------------------------------------------------ setup
|
|
253
265
|
|
|
254
266
|
async start() {
|
|
267
|
+
// **One item per slot at a time.** A hook switched off and on again puts
|
|
268
|
+
// its new item on the old one's path while the old one is still leaving:
|
|
269
|
+
// `announcePassive()` holds its object open for a moment, and its
|
|
270
|
+
// teardown comes after that. dbus-native's `registration.remove()`
|
|
271
|
+
// unexports whatever is at the path when it runs, whoever exported it,
|
|
272
|
+
// so a successor already there would lose its object — and its menu —
|
|
273
|
+
// while the watcher kept its registration: a dead icon. So an item starts
|
|
274
|
+
// once every item before it on the slot has stopped. Every one, not only
|
|
275
|
+
// the last: an item stopped while it was still waiting is gone at once,
|
|
276
|
+
// and the one it was waiting for may not be.
|
|
277
|
+
const before = vacancies.get(this.index);
|
|
278
|
+
const vacated = Promise.all([before, this.gone]);
|
|
279
|
+
vacancies.set(this.index, vacated);
|
|
280
|
+
void vacated.then(() => {
|
|
281
|
+
if (vacancies.get(this.index) === vacated) vacancies.delete(this.index);
|
|
282
|
+
});
|
|
283
|
+
await before;
|
|
284
|
+
|
|
255
285
|
const ref = await sessionBus();
|
|
256
286
|
// No bus is a first-class configuration, not a degraded one: ssh, a bare
|
|
257
287
|
// startx, CI, Node 20 without the transport. There is simply no tray.
|
|
@@ -450,6 +480,8 @@ export class StatusNotifierItem {
|
|
|
450
480
|
*
|
|
451
481
|
* The pair to this is the **stable path** (`slot`): coming back re-registers
|
|
452
482
|
* the same id, which a host dedupes to a reset rather than a second icon.
|
|
483
|
+
* The same path is why the item coming back waits for this hold, and the
|
|
484
|
+
* teardown after it, to finish — see `start()`.
|
|
453
485
|
*/
|
|
454
486
|
async announcePassive() {
|
|
455
487
|
if (!this.exported || !this.iface) return;
|
|
@@ -479,12 +511,18 @@ export class StatusNotifierItem {
|
|
|
479
511
|
}
|
|
480
512
|
|
|
481
513
|
async stop() {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
514
|
+
try {
|
|
515
|
+
// Announced **before** `stopped`, which gates `_sync()` and every other
|
|
516
|
+
// path that could tear the export out from under the signal.
|
|
517
|
+
await this.announcePassive();
|
|
518
|
+
this.stopped = true;
|
|
519
|
+
await this.syncing;
|
|
520
|
+
await this.teardown();
|
|
521
|
+
} finally {
|
|
522
|
+
// Whatever happened, the slot is free: the next item on it would
|
|
523
|
+
// otherwise wait for good, and the icon never come back.
|
|
524
|
+
this.markGone();
|
|
525
|
+
}
|
|
488
526
|
}
|
|
489
527
|
|
|
490
528
|
async teardown() {
|
|
@@ -746,7 +784,11 @@ export class StatusNotifierItem {
|
|
|
746
784
|
}
|
|
747
785
|
}
|
|
748
786
|
|
|
749
|
-
/** Test seam, not public: make paths predictable across test files
|
|
787
|
+
/** Test seam, not public: make paths predictable across test files, and
|
|
788
|
+
* forget who held them. A previous test's items are on a bus it has closed,
|
|
789
|
+
* so there is nothing to wait for, and one it never stopped would otherwise
|
|
790
|
+
* hold its slot for the rest of the run. */
|
|
750
791
|
export function _resetItemIndex() {
|
|
751
792
|
nextItemIndex = 1;
|
|
793
|
+
vacancies.clear();
|
|
752
794
|
}
|
package/src/svgnodes.js
CHANGED
|
@@ -51,23 +51,57 @@ function svgAttribs(props, skip) {
|
|
|
51
51
|
const value = props[key];
|
|
52
52
|
if (key === 'children' || value == null) continue;
|
|
53
53
|
if (typeof value === 'function') continue;
|
|
54
|
+
// React's, not the drawing's. Since React 19 a ref arrives as an
|
|
55
|
+
// ordinary prop, and an object one — `useRef()` — was written into the
|
|
56
|
+
// document as `ref="[object Object]"`. `key` would be the same if React
|
|
57
|
+
// ever passed it on.
|
|
58
|
+
if (key === 'ref' || key === 'key') continue;
|
|
54
59
|
if (skip && skip(key)) continue;
|
|
55
60
|
attribs[toSvgAttrib(key)] = String(value);
|
|
56
61
|
}
|
|
57
62
|
return attribs;
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
/** Whether two `svgAttribs` results say the same thing. Order aside: the
|
|
66
|
+
* document reads an attribute by its name. */
|
|
67
|
+
function sameAttribs(a, b) {
|
|
68
|
+
const names = Object.keys(a);
|
|
69
|
+
if (names.length !== Object.keys(b).length) return false;
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
if (a[name] !== b[name]) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Props of the `<svg>` element that are react-x11's rather than the
|
|
77
|
+
// drawing's: `source`, the other form of the content, and what lays the
|
|
78
|
+
// element out, styles it and takes its input. `style` is one of them, an
|
|
79
|
+
// object that reached the document as "[object Object]".
|
|
80
|
+
const notAnAttribute = (key) =>
|
|
81
|
+
key === 'source' ||
|
|
82
|
+
key === 'style' ||
|
|
83
|
+
isLayoutProp(key) ||
|
|
84
|
+
isPaintProp(key) ||
|
|
85
|
+
key === 'cursor' ||
|
|
86
|
+
key === 'focusable' ||
|
|
87
|
+
key === 'pointerEvents';
|
|
88
|
+
|
|
60
89
|
/**
|
|
61
90
|
* An element inside <svg>: no yoga node, no painting of its own — it only
|
|
62
91
|
* carries a tag name and props, and is serialized into the htmlparser2-style
|
|
63
|
-
* DOM that SvgView consumes.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
92
|
+
* DOM that SvgView consumes. A change that reaches that DOM — an attribute
|
|
93
|
+
* that serializes differently, a child added or removed — bubbles to the
|
|
94
|
+
* owning SvgNode via the _textContentChanged channel (also used by text
|
|
95
|
+
* chunks, so string children of an SVG <text> invalidate the same way).
|
|
66
96
|
*/
|
|
67
97
|
export class SvgChildNode extends Node {
|
|
68
98
|
constructor(tag, props, app) {
|
|
69
99
|
super(tag, props, app, { yoga: false });
|
|
70
100
|
this.isSvgChild = true;
|
|
101
|
+
/** What this element writes into the document: serialized once per
|
|
102
|
+
* change rather than once per rebuild, and what a commit is compared
|
|
103
|
+
* against. */
|
|
104
|
+
this._attribs = svgAttribs(props);
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
// <circle cx r fill>… is SVG's vocabulary, not the style channel's
|
|
@@ -81,14 +115,34 @@ export class SvgChildNode extends Node {
|
|
|
81
115
|
|
|
82
116
|
applyProps(newProps, oldProps) {
|
|
83
117
|
super.applyProps(newProps, oldProps);
|
|
118
|
+
// React commits every child of an `<svg>` it re-renders, each with a new
|
|
119
|
+
// props object and almost always with every attribute as it was — and a
|
|
120
|
+
// rebuild re-parses the whole drawing and re-measures the `<svg>`. So
|
|
121
|
+
// only a commit that serializes differently is a change.
|
|
122
|
+
const attribs = svgAttribs(newProps);
|
|
123
|
+
if (sameAttribs(attribs, this._attribs)) return;
|
|
124
|
+
this._attribs = attribs;
|
|
84
125
|
this._textContentChanged();
|
|
85
126
|
}
|
|
86
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Nothing this node draws, whatever changed: the `<svg>` paints the whole
|
|
130
|
+
* document, and a change to it claims that element's box on the way
|
|
131
|
+
* (`SvgNode._textContentChanged`). This node has no box — its rect is an
|
|
132
|
+
* empty one at the window's origin — so core's answer, a claim of that
|
|
133
|
+
* rect whenever a prop is a new value, repainted a corner nothing here
|
|
134
|
+
* drew, on every commit that handed over an attribute rebuilt equal: a
|
|
135
|
+
* `points` or `strokeDasharray` array, say.
|
|
136
|
+
*/
|
|
137
|
+
paintChanged() {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
87
141
|
toDom() {
|
|
88
142
|
return {
|
|
89
143
|
type: 'tag',
|
|
90
144
|
name: this.kind,
|
|
91
|
-
attribs:
|
|
145
|
+
attribs: this._attribs,
|
|
92
146
|
children: this.children
|
|
93
147
|
.map((c) =>
|
|
94
148
|
c.kind === 'textchunk'
|
|
@@ -114,6 +168,9 @@ export class SvgNode extends Node {
|
|
|
114
168
|
super('svg', props, app);
|
|
115
169
|
this.view = null;
|
|
116
170
|
this._stale = true;
|
|
171
|
+
/** This element's own attributes in children form, kept like a child's
|
|
172
|
+
* for the same comparison. */
|
|
173
|
+
this._attribs = svgAttribs(props, notAnAttribute);
|
|
117
174
|
/** Bumped every time the document is rebuilt, and sampled at paint time,
|
|
118
175
|
* so `paintCachePlan` can tell an animated document from a settled one.
|
|
119
176
|
* See the note there. */
|
|
@@ -138,7 +195,9 @@ export class SvgNode extends Node {
|
|
|
138
195
|
);
|
|
139
196
|
}
|
|
140
197
|
|
|
141
|
-
/** Any change to the
|
|
198
|
+
/** Any change to the document — `source`, an attribute, a child, a text
|
|
199
|
+
* chunk — lands here, and only such a change: a commit that serializes
|
|
200
|
+
* the same never calls it, so it rebuilds nothing and claims nothing. */
|
|
142
201
|
_textContentChanged() {
|
|
143
202
|
this._stale = true;
|
|
144
203
|
this.invalidateMeasure('props');
|
|
@@ -155,17 +214,10 @@ export class SvgNode extends Node {
|
|
|
155
214
|
(c) => c.isSvgChild || c.kind === 'textchunk',
|
|
156
215
|
);
|
|
157
216
|
if (children.length > 0) {
|
|
158
|
-
const skip = (key) =>
|
|
159
|
-
key === 'source' ||
|
|
160
|
-
isLayoutProp(key) ||
|
|
161
|
-
isPaintProp(key) ||
|
|
162
|
-
key === 'cursor' ||
|
|
163
|
-
key === 'focusable' ||
|
|
164
|
-
key === 'pointerEvents';
|
|
165
217
|
const dom = {
|
|
166
218
|
type: 'tag',
|
|
167
219
|
name: 'svg',
|
|
168
|
-
attribs:
|
|
220
|
+
attribs: this._attribs,
|
|
169
221
|
children: children.map((c) =>
|
|
170
222
|
c.kind === 'textchunk' ? { type: 'text', data: c.text } : c.toDom(),
|
|
171
223
|
),
|
|
@@ -190,9 +242,18 @@ export class SvgNode extends Node {
|
|
|
190
242
|
}
|
|
191
243
|
|
|
192
244
|
applyProps(newProps, oldProps) {
|
|
245
|
+
const source = this.props.source;
|
|
193
246
|
super.applyProps(newProps, oldProps);
|
|
194
|
-
//
|
|
195
|
-
//
|
|
247
|
+
// The document is `source`, or this element's attributes over its
|
|
248
|
+
// children's, whose changes arrive on their own. A commit that leaves
|
|
249
|
+
// both as they were — most of them: a parent re-rendering, a new style
|
|
250
|
+
// object, a new ref — has nothing to re-parse or re-measure. The style
|
|
251
|
+
// is core's to diff, like any element's.
|
|
252
|
+
const attribs = svgAttribs(newProps, notAnAttribute);
|
|
253
|
+
if (newProps.source === source && sameAttribs(attribs, this._attribs)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this._attribs = attribs;
|
|
196
257
|
this._textContentChanged();
|
|
197
258
|
}
|
|
198
259
|
|
package/src/types/elements.d.ts
CHANGED
|
@@ -992,11 +992,12 @@ export type FrameLoop = 'demand' | 'always';
|
|
|
992
992
|
* `<glarea>`: a GL surface in the layout. Its **children** are 2D content
|
|
993
993
|
* drawn above the surface — laid out in its box like a `<box>`'s and cut to
|
|
994
994
|
* it, painted over the GL frame, hit before it — composited on the Cocoa
|
|
995
|
-
* backend
|
|
996
|
-
* asks whether they will be. The
|
|
997
|
-
* node: the handlers it inherits
|
|
998
|
-
* fire at the child under the
|
|
999
|
-
*
|
|
995
|
+
* backend, opaque on X11 (docs/elements.md), and not drawn at all on
|
|
996
|
+
* XQuartz; `useSupports('glOverlay')` asks whether they will be. The
|
|
997
|
+
* pointer over it is the tree's, as over any node: the handlers it inherits
|
|
998
|
+
* (`onMouseDown`, `onClick`, `onWheel`, …) fire at the child under the
|
|
999
|
+
* pointer or the `<glarea>` and bubble, on both backends, and
|
|
1000
|
+
* `GlAreaNode.forwardsPointer` says so at run time.
|
|
1000
1001
|
*/
|
|
1001
1002
|
export interface GlAreaProps extends DrawnProps<DrawnNode> {
|
|
1002
1003
|
/** CSS colour, or `[r, g, b, a]` floats. Default black. */
|
package/src/win32/a11y.js
CHANGED
|
@@ -36,6 +36,22 @@ import { onApp } from '../trace-registry.js';
|
|
|
36
36
|
|
|
37
37
|
const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* How often commits may push the mirror, in ms. A commit that follows a
|
|
41
|
+
* quiet spell pushes at once; a stream of them — a pan, a drag, a value
|
|
42
|
+
* ticking — pushes once per interval, the last of them catching up.
|
|
43
|
+
*
|
|
44
|
+
* UIA's clients are almost always listening on a desktop Windows (the
|
|
45
|
+
* touch keyboard, the text services and others ask for the tree), so the
|
|
46
|
+
* push is not the rare case the listening gate makes it elsewhere: a walk
|
|
47
|
+
* of the window and a marshalled diff, on every commit. For a graph pane
|
|
48
|
+
* whose every item moves on every step of a pan that was 1.6 ms of each
|
|
49
|
+
* frame. A screen reader reads positions on demand and does not need
|
|
50
|
+
* sixty of them a second; a focus change, which it is waiting on, still
|
|
51
|
+
* pushes at once (`hooks.focus`), as does an announcement.
|
|
52
|
+
*/
|
|
53
|
+
const COMMIT_PUSH_MS = 100;
|
|
54
|
+
|
|
39
55
|
// --------------------------------------------------------------------------
|
|
40
56
|
// The one translation that is Windows' own
|
|
41
57
|
// --------------------------------------------------------------------------
|
|
@@ -300,6 +316,9 @@ export class Win32Accessibility {
|
|
|
300
316
|
this._pushed = new Set();
|
|
301
317
|
this._unsubscribes = [];
|
|
302
318
|
this.dead = false;
|
|
319
|
+
/** When a commit last pushed, and the push a stream of commits owes. */
|
|
320
|
+
this._lastCommitPush = -Infinity;
|
|
321
|
+
this._trailing = null;
|
|
303
322
|
}
|
|
304
323
|
|
|
305
324
|
_idOf(node) {
|
|
@@ -388,15 +407,29 @@ export class Win32Accessibility {
|
|
|
388
407
|
* commit and paying nothing — the same shape as the AT-SPI bridge's "no
|
|
389
408
|
* bus, no work".
|
|
390
409
|
*
|
|
410
|
+
* *Nobody* is asked per window where the bridge can say (`uiaActive`):
|
|
411
|
+
* whether a client has read this window's tree in the last few seconds.
|
|
412
|
+
* UIA's own answer, `UiaClientsAreListening`, is whether any client on
|
|
413
|
+
* the desktop is subscribed to anything, and on a Windows running the
|
|
414
|
+
* touch keyboard or the text services that is always yes — so every
|
|
415
|
+
* commit walked and diffed the window for a mirror nobody read. A client
|
|
416
|
+
* that comes back after a quiet spell is noticed on its first read and
|
|
417
|
+
* asks for a fresh push (`wanted`). `global` keeps the desktop-wide answer
|
|
418
|
+
* for a focus change and an announcement: a client subscribed to focus
|
|
419
|
+
* events reads the focused element the moment it hears of it, before
|
|
420
|
+
* any read of ours could say it was there.
|
|
421
|
+
*
|
|
391
422
|
* With one exception, which is what makes the gate safe: **every window is
|
|
392
423
|
* pushed once regardless.** A client's first `WM_GETOBJECT` has to find a
|
|
393
424
|
* tree, and it arrives before anything in this process knows a client
|
|
394
425
|
* exists. After that first push the mirror is live, and a client attaching
|
|
395
426
|
* later asks for a fresh one (`uia-wanted`).
|
|
396
427
|
*/
|
|
397
|
-
flush({ force = false } = {}) {
|
|
428
|
+
flush({ force = false, global = false } = {}) {
|
|
398
429
|
if (this.dead || this._dirty.size === 0) return;
|
|
399
|
-
const
|
|
430
|
+
const perWindow =
|
|
431
|
+
!force && !global && typeof this._native.uiaActive === 'function';
|
|
432
|
+
const listening = force || (!perWindow && this._native.uiaListening());
|
|
400
433
|
const windows = [...this._dirty];
|
|
401
434
|
this._dirty.clear();
|
|
402
435
|
for (const win of windows) {
|
|
@@ -409,12 +442,37 @@ export class Win32Accessibility {
|
|
|
409
442
|
this._dirty.add(win);
|
|
410
443
|
continue;
|
|
411
444
|
}
|
|
412
|
-
if (
|
|
445
|
+
if (
|
|
446
|
+
!listening &&
|
|
447
|
+
this._pushed.has(wnd.id) &&
|
|
448
|
+
!(perWindow && this._native.uiaActive(wnd.id))
|
|
449
|
+
) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
413
452
|
this._pushed.add(wnd.id);
|
|
414
453
|
this._push(win);
|
|
415
454
|
}
|
|
416
455
|
}
|
|
417
456
|
|
|
457
|
+
/** A commit's push, at most once per `COMMIT_PUSH_MS`. */
|
|
458
|
+
_commitFlush() {
|
|
459
|
+
if (this.dead || this._dirty.size === 0) return;
|
|
460
|
+
const now = performance.now();
|
|
461
|
+
const wait = this._lastCommitPush + COMMIT_PUSH_MS - now;
|
|
462
|
+
if (wait <= 0) {
|
|
463
|
+
this._lastCommitPush = now;
|
|
464
|
+
this.flush();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this._trailing) return;
|
|
468
|
+
this._trailing = setTimeout(() => {
|
|
469
|
+
this._trailing = null;
|
|
470
|
+
this._lastCommitPush = performance.now();
|
|
471
|
+
this.flush();
|
|
472
|
+
}, wait);
|
|
473
|
+
this._trailing.unref?.();
|
|
474
|
+
}
|
|
475
|
+
|
|
418
476
|
/** A window's HWND exists now, so what is owed for it can be pushed. */
|
|
419
477
|
windowReady(wnd) {
|
|
420
478
|
const win = this.toplevels.find((w) => w.window === wnd);
|
|
@@ -546,12 +604,12 @@ export class Win32Accessibility {
|
|
|
546
604
|
// Pushed now rather than at the next commit: a focus change is what a
|
|
547
605
|
// screen reader is waiting for, and the event has to follow an update
|
|
548
606
|
// that already carries the new state or the client reads the old one.
|
|
549
|
-
this.flush();
|
|
607
|
+
this.flush({ global: true });
|
|
550
608
|
const wnd = this._windowOf(win);
|
|
551
609
|
if (wnd && next) this._native.uiaFocusChanged(wnd.id, this._idOf(next));
|
|
552
610
|
};
|
|
553
611
|
hooks.windowFocus = (win) => this._dirty.add(win);
|
|
554
|
-
hooks.commit = () => this.
|
|
612
|
+
hooks.commit = () => this._commitFlush();
|
|
555
613
|
hooks.announce = (text, opts) => {
|
|
556
614
|
const win =
|
|
557
615
|
this.toplevels.find((w) => w.events?.windowFocused) ??
|
|
@@ -562,7 +620,7 @@ export class Win32Accessibility {
|
|
|
562
620
|
// The window must be in the mirror before anything can be announced
|
|
563
621
|
// from it, which on the first announcement of a session it may not be.
|
|
564
622
|
this._dirty.add(win);
|
|
565
|
-
this.flush();
|
|
623
|
+
this.flush({ global: true });
|
|
566
624
|
return Boolean(
|
|
567
625
|
this._native.uiaAnnounce(wnd.id, String(text), !opts?.assertive),
|
|
568
626
|
);
|
|
@@ -579,6 +637,8 @@ export class Win32Accessibility {
|
|
|
579
637
|
bury() {
|
|
580
638
|
if (this.dead) return;
|
|
581
639
|
this.dead = true;
|
|
640
|
+
if (this._trailing) clearTimeout(this._trailing);
|
|
641
|
+
this._trailing = null;
|
|
582
642
|
for (const key of Object.keys(hooks)) hooks[key] = null;
|
|
583
643
|
for (const unsubscribe of this._unsubscribes) unsubscribe();
|
|
584
644
|
this._unsubscribes = [];
|