react-x11 2.18.4 → 2.18.6

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.
@@ -3,7 +3,7 @@
3
3
  // window, background first.
4
4
 
5
5
  import { isPlaced } from '../layouts.js';
6
- import { Surface } from '../ntk.js';
6
+ import { Surface } from '../offscreen.js';
7
7
  import { isPaintedColor } from './boxpaint.js';
8
8
  import { DAMAGE_SLOP } from './damage.js';
9
9
  import { DRAWN_KINDS } from './kinds.js';
@@ -301,11 +301,11 @@ export const Scrollable = (Base) =>
301
301
  // for a `<glarea>` child's move that this pane rides (issue
302
302
  // #644): its box claimed nothing, and a pane laid out again may
303
303
  // have moved rows the move's copy carries where they were.
304
- if (clamped || layoutDiff.ride) outer(vp);
304
+ if (clamped || layoutDiff.ride) outer(vp, this);
305
305
  } else {
306
- layoutDiff.sink = (rect) => {
306
+ layoutDiff.sink = (rect, node) => {
307
307
  const clipped = intersectRects(rect, vp);
308
- if (clipped) outer(clipped);
308
+ if (clipped) outer(clipped, node);
309
309
  };
310
310
  }
311
311
  this._claimThumbs(thumbsWere, outer);
@@ -863,8 +863,8 @@ export const Scrollable = (Base) =>
863
863
  if (sameThumb(before, after)) continue;
864
864
  // a pixel of slop for the rounded corners' antialiasing, as the
865
865
  // blit's own thumb repair takes
866
- if (before) sink(insetRect(before, -1));
867
- if (after) sink(insetRect(after, -1));
866
+ if (before) sink(insetRect(before, -1), this);
867
+ if (after) sink(insetRect(after, -1), this);
868
868
  }
869
869
  }
870
870
 
@@ -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.max(bw.top, bw.right, bw.bottom, bw.left);
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
  }
package/src/nodes/text.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // selection paints, and the glyph strip.
3
3
 
4
4
  import { localTextStyleChanged, TEXT_REMEASURE } from '../styles.js';
5
- import { cssColorStraight } from 'ntk';
5
+ import { cssColorStraight } from 'ntk/color';
6
6
  import { hooks as a11yHooks } from '../a11y.js';
7
7
  import { codePointAtOffset, codeUnitOffsets } from '../textrange.js';
8
8
  import { NO_DAMAGE } from './damage.js';
@@ -1,7 +1,7 @@
1
1
  // What a window can paint: compositing and ARGB transparency, the window
2
2
  // background, and the capabilities `@supports` blocks read.
3
3
 
4
- import { cssColorStraight } from 'ntk';
4
+ import { cssColorStraight } from 'ntk/color';
5
5
  import {
6
6
  argbVisual,
7
7
  compositingActive,
@@ -6,7 +6,7 @@ import { resolveFramePolicy } from '../../pacing.js';
6
6
  import { paintCacheFor } from '../../paintcache.js';
7
7
  import { hooks as traceHooks } from '../../trace-registry.js';
8
8
  import { now } from '../animation.js';
9
- import { FULL_DAMAGE, layoutDiff, addDamageRect } from '../damage.js';
9
+ import { FULL_DAMAGE, layoutDiff } from '../damage.js';
10
10
  import { BLIT_POISONED } from '../scrollblit.js';
11
11
  import { debugPaint } from './debugpaint.js';
12
12
 
@@ -204,12 +204,10 @@ export class WindowFlush {
204
204
  (panes && this._paneDamage !== FULL_DAMAGE)
205
205
  ) {
206
206
  const cap = this._damageRectCap();
207
- layoutDiff.sink = (rect) => {
208
- if (this._damage !== FULL_DAMAGE) {
209
- layoutMoved = true;
210
- this._damage = addDamageRect(this._damage, rect, cap);
211
- }
212
- if (panes) this._addPaneDamage(rect);
207
+ // each to the lists that paint the node that moved
208
+ // (`_claimLayoutMove`): a move inside a `<glarea>` is its panes'
209
+ layoutDiff.sink = (rect, node) => {
210
+ if (this._claimLayoutMove(rect, node, cap)) layoutMoved = true;
213
211
  };
214
212
  }
215
213
  try {
@@ -259,14 +257,8 @@ export class WindowFlush {
259
257
  if (sv && !sv._recordBlitClaim(after)) {
260
258
  sv._pendingBlitFrom = BLIT_POISONED;
261
259
  }
262
- if (this._damage !== FULL_DAMAGE) {
263
- this._damage = addDamageRect(
264
- this._damage,
265
- after,
266
- this._damageRectCap(),
267
- );
268
- }
269
- if (panes) this._addPaneDamage(after);
260
+ // to the lists that paint it, as the layout diff's claims go
261
+ this._claimLayoutMove(after, node, this._damageRectCap());
270
262
  }
271
263
  this._reflowed.clear();
272
264
  } else if (this._reflowed.size) {
@@ -326,9 +318,26 @@ export class WindowFlush {
326
318
  // list was still taken (its bookkeeping is what keeps the two paths one
327
319
  // code) and is simply not consumed; the presenter diffs at the layer.
328
320
  if (typeof this.window.presentFrame === 'function') {
329
- // the panes are no presenter's: they paint from their damage either way
330
- if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
331
- this.window.presentFrame(this, damage);
321
+ // A presenter whose frame is the ordinary paint walk — a pass per
322
+ // damage rect into a context, the Windows window's — paints through
323
+ // the paint cache as an ntk window does, and says so
324
+ // (`usesPaintCache`). Without it every `<canvas cacheKey>` drew
325
+ // afresh in every pass that reached it, and a shadow, which is only
326
+ // ever baked into the cache (issue #413), was not drawn at all. The
327
+ // retained layer presenter paints nodes onto layers of their own and
328
+ // keeps to itself.
329
+ const cache = this.window.usesPaintCache
330
+ ? (this._paintCache ??= paintCacheFor(this.app))
331
+ : null;
332
+ cache?.beginFrame();
333
+ try {
334
+ // the panes are no presenter's: they paint from their damage either
335
+ // way
336
+ if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
337
+ this.window.presentFrame(this, damage);
338
+ } finally {
339
+ cache?.endFrame();
340
+ }
332
341
  this.app._reactX11Startup?.painted();
333
342
  return true;
334
343
  }
package/src/ntk.js CHANGED
@@ -22,43 +22,20 @@
22
22
  // `@react-x11/components` (`<Markdown>`, `<Formula>`). `SvgView` is still
23
23
  // here; a drawing is not a document.
24
24
  //
25
- // One name is not a plain re-export. `Surface` below asks the app it is
25
+ // One name is not a plain re-export. `Surface` (src/offscreen.js) asks the app it is
26
26
  // handed for the implementation, because ntk's own is a pixmap and a
27
27
  // Picture — an X connection's — and a component allocates its buffer
28
28
  // without knowing which backend it was mounted on. This subpath is where a
29
29
  // drawing-adjacent name gets its backend-neutral answer; the X-only names
30
30
  // (`createClient`, `Pixmap`, `Picture`, `XEmbedSocket`) stay X-only.
31
- import { Surface as NtkSurface } from 'ntk';
31
+ import * as ntk from 'ntk';
32
+
33
+ import { adoptNtk } from './ntkroot.js';
32
34
 
33
35
  export * from 'ntk';
34
36
  export { default } from 'ntk';
37
+ export { Surface } from './offscreen.js';
35
38
 
36
- /**
37
- * ntk's offscreen `Surface`, on whichever backend `app` is.
38
- *
39
- * An app that makes its own surfaces answers `createSurface(options)` —
40
- * the Cocoa app does, over a CG bitmap (src/cocoa/surface.js), the Wayland
41
- * app over a GL render target (src/wayland/surface.js) — and an ntk
42
- * connection has no such method and gets ntk's pixmap. The result is
43
- * whichever implementation answered, not an instance of this class: the
44
- * contract is the shape — `width`/`height`, `getContext('2d')`, `render`,
45
- * `clear`, `copyWithin`, `destroy`, and `ctx.drawImage(surface, …)` —
46
- * (docs/extending.md "Scrolling the pixels, not just the offset"), and
47
- * nothing needs `instanceof`.
48
- *
49
- * Part of that shape is that a context may be **held**: ntk tells a caller
50
- * doing many draws to take `getContext('2d')` once rather than one per
51
- * frame, and that holds on every backend here, including the ones where a
52
- * surface is a GPU target sharing a device with the window (#566). Draws
53
- * through a held context land in the surface whenever they are made, and
54
- * leave nothing for the caller to restore; `render(fn)` is the scoped form,
55
- * for a caller that wants a frame's clean transform and clip.
56
- */
57
- export class Surface {
58
- constructor(app, options) {
59
- if (typeof app?.createSurface === 'function') {
60
- return app.createSurface(options);
61
- }
62
- return new NtkSurface(app, options);
63
- }
64
- }
39
+ // what src/ntkroot.js would load, already loaded: handed over rather than
40
+ // imported twice
41
+ adoptNtk(ntk);
package/src/ntkroot.js ADDED
@@ -0,0 +1,50 @@
1
+ // ntk's package root — the X11 client, ntk's windows and their contexts, the
2
+ // GL rungs, fontconfig and the rest of what the X11 backend draws with —
3
+ // loaded where an X connection is made rather than wherever react-x11 is
4
+ // imported.
5
+ //
6
+ // Only that backend needs it. The Cocoa, Windows and Wayland backends use a
7
+ // colour parser, the shadow arithmetic, the image and SVG decoders and the
8
+ // font parser of ntk's, each from a subpath of its own (`ntk/color`,
9
+ // `ntk/shadow-math`, `ntk/image`, `ntk/svg`, `ntk/font`, …), and importing
10
+ // the root for them was ~210 ms of module loading on Windows before the first
11
+ // window of an app that never opens an X connection.
12
+ //
13
+ // `createRoot` loads it on the X11 path, before the connection, and for an
14
+ // ntk app it is handed rather than makes — an app that could only have come
15
+ // from ntk's own `createClient`, so the import is already done and this is a
16
+ // lookup. `react-x11/ntk` hands over the root it imports. After that, the
17
+ // code that only ever runs against an X connection reads it synchronously.
18
+ let root = null;
19
+ let loading = null;
20
+
21
+ /** Load ntk's root, once; resolves to the module. */
22
+ export function loadNtk() {
23
+ return (loading ??= import('ntk').then((mod) => (root = mod)));
24
+ }
25
+
26
+ /** A root imported some other way (`react-x11/ntk`): the same module, so
27
+ * nothing is loaded twice by taking it. */
28
+ export function adoptNtk(mod) {
29
+ root ??= mod;
30
+ loading ??= Promise.resolve(mod);
31
+ }
32
+
33
+ /** ntk's root, or null while no X connection has been made. */
34
+ export function ntkRoot() {
35
+ return root;
36
+ }
37
+
38
+ /**
39
+ * ntk's root, for code that only runs against an X connection — which
40
+ * `createRoot` has loaded it for. Throws where it is not: reaching for an X
41
+ * resource on a backend that has none is the mistake, and this says so
42
+ * rather than failing inside ntk on an app that is not its own.
43
+ */
44
+ export function x11Ntk(what) {
45
+ if (root) return root;
46
+ throw new Error(
47
+ `react-x11: ${what} needs an X connection, and this process has none ` +
48
+ '(ntk loads with the X11 backend, in createRoot).',
49
+ );
50
+ }
@@ -0,0 +1,33 @@
1
+ // An offscreen surface on whichever backend an app is — what the paint cache,
2
+ // a group's opacity and the symbol icons draw into, and what `react-x11/ntk`
3
+ // hands a component as `Surface`.
4
+ //
5
+ // An app that makes its own surfaces answers `createSurface(options)` — the
6
+ // Cocoa app over a CG bitmap (src/cocoa/surface.js), the Windows app over a
7
+ // Direct2D bitmap, the Wayland app over a GL render target
8
+ // (src/wayland/surface.js) — and an X connection has no such method and gets
9
+ // ntk's pixmap, from ntk's root, which only an X connection loads
10
+ // (src/ntkroot.js). The result is whichever implementation answered, not an
11
+ // instance of this class: the contract is the shape — `width`/`height`,
12
+ // `getContext('2d')`, `render`, `clear`, `copyWithin`, `destroy`, and
13
+ // `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling the pixels, not
14
+ // just the offset"), and nothing needs `instanceof`.
15
+ //
16
+ // Part of that shape is that a context may be **held**: ntk tells a caller
17
+ // doing many draws to take `getContext('2d')` once rather than one per frame,
18
+ // and that holds on every backend here, including the ones where a surface is
19
+ // a GPU target sharing a device with the window (#566). Draws through a held
20
+ // context land in the surface whenever they are made, and leave nothing for
21
+ // the caller to restore; `render(fn)` is the scoped form, for a caller that
22
+ // wants a frame's clean transform and clip.
23
+ import { x11Ntk } from './ntkroot.js';
24
+
25
+ export class Surface {
26
+ constructor(app, options) {
27
+ if (typeof app?.createSurface === 'function') {
28
+ return app.createSurface(options);
29
+ }
30
+ const { Surface: NtkSurface } = x11Ntk('an offscreen surface');
31
+ return new NtkSurface(app, options);
32
+ }
33
+ }
package/src/paintcache.js CHANGED
@@ -42,13 +42,13 @@
42
42
  // this package pins, and a named import of something absent is a *load-time*
43
43
  // SyntaxError — the whole renderer, not just the cache. Same shape as the
44
44
  // `typeof wnd?.scrollRegion !== 'function'` guard for ntk without #139.
45
- import * as ntk from 'ntk';
46
45
 
47
46
  // The surface this cache draws into is `react-x11/ntk`'s: ntk's pixmap on
48
47
  // an X connection, the app's own (`app.createSurface`) on a backend that
49
48
  // makes them — the Cocoa backend's CG bitmap. One import, so the cache
50
49
  // names neither.
51
- import { Surface } from './ntk.js';
50
+ import { ntkRoot } from './ntkroot.js';
51
+ import { Surface } from './offscreen.js';
52
52
 
53
53
  /** Stale pixels are undebuggable, and every other optimization here has an
54
54
  * escape hatch — see NO_SCROLL_BLIT. */
@@ -415,15 +415,19 @@ export class PaintCache {
415
415
  }
416
416
  }
417
417
 
418
- /** Whether the installed ntk can make the surfaces this needs. Older ones
419
- * cannot, and the answer is simply that nothing is cached. */
420
- export const paintCacheSupported = () => typeof ntk.Surface === 'function';
418
+ /** Whether the installed ntk can make the surfaces this needs on an X
419
+ * connection — ntk's root, which only an X connection loads
420
+ * (src/ntkroot.js). Older ones cannot, and the answer is simply that nothing
421
+ * is cached there. */
422
+ export const paintCacheSupported = () =>
423
+ typeof ntkRoot()?.Surface === 'function';
421
424
 
422
425
  /** Whether `app` can make an offscreen surface at all: an X connection
423
426
  * with the Render extension (ntk's pixmap surface), or a backend with a
424
427
  * surface of its own — the Cocoa app's `createSurface` (docs/macos.md). */
425
428
  const canMakeSurfaces = (app) =>
426
- Boolean(app?.display?.Render) || typeof app?.createSurface === 'function';
429
+ (Boolean(app?.display?.Render) && paintCacheSupported()) ||
430
+ typeof app?.createSurface === 'function';
427
431
 
428
432
  /**
429
433
  * The cache for an app, created on first use. Null when caching is off, when
@@ -432,6 +436,6 @@ const canMakeSurfaces = (app) =>
432
436
  * there must paint live.
433
437
  */
434
438
  export function paintCacheFor(app) {
435
- if (DISABLED || !paintCacheSupported() || !canMakeSurfaces(app)) return null;
439
+ if (DISABLED || !canMakeSurfaces(app)) return null;
436
440
  return (app._paintCache ??= new PaintCache(app));
437
441
  }
@@ -326,6 +326,28 @@ function buildTransformer({ babel, jsxTransform, refreshTransform }, options) {
326
326
 
327
327
  let registered = false;
328
328
 
329
+ /**
330
+ * Which files stay out of the hot graph: every package in node_modules,
331
+ * react-x11's own src/, and whatever the app's `ignore` names.
332
+ *
333
+ * Compared with forward slashes, whichever the path arrives with: on
334
+ * Windows a file path's separators are backslashes, and a forward-slash
335
+ * test let every package in node_modules into the hot graph — where the
336
+ * HMR layer's rewrite of a package's modules could leave one reading an
337
+ * import before it was bound (entities, under htmlparser2).
338
+ */
339
+ export function hotIgnoreFor(ignore, srcDir = SRC_DIR) {
340
+ const src = srcDir.replaceAll('\\', '/');
341
+ return (path) => {
342
+ const slashed = path.replaceAll('\\', '/');
343
+ return (
344
+ slashed.includes('/node_modules/') ||
345
+ slashed.startsWith(src) ||
346
+ (ignore ? ignore(path) === true : false)
347
+ );
348
+ };
349
+ }
350
+
329
351
  /**
330
352
  * Register the hot-reload loader. `react-x11/refresh/register` calls this
331
353
  * with no options; a tool that needs the seams writes its own two-line
@@ -362,10 +384,7 @@ export async function registerRefresh(options = {}) {
362
384
  const { ignore } = validated;
363
385
  registered = true;
364
386
 
365
- const hotIgnore = (path) =>
366
- path.includes('/node_modules/') ||
367
- path.startsWith(SRC_DIR) ||
368
- (ignore ? ignore(path) === true : false);
387
+ const hotIgnore = hotIgnoreFor(ignore);
369
388
 
370
389
  nodeModule.registerHooks({
371
390
  load(url, context, nextLoad) {
@@ -56,7 +56,7 @@
56
56
  // and losing the grab to someone else is detected (the server says so, with
57
57
  // a LeaveNotify of mode Ungrab) rather than waited out.
58
58
 
59
- import { pixelLayout, toStraightRgba } from 'ntk';
59
+ import { pixelLayout, toStraightRgba } from 'ntk/imagedata';
60
60
 
61
61
  import { sessionBus } from './bus.js';
62
62
  import { XK_ESCAPE, XK_KP_ENTER, XK_RETURN, XK_SPACE } from './keysyms.js';
package/src/styles.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Numbers are pixels; strings like '50%' / 'auto' pass through to yoga.
4
4
  // The engine is ours (`./yoga.js`) — the enum tables below are built at
5
5
  // module scope, which is what that module's synchronous half is for.
6
- import { cssColorStraight } from 'ntk';
6
+ import { cssColorStraight } from 'ntk/color';
7
7
 
8
8
  import { parseBoxShadow, parseLinearGradient } from './decorations.js';
9
9
  import {
package/src/svgnodes.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // `<Formula>`), composed from public host elements. `<svg>` stays here
11
11
  // because it is not a document — it is a drawing, and one node is the right
12
12
  // grain for it.
13
- import { SvgView } from 'ntk';
13
+ import SvgView from 'ntk/svg';
14
14
 
15
15
  import { Node } from './nodes/node.js';
16
16
  import { intrinsicSize } from './nodes/layout.js';
package/src/symbols.js CHANGED
@@ -17,11 +17,13 @@
17
17
  // the same bargain a tray icon's name makes — so an app that runs on both
18
18
  // picks its names by platform, as it does for the tray.
19
19
 
20
- import { cssColorStraight, decodeImage, SvgView } from 'ntk';
20
+ import { cssColorStraight } from 'ntk/color';
21
+ import { decodeImage } from 'ntk/image';
22
+ import SvgView from 'ntk/svg';
21
23
  import * as nodeFs from 'node:fs';
22
24
 
23
25
  import { IconTheme } from './icontheme.js';
24
- import { Surface } from './ntk.js';
26
+ import { Surface } from './offscreen.js';
25
27
  import { xsettings } from './xsettings.js';
26
28
 
27
29
  /** A `fontWeight` as the number both icon systems configure with. */
package/src/trayhooks.js CHANGED
@@ -54,7 +54,7 @@
54
54
  // frame is not available for anything that has to ask the desktop.
55
55
 
56
56
  import { useEffect, useRef, useState } from 'react';
57
- import { decodeImage } from 'ntk';
57
+ import { decodeImage } from 'ntk/image';
58
58
 
59
59
  import { useAppOrNull } from './appcontext.js';
60
60
  import { currentRegistration } from './application.js';
@@ -42,7 +42,8 @@
42
42
  import { createRequire } from 'node:module';
43
43
  import path from 'node:path';
44
44
  import { pathToFileURL } from 'node:url';
45
- import { Path2D, Image as NtkImage } from 'ntk';
45
+ import { Image as NtkImage } from 'ntk/image';
46
+ import { Path2D } from 'ntk/path';
46
47
  import { glDevice } from './device.js';
47
48
  import { GlyphAtlas } from './glyphatlas.js';
48
49
  import { GLTarget } from './target.js';
@@ -32,7 +32,7 @@
32
32
  // buttons do what they say. input.js routes; this answers where things are.
33
33
 
34
34
  import fs from 'node:fs';
35
- import { Path2D } from 'ntk';
35
+ import { Path2D } from 'ntk/path';
36
36
  import { RESIZE_EDGE, TOPLEVEL_STATE } from './window.js';
37
37
  import { DEFAULTS, parseButtonLayout, titleFont } from './framestyle.js';
38
38
 
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 listening = force || this._native.uiaListening();
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 (!listening && this._pushed.has(wnd.id)) continue;
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.flush();
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 = [];