react-x11 2.22.9 → 2.22.10
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/a11ypace.js +83 -0
- package/src/atspi.js +76 -18
- package/src/cocoa/app.js +30 -3
- package/src/cocoa/bezels.js +69 -1
- package/src/cocoa/window.js +24 -0
- package/src/components/Button.js +28 -12
- package/src/components/Select.js +3 -3
- package/src/components/native.js +43 -12
- package/src/frames.js +7 -0
- package/src/nodes/window/floors.js +40 -4
- package/src/win32/a11y.js +47 -39
package/package.json
CHANGED
package/src/a11ypace.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// How often an accessibility bridge tells its platform what changed.
|
|
2
|
+
//
|
|
3
|
+
// Both bridges mirror the tree for a platform that reads it on its own
|
|
4
|
+
// schedule: AT-SPI (src/atspi.js) exports objects and sends signals, UI
|
|
5
|
+
// Automation (src/win32/a11y.js) walks a window and pushes a diff. Pushed on
|
|
6
|
+
// every commit, that is paid by everything that commits often, and mostly
|
|
7
|
+
// for nobody. On Windows it was 1.6 ms of each frame of a graph pane's pan
|
|
8
|
+
// (#656). On a Linux desktop whose session runs the AT-SPI bus, as most do
|
|
9
|
+
// with no screen reader in sight, it was a sixth of a 100,000-row table's
|
|
10
|
+
// fling: rows exported, diffed and unexported as they scrolled past.
|
|
11
|
+
//
|
|
12
|
+
// The rule is the one browsers use. Chromium's AXObjectCacheImpl batches
|
|
13
|
+
// non-interactive updates, 150 ms apart once a page has loaded and 350 ms
|
|
14
|
+
// before, and the bounds of anything that is not focused 500 ms apart. It
|
|
15
|
+
// serializes a focus or selection change at once, because that is what the
|
|
16
|
+
// user is waiting on. So here:
|
|
17
|
+
//
|
|
18
|
+
// - a change after a quiet spell is pushed at once, so a click or a key
|
|
19
|
+
// that changes one thing is heard immediately;
|
|
20
|
+
// - a stream of changes (a scroll, a pan, a value ticking) is pushed at
|
|
21
|
+
// most once per `interval`, and the last push carries the last state;
|
|
22
|
+
// - what the user is waiting on goes at once regardless: a focus change,
|
|
23
|
+
// an announcement, and a change to the focused element itself, such as
|
|
24
|
+
// the characters typed into it. That exception is each bridge's to
|
|
25
|
+
// call (`urgent`).
|
|
26
|
+
//
|
|
27
|
+
// The interval is 500 ms: at most two pushes a second, the slower end of
|
|
28
|
+
// what Chromium spends. `REACT_X11_A11Y_INTERVAL` sets it in milliseconds,
|
|
29
|
+
// and `0` pushes on every change, as both bridges once did.
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_A11Y_INTERVAL = 500;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The push interval: `REACT_X11_A11Y_INTERVAL` in milliseconds where it is a
|
|
35
|
+
* number of them (`0` included), and the default otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export function a11yInterval(env = globalThis.process?.env ?? {}) {
|
|
38
|
+
const raw = env.REACT_X11_A11Y_INTERVAL;
|
|
39
|
+
if (raw === undefined || raw === '') return DEFAULT_A11Y_INTERVAL;
|
|
40
|
+
const ms = Number(raw);
|
|
41
|
+
return Number.isFinite(ms) && ms >= 0 ? ms : DEFAULT_A11Y_INTERVAL;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pacing, without the pushing: the bridge asks whether a push may go now
|
|
46
|
+
* and says when one went, and the pacer keeps the one timer a stream owes.
|
|
47
|
+
*/
|
|
48
|
+
export class A11yPacer {
|
|
49
|
+
constructor(interval = a11yInterval()) {
|
|
50
|
+
this.interval = interval;
|
|
51
|
+
this._last = -Infinity;
|
|
52
|
+
this._timer = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Something changed. True when it may be pushed now, because nothing has
|
|
57
|
+
* been pushed for an interval. False when `later` has been set to run once
|
|
58
|
+
* the interval is up: one timer, however many changes ask while it runs.
|
|
59
|
+
*/
|
|
60
|
+
ready(later) {
|
|
61
|
+
if (this._timer) return false;
|
|
62
|
+
const wait = this._last + this.interval - performance.now();
|
|
63
|
+
if (wait <= 0) return true;
|
|
64
|
+
this._timer = setTimeout(() => {
|
|
65
|
+
this._timer = null;
|
|
66
|
+
later();
|
|
67
|
+
}, wait);
|
|
68
|
+
this._timer.unref?.();
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A push went out, paced or not: the next interval runs from here. */
|
|
73
|
+
pushed() {
|
|
74
|
+
this._last = performance.now();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Drop the push a stream is owed, for one that goes now and takes it. */
|
|
78
|
+
cancel() {
|
|
79
|
+
if (this._timer === null) return;
|
|
80
|
+
clearTimeout(this._timer);
|
|
81
|
+
this._timer = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/atspi.js
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
diffChars,
|
|
63
63
|
} from './a11y.js';
|
|
64
64
|
import { onApp } from './trace-registry.js';
|
|
65
|
+
import { A11yPacer } from './a11ypace.js';
|
|
65
66
|
import { synthesizeClick } from './events.js';
|
|
66
67
|
import { callHandler } from './errors.js';
|
|
67
68
|
|
|
@@ -100,6 +101,8 @@ const LAYER_WINDOW = 7;
|
|
|
100
101
|
const ATSPI_VERSION = '2.1';
|
|
101
102
|
|
|
102
103
|
const EVENT_SIGNATURE = 'siiva{sv}';
|
|
104
|
+
/** D-Bus message type 1: a method call, which is how an AT reads. */
|
|
105
|
+
const METHOD_CALL = 1;
|
|
103
106
|
const CACHE_ITEM_SIGNATURE = '((so)(so)(so)iiassusau)';
|
|
104
107
|
|
|
105
108
|
// --------------------------------------------------------------------------
|
|
@@ -765,7 +768,8 @@ class AtspiBridge {
|
|
|
765
768
|
this.desktop = [REGISTRY_NAME, ROOT_PATH];
|
|
766
769
|
this.appId = -1;
|
|
767
770
|
this._unsubscribes = [];
|
|
768
|
-
// batched renderer notifications, flushed
|
|
771
|
+
// batched renderer notifications, flushed in a microtask once the pacer
|
|
772
|
+
// says so (src/a11ypace.js), and at once for what the user waits on
|
|
769
773
|
this._pending = {
|
|
770
774
|
attach: new Set(),
|
|
771
775
|
remove: [],
|
|
@@ -775,6 +779,17 @@ class AtspiBridge {
|
|
|
775
779
|
window: [],
|
|
776
780
|
};
|
|
777
781
|
this._flushScheduled = false;
|
|
782
|
+
this._pacer = new A11yPacer();
|
|
783
|
+
/** The focused node, whose own changes are not paced. */
|
|
784
|
+
this._focused = null;
|
|
785
|
+
// Pushes are paced, reads are not: whatever the pacer is holding is
|
|
786
|
+
// flushed before dbus-native dispatches an AT's call, so the objects and
|
|
787
|
+
// interfaces it asks about exist as the tree has them now. A node React
|
|
788
|
+
// reused as a slider still exported a button's interfaces until the
|
|
789
|
+
// next push, and a Value read of it failed.
|
|
790
|
+
bus.connection?.prependListener?.('message', (msg) => {
|
|
791
|
+
if (msg.type === METHOD_CALL && this._hasPending()) this.flush();
|
|
792
|
+
});
|
|
778
793
|
}
|
|
779
794
|
|
|
780
795
|
// ---- refs and export -------------------------------------------------
|
|
@@ -1184,12 +1199,35 @@ class AtspiBridge {
|
|
|
1184
1199
|
|
|
1185
1200
|
// ---- the queue -------------------------------------------------------
|
|
1186
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* A change to tell the bus about, when the pacer allows: in this turn's
|
|
1204
|
+
* microtask after a quiet spell, and otherwise once its interval is up,
|
|
1205
|
+
* together with everything else that changed meanwhile. Rows that scroll
|
|
1206
|
+
* in and out between two pushes are then never exported at all: the attach
|
|
1207
|
+
* finds them gone, and the removal finds nothing the AT ever saw.
|
|
1208
|
+
*/
|
|
1187
1209
|
_schedule() {
|
|
1210
|
+
if (this._flushScheduled || this.dead) return;
|
|
1211
|
+
if (this._pacer.ready(() => this.flush())) this._flushSoon();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
_flushSoon() {
|
|
1188
1215
|
if (this._flushScheduled || this.dead) return;
|
|
1189
1216
|
this._flushScheduled = true;
|
|
1190
1217
|
queueMicrotask(() => this.flush());
|
|
1191
1218
|
}
|
|
1192
1219
|
|
|
1220
|
+
/**
|
|
1221
|
+
* A change the user is waiting to hear: focus moving, or the focused
|
|
1222
|
+
* element's own name, state or text, such as the characters typed into
|
|
1223
|
+
* it. Chromium serializes these at once too. It takes everything pending
|
|
1224
|
+
* with it, so the AT can resolve whatever the event names.
|
|
1225
|
+
*/
|
|
1226
|
+
_urgent() {
|
|
1227
|
+
this._pacer.cancel();
|
|
1228
|
+
this._flushSoon();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1193
1231
|
/** Is this node under a toplevel the bridge is presenting? Detached
|
|
1194
1232
|
* subtrees under construction are not, which is what keeps the initial
|
|
1195
1233
|
* bottom-up build of a tree from queueing one event per node. */
|
|
@@ -1214,38 +1252,47 @@ class AtspiBridge {
|
|
|
1214
1252
|
|
|
1215
1253
|
queueProps(node) {
|
|
1216
1254
|
this._pending.props.add(node);
|
|
1217
|
-
this.
|
|
1255
|
+
if (node === this._focused) this._urgent();
|
|
1256
|
+
else this._schedule();
|
|
1218
1257
|
}
|
|
1219
1258
|
|
|
1220
1259
|
queueText(node) {
|
|
1221
1260
|
this._pending.text.add(node);
|
|
1222
|
-
this.
|
|
1261
|
+
if (node === this._focused) this._urgent();
|
|
1262
|
+
else this._schedule();
|
|
1223
1263
|
}
|
|
1224
1264
|
|
|
1225
1265
|
queueFocus(previous, next) {
|
|
1226
1266
|
this._pending.focus.push({ previous, next });
|
|
1227
|
-
this.
|
|
1267
|
+
this._urgent();
|
|
1228
1268
|
}
|
|
1229
1269
|
|
|
1230
1270
|
queueWindowFocus(win, focused) {
|
|
1231
1271
|
this._pending.window.push({ win, focused });
|
|
1232
|
-
this.
|
|
1272
|
+
this._urgent();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
_hasPending() {
|
|
1276
|
+
const p = this._pending;
|
|
1277
|
+
return (
|
|
1278
|
+
p.attach.size > 0 ||
|
|
1279
|
+
p.remove.length > 0 ||
|
|
1280
|
+
p.props.size > 0 ||
|
|
1281
|
+
p.text.size > 0 ||
|
|
1282
|
+
p.focus.length > 0 ||
|
|
1283
|
+
p.window.length > 0
|
|
1284
|
+
);
|
|
1233
1285
|
}
|
|
1234
1286
|
|
|
1235
1287
|
flush() {
|
|
1236
1288
|
if (this.dead) return;
|
|
1237
1289
|
this._flushScheduled = false;
|
|
1238
1290
|
const p = this._pending;
|
|
1239
|
-
if (
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
p.focus.length === 0 &&
|
|
1245
|
-
p.window.length === 0
|
|
1246
|
-
) {
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1291
|
+
if (!this._hasPending()) return;
|
|
1292
|
+
// this push takes what a stream was owed, and the next interval runs
|
|
1293
|
+
// from here
|
|
1294
|
+
this._pacer.cancel();
|
|
1295
|
+
this._pacer.pushed();
|
|
1249
1296
|
this._pending = {
|
|
1250
1297
|
attach: new Set(),
|
|
1251
1298
|
remove: [],
|
|
@@ -1699,8 +1746,15 @@ class AtspiBridge {
|
|
|
1699
1746
|
this.queueAttach(child);
|
|
1700
1747
|
};
|
|
1701
1748
|
hooks.detach = (parent, child) => {
|
|
1702
|
-
|
|
1703
|
-
|
|
1749
|
+
const exported = this.exported.has(child);
|
|
1750
|
+
if (!exported && !this._live(parent)) return;
|
|
1751
|
+
// the index is for the AT's children-changed, which only a child it
|
|
1752
|
+
// saw is owed — and a row scrolled away was usually never exported
|
|
1753
|
+
this.queueRemove(
|
|
1754
|
+
parent,
|
|
1755
|
+
child,
|
|
1756
|
+
exported ? a11yIndexIn(parent, child) : -1,
|
|
1757
|
+
);
|
|
1704
1758
|
};
|
|
1705
1759
|
hooks.propsChanged = (node) => {
|
|
1706
1760
|
if (this.exported.has(node)) this.queueProps(node);
|
|
@@ -1720,12 +1774,15 @@ class AtspiBridge {
|
|
|
1720
1774
|
if (this.exported.has(node)) this.queueText(node);
|
|
1721
1775
|
};
|
|
1722
1776
|
hooks.focus = (previous, next) => {
|
|
1777
|
+
this._focused = next ?? null;
|
|
1723
1778
|
this.queueFocus(previous, next);
|
|
1724
1779
|
};
|
|
1725
1780
|
hooks.windowFocus = (win, focused) => {
|
|
1726
1781
|
this.queueWindowFocus(win, focused);
|
|
1727
1782
|
};
|
|
1728
|
-
|
|
1783
|
+
// a commit is one more change to pace, not a push of its own: every
|
|
1784
|
+
// hook above has already asked for the one it needs
|
|
1785
|
+
hooks.commit = () => this._schedule();
|
|
1729
1786
|
hooks.announce = (text, opts) => {
|
|
1730
1787
|
// spoken from the active window, which is the context the user is in
|
|
1731
1788
|
const target =
|
|
@@ -1758,6 +1815,7 @@ class AtspiBridge {
|
|
|
1758
1815
|
bury() {
|
|
1759
1816
|
if (this.dead) return;
|
|
1760
1817
|
this.dead = true;
|
|
1818
|
+
this._pacer.cancel();
|
|
1761
1819
|
for (const key of Object.keys(hooks)) hooks[key] = null;
|
|
1762
1820
|
for (const unsubscribe of this._unsubscribes) unsubscribe();
|
|
1763
1821
|
this._unsubscribes = [];
|
package/src/cocoa/app.js
CHANGED
|
@@ -965,6 +965,7 @@ export class CocoaApp {
|
|
|
965
965
|
// which is more than a display frame when the change costs layout
|
|
966
966
|
// (issue #641). The Wayland backend orders them the same way
|
|
967
967
|
// (`requestSurfaceFrame`).
|
|
968
|
+
const frames = [];
|
|
968
969
|
const surfaces = [];
|
|
969
970
|
for (const entry of queue) {
|
|
970
971
|
// A window whose last flip has not reached its layer yet (threaded
|
|
@@ -993,10 +994,36 @@ export class CocoaApp {
|
|
|
993
994
|
this._rafQueue.push(entry);
|
|
994
995
|
continue;
|
|
995
996
|
}
|
|
996
|
-
|
|
997
|
-
else runFrame(entry, now);
|
|
997
|
+
(entry.surface ? surfaces : frames).push(entry);
|
|
998
998
|
}
|
|
999
|
-
|
|
999
|
+
// …and in one commit with them. The overlay and the GL frame are two
|
|
1000
|
+
// layers, each flipped in a transaction of its own — the overlay's in
|
|
1001
|
+
// the window's flush, the GL frame's after its draw — and the draw came
|
|
1002
|
+
// between the two commits: a frame that rebuilt its scene let the
|
|
1003
|
+
// overlay reach the screen a refresh or more before the frame it was
|
|
1004
|
+
// painted for. So a window with a surface drawing in this tick has its
|
|
1005
|
+
// frame and theirs inside one transaction, which puts every layer the
|
|
1006
|
+
// two changed on glass at once; committed with the window's size, as
|
|
1007
|
+
// the frame a worker's resize handshake waits for (`_flip`).
|
|
1008
|
+
const joined = new Set();
|
|
1009
|
+
for (const entry of surfaces) if (entry.wnd) joined.add(entry.wnd);
|
|
1010
|
+
for (const entry of frames) {
|
|
1011
|
+
if (!joined.has(entry.wnd)) runFrame(entry, now);
|
|
1012
|
+
}
|
|
1013
|
+
for (const wnd of joined) {
|
|
1014
|
+
this._native.txBegin({ disableActions: true });
|
|
1015
|
+
try {
|
|
1016
|
+
for (const entry of frames) {
|
|
1017
|
+
if (entry.wnd === wnd) runFrame(entry, now);
|
|
1018
|
+
}
|
|
1019
|
+
for (const entry of surfaces) {
|
|
1020
|
+
if (entry.wnd === wnd) runFrame(entry, now);
|
|
1021
|
+
}
|
|
1022
|
+
} finally {
|
|
1023
|
+
this._native.txCommit(wnd._frameSize?.());
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
for (const entry of surfaces) if (!entry.wnd) runFrame(entry, now);
|
|
1000
1027
|
this._armFrameTimer(soonest, now);
|
|
1001
1028
|
}
|
|
1002
1029
|
|
package/src/cocoa/bezels.js
CHANGED
|
@@ -31,8 +31,23 @@
|
|
|
31
31
|
// caller draws what it had meanwhile. A bezel drawn at rest brings its
|
|
32
32
|
// pressed twin along, so the press — the input whose answer is the bezel —
|
|
33
33
|
// never waits for one (AGENTS.md, "Answer the input").
|
|
34
|
+
//
|
|
35
|
+
// ## Taller than one line
|
|
36
|
+
//
|
|
37
|
+
// A push bezel is one height. Asked to fill a taller frame, AppKit draws its
|
|
38
|
+
// own 22pt bezel centred in it — and a `<Button>` whose label wrapped is a
|
|
39
|
+
// taller frame. What AppKit has for that button is the *flexible* push,
|
|
40
|
+
// `NSBezelStyleFlexiblePush`: the same bezel stretched to its frame, the one
|
|
41
|
+
// AppKit gives a title that wraps, and what WebKit and Gecko draw a push
|
|
42
|
+
// button taller than a push button with. At one line the two are the same
|
|
43
|
+
// pixels, the default button's accent aside (the push has a sheen, the
|
|
44
|
+
// flexible one is flat), so a push box keeps its bezel at its own height and
|
|
45
|
+
// is drawn as the flexible push above it (`TALLER`). A bridge that predates
|
|
46
|
+
// the kind keeps the push (`_draws`).
|
|
34
47
|
|
|
35
48
|
const KINDS = ['push', 'checkbox', 'radio', 'popup', 'slider', 'switch'];
|
|
49
|
+
// what a kind is drawn as in a box taller than its bezel
|
|
50
|
+
const TALLER = Object.freeze({ push: 'flexiblePush' });
|
|
36
51
|
// the sizes the widget set lays its controls out at (components/native.js)
|
|
37
52
|
const SIZES = ['regular', 'small'];
|
|
38
53
|
// the fullest state, so the scan sees the whole footprint
|
|
@@ -55,6 +70,7 @@ export class BezelStore {
|
|
|
55
70
|
this._scanning = new Map(); // kind|size|scale → the scan in flight
|
|
56
71
|
this._cache = new Map(); // full param key → { surface, sx, sy, sw, sh }
|
|
57
72
|
this._drawing = new Map(); // full param key → callbacks awaiting it
|
|
73
|
+
this._drawable = new Map(); // kind → whether the bridge draws it
|
|
58
74
|
this._gen = 0;
|
|
59
75
|
this._MAX = 160;
|
|
60
76
|
}
|
|
@@ -118,9 +134,13 @@ export class BezelStore {
|
|
|
118
134
|
* each scan running the first time it is asked for.
|
|
119
135
|
*/
|
|
120
136
|
prefetch(scale) {
|
|
137
|
+
const kinds = [
|
|
138
|
+
...KINDS,
|
|
139
|
+
...Object.values(TALLER).filter((kind) => this._draws(kind)),
|
|
140
|
+
];
|
|
121
141
|
const jobs = [];
|
|
122
142
|
for (const s of new Set([2, scale])) {
|
|
123
|
-
for (const kind of
|
|
143
|
+
for (const kind of kinds) {
|
|
124
144
|
for (const size of SIZES) jobs.push(this._scanLater(kind, size, s));
|
|
125
145
|
}
|
|
126
146
|
}
|
|
@@ -132,9 +152,13 @@ export class BezelStore {
|
|
|
132
152
|
* Returns `{ surface, sx, sy, sw, sh }` — draw with the 9-arg
|
|
133
153
|
* `ctx.drawImage` so the ink region lands exactly on the box. On a worker
|
|
134
154
|
* a bezel not drawn yet is null, and `onReady` is called once it is in.
|
|
155
|
+
* A push box taller than a push bezel is drawn as the flexible push
|
|
156
|
+
* ("Taller than one line").
|
|
135
157
|
*/
|
|
136
158
|
get(params, w, h, scale, onReady = null) {
|
|
137
159
|
const controlSize = params.controlSize ?? 'regular';
|
|
160
|
+
const kind = this._kindAt(params.kind, controlSize, h / scale);
|
|
161
|
+
if (kind !== params.kind) params = { ...params, kind };
|
|
138
162
|
const key = JSON.stringify([
|
|
139
163
|
params.kind,
|
|
140
164
|
controlSize,
|
|
@@ -170,6 +194,50 @@ export class BezelStore {
|
|
|
170
194
|
return frame.entry;
|
|
171
195
|
}
|
|
172
196
|
|
|
197
|
+
/** The kind that draws a `kind` box `height` logical px tall: itself, or
|
|
198
|
+
* above its bezel's own height the kind `TALLER` names for it, where the
|
|
199
|
+
* bridge draws that. On a worker, before `prefetch`, a kind whose height
|
|
200
|
+
* is not in yet is itself. */
|
|
201
|
+
_kindAt(kind, controlSize, height) {
|
|
202
|
+
const taller = TALLER[kind];
|
|
203
|
+
if (!taller) return kind;
|
|
204
|
+
const natural = this.natural(kind, controlSize);
|
|
205
|
+
if (!natural || height <= natural.height) return kind;
|
|
206
|
+
return this._draws(taller) ? taller : kind;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether the bridge draws `kind` at all. One that predates it throws
|
|
211
|
+
* "unknown control kind" as it reads the parameters — on either thread,
|
|
212
|
+
* before anything is measured or dispatched — so the question costs one
|
|
213
|
+
* measure, once. A no keeps the kind it would have replaced, which is a
|
|
214
|
+
* button whose bezel stops short of its label: broken rather than
|
|
215
|
+
* degraded, so development hears about it, once.
|
|
216
|
+
*/
|
|
217
|
+
_draws(kind) {
|
|
218
|
+
let known = this._drawable.get(kind);
|
|
219
|
+
if (known !== undefined) return known;
|
|
220
|
+
try {
|
|
221
|
+
// off the main thread the callback is how the bridge answers at all;
|
|
222
|
+
// on it, the call answers in place
|
|
223
|
+
if (this._answersLater()) this._native.measureControl({ kind }, () => {});
|
|
224
|
+
else this._native.measureControl({ kind });
|
|
225
|
+
known = true;
|
|
226
|
+
} catch {
|
|
227
|
+
known = false;
|
|
228
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
229
|
+
console.warn(
|
|
230
|
+
`react-x11: this @windowkit/appkit has no '${kind}' bezel, so a ` +
|
|
231
|
+
'native <Button> whose label wrapped is drawn over a one-line ' +
|
|
232
|
+
'bezel that stops short of it. Update @windowkit/appkit to the ' +
|
|
233
|
+
'version react-x11 lists in its optionalDependencies.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this._drawable.set(kind, known);
|
|
238
|
+
return known;
|
|
239
|
+
}
|
|
240
|
+
|
|
173
241
|
/** A surface padded out by the kind's insets, and the region of it a box
|
|
174
242
|
* `w` × `h` blits from. */
|
|
175
243
|
_frame(c, w, h, scale) {
|
package/src/cocoa/window.js
CHANGED
|
@@ -1013,6 +1013,30 @@ export class CocoaWindow {
|
|
|
1013
1013
|
return this.app._requestFrame(cb, this, true);
|
|
1014
1014
|
}
|
|
1015
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* Whether a `<glarea>` of this window has a frame queued on its clock —
|
|
1018
|
+
* which keeps this window's frame on the clock too, where the early frame
|
|
1019
|
+
* an input asks for would have painted it at once (src/frames.js).
|
|
1020
|
+
*
|
|
1021
|
+
* The clock pairs the two: a tick runs this window's frame and then its
|
|
1022
|
+
* surfaces', so a GL frame goes out after the overlay its children were
|
|
1023
|
+
* painted on (#641). An input painted early went around that — the
|
|
1024
|
+
* window's frame, the overlay with it and the present, inside the input's
|
|
1025
|
+
* own dispatch, and the GL frame the same input asked for a refresh or
|
|
1026
|
+
* more later, on the clock. For that long the children were drawn over
|
|
1027
|
+
* the frame before: a graph whose cards were a surface's children hid
|
|
1028
|
+
* them for a wheel zoom that way, and showed its edges with nothing under
|
|
1029
|
+
* them for 20–25ms, until the GL frame that drew the cards came. Such an
|
|
1030
|
+
* input is answered on the clock, both halves in order. What it gives up
|
|
1031
|
+
* is its 2D half's head start; the GL half never had one.
|
|
1032
|
+
*/
|
|
1033
|
+
surfaceFrameOwed() {
|
|
1034
|
+
for (const entry of this.app._rafQueue) {
|
|
1035
|
+
if (entry.surface && entry.wnd === this) return true;
|
|
1036
|
+
}
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1016
1040
|
/**
|
|
1017
1041
|
* The earliest moment this window's clock will hand out another frame,
|
|
1018
1042
|
* for a gate of `interval` ms — the display's period by default, which
|
package/src/components/Button.js
CHANGED
|
@@ -8,11 +8,11 @@ import {
|
|
|
8
8
|
ABS_FILL,
|
|
9
9
|
Bezel,
|
|
10
10
|
NATIVE_BAND,
|
|
11
|
+
NATIVE_FOOTPRINT,
|
|
11
12
|
NATIVE_RING,
|
|
12
|
-
TITLE_BASELINE,
|
|
13
13
|
bezelNatural,
|
|
14
14
|
bezelShadow,
|
|
15
|
-
|
|
15
|
+
nativeTitleMargins,
|
|
16
16
|
nativeTitleStyle,
|
|
17
17
|
pressWash,
|
|
18
18
|
useNativeControls,
|
|
@@ -24,6 +24,11 @@ const h = React.createElement;
|
|
|
24
24
|
const VARIANTS = ['solid', 'outline', 'ghost'];
|
|
25
25
|
const SIZES = ['medium', 'small'];
|
|
26
26
|
|
|
27
|
+
// A label squeezed below its width wraps at its words, and the lines are
|
|
28
|
+
// centred under one another, as AppKit centres a title that wrapped and a
|
|
29
|
+
// browser a `<button>`'s. One line is as wide as its box, so it is not moved.
|
|
30
|
+
const CENTRED_LINES = Object.freeze({ textAlign: 'center' });
|
|
31
|
+
|
|
27
32
|
/**
|
|
28
33
|
* <Button onPress variant size primary disabled …boxProps>label</Button> —
|
|
29
34
|
* the standard push button the examples kept re-implementing: hover/press/
|
|
@@ -97,8 +102,8 @@ export function Button({
|
|
|
97
102
|
{
|
|
98
103
|
theme,
|
|
99
104
|
// The footprint the caller's style sizes; the button below keeps
|
|
100
|
-
//
|
|
101
|
-
style: [
|
|
105
|
+
// its own height inside it, centred (`NATIVE_FOOTPRINT`).
|
|
106
|
+
style: [NATIVE_FOOTPRINT, style],
|
|
102
107
|
},
|
|
103
108
|
h(
|
|
104
109
|
'box',
|
|
@@ -117,16 +122,23 @@ export function Button({
|
|
|
117
122
|
gap: small ? 6 : 8,
|
|
118
123
|
// AppKit's metrics, not the palette's: a native bezel is
|
|
119
124
|
// designed at its own height, and stretching it is what this
|
|
120
|
-
// mode exists to avoid. Width still follows the label
|
|
121
|
-
|
|
125
|
+
// mode exists to avoid. Width still follows the label — and
|
|
126
|
+
// so does the height, once there is not the width for the
|
|
127
|
+
// label: it wraps at its words, as a drawn one does, and the
|
|
128
|
+
// lines it grew by make the control taller. AppKit has a
|
|
129
|
+
// bezel for that button (src/cocoa/bezels.js); a title
|
|
130
|
+
// pinned to one line's height put its first line above the
|
|
131
|
+
// bezel instead.
|
|
132
|
+
minHeight: nat.height,
|
|
122
133
|
paddingLeft: small ? 10 : 14,
|
|
123
134
|
paddingRight: small ? 10 : 14,
|
|
124
135
|
// The natural box is the bezel's footprint, shadow included;
|
|
125
|
-
// the
|
|
126
|
-
//
|
|
127
|
-
//
|
|
136
|
+
// inside the shadow is the body, which an icon is centred on
|
|
137
|
+
// and the title is placed against — placed, not centred
|
|
138
|
+
// (`nativeTitleMargins`). A label centred by its capitals sat
|
|
139
|
+
// 1pt low beside a native button.
|
|
128
140
|
paddingTop: shadow.top,
|
|
129
|
-
paddingBottom: shadow.bottom
|
|
141
|
+
paddingBottom: shadow.bottom,
|
|
130
142
|
// The keyboard ring is the renderer's, on this box — shaped by
|
|
131
143
|
// the bezel's corners and hugging it, as AppKit's is
|
|
132
144
|
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
@@ -151,7 +163,11 @@ export function Button({
|
|
|
151
163
|
isDefault: primary && !disabled,
|
|
152
164
|
style: ABS_FILL,
|
|
153
165
|
}),
|
|
154
|
-
labelContent(children ?? label,
|
|
166
|
+
labelContent(children ?? label, [
|
|
167
|
+
nativeTitleStyle(controlSize),
|
|
168
|
+
nativeTitleMargins(nat, shadow, controlSize),
|
|
169
|
+
CENTRED_LINES,
|
|
170
|
+
]),
|
|
155
171
|
// The press answer. Last child on purpose: `:active` marks the
|
|
156
172
|
// pressed node and its ancestors, and the topmost child is what the
|
|
157
173
|
// press lands on. No hover tint — AppKit buttons have none.
|
|
@@ -271,6 +287,6 @@ export function Button({
|
|
|
271
287
|
style,
|
|
272
288
|
],
|
|
273
289
|
},
|
|
274
|
-
labelContent(children ?? label),
|
|
290
|
+
labelContent(children ?? label, CENTRED_LINES),
|
|
275
291
|
);
|
|
276
292
|
}
|
package/src/components/Select.js
CHANGED
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
ABS_FILL,
|
|
11
11
|
Bezel,
|
|
12
12
|
NATIVE_BAND,
|
|
13
|
+
NATIVE_FOOTPRINT,
|
|
13
14
|
NATIVE_MENU,
|
|
14
15
|
NATIVE_RING,
|
|
15
16
|
TITLE_BASELINE,
|
|
16
17
|
bezelNatural,
|
|
17
18
|
bezelShadow,
|
|
18
|
-
nativeFootprintStyle,
|
|
19
19
|
nativeTitleStyle,
|
|
20
20
|
pressWash,
|
|
21
21
|
useNativeControls,
|
|
@@ -550,7 +550,7 @@ export function Select({
|
|
|
550
550
|
},
|
|
551
551
|
// The caller's style, on the trigger where the trigger is the whole
|
|
552
552
|
// control. Under a native bezel it sizes the footprint below instead
|
|
553
|
-
// and the trigger keeps AppKit's height (`
|
|
553
|
+
// and the trigger keeps AppKit's height (`NATIVE_FOOTPRINT`).
|
|
554
554
|
!nativeControls && style,
|
|
555
555
|
],
|
|
556
556
|
},
|
|
@@ -677,6 +677,6 @@ export function Select({
|
|
|
677
677
|
// around them is what a `height`, a `flexGrow` or a parent's align-stretch
|
|
678
678
|
// is free to make taller, with the control centred in it (issue #510).
|
|
679
679
|
return nativeControls
|
|
680
|
-
? h('box', { theme, style: [
|
|
680
|
+
? h('box', { theme, style: [NATIVE_FOOTPRINT, style] }, trigger)
|
|
681
681
|
: trigger;
|
|
682
682
|
}
|
package/src/components/native.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import React, { useCallback, useRef } from 'react';
|
|
21
21
|
import { useAppOrNull } from '../appcontext.js';
|
|
22
|
-
import { useTheme } from './theme.js';
|
|
22
|
+
import { capBand, useTheme } from './theme.js';
|
|
23
23
|
|
|
24
24
|
const h = React.createElement;
|
|
25
25
|
|
|
@@ -101,6 +101,37 @@ export function nativeTitleStyle(controlSize = 'regular') {
|
|
|
101
101
|
return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Where a push button's title goes in a control whose padding is only the
|
|
106
|
+
* bezel's shadow — so the box inside it is the body, and an icon beside the
|
|
107
|
+
* title is centred on the body as AppKit centres an image. The title is a
|
|
108
|
+
* margin box inside that: `TITLE_BASELINE` under its last baseline, and over
|
|
109
|
+
* its capitals the room a one-line title leaves there in a body of the
|
|
110
|
+
* bezel's own height. So a title that fits sits exactly where the cell puts
|
|
111
|
+
* it, filling the body to the pixel, and one that wrapped keeps the same
|
|
112
|
+
* margins round the lines it grew by: the control is taller by those lines,
|
|
113
|
+
* and so is the bezel drawn to it (src/cocoa/bezels.js, the flexible push).
|
|
114
|
+
* AppKit's own flexible push starts a wrapped title's capitals within half a
|
|
115
|
+
* point of here (4.5pt under the body's top edge at the regular size, 4 at
|
|
116
|
+
* the small) and ends it 2.5pt nearer the bottom edge at the regular size:
|
|
117
|
+
* the one-line baseline is kept instead, so a title does not move when it
|
|
118
|
+
* wraps, and a wrapped button is that much taller than AppKit's.
|
|
119
|
+
*
|
|
120
|
+
* The capitals are measured the way the title will be, `capBand` of the
|
|
121
|
+
* control font size, so the two cancel: a face whose trimmed caps round to
|
|
122
|
+
* the band — every UI face measured — fills the body exactly and never
|
|
123
|
+
* grows the control by a line it does not have.
|
|
124
|
+
*/
|
|
125
|
+
export function nativeTitleMargins(nat, shadow, controlSize = 'regular') {
|
|
126
|
+
const body = nat.height - shadow.top - shadow.bottom;
|
|
127
|
+
const baseline = TITLE_BASELINE[controlSize];
|
|
128
|
+
const caps = capBand(CONTROL_FONT_SIZE[controlSize]);
|
|
129
|
+
return {
|
|
130
|
+
marginTop: Math.max(0, body - baseline - caps),
|
|
131
|
+
marginBottom: baseline,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
/**
|
|
105
136
|
* The **footprint** a native control sits in: the box the caller's style
|
|
106
137
|
* sizes, with the control — bezel, title and press wash, one unit at
|
|
@@ -108,9 +139,9 @@ export function nativeTitleStyle(controlSize = 'regular') {
|
|
|
108
139
|
*
|
|
109
140
|
* Two boxes rather than one, because the title is *placed* and not centred:
|
|
110
141
|
* it rides `TITLE_BASELINE` above the bezel body's bottom edge, and that is
|
|
111
|
-
* only where the cell puts it while the box is
|
|
112
|
-
*
|
|
113
|
-
*
|
|
142
|
+
* only where the cell puts it while the box is the control's own height. The
|
|
143
|
+
* caller's style is applied last and flex stretches a box regardless, so it
|
|
144
|
+
* was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
|
|
114
145
|
* parent and a bare `flexGrow: 1` each made the box taller, and the two
|
|
115
146
|
* halves — the bezel absolutely filling it, the title glued to its bottom —
|
|
116
147
|
* came apart, the label landing below the control it names (issue #510). The
|
|
@@ -118,20 +149,20 @@ export function nativeTitleStyle(controlSize = 'regular') {
|
|
|
118
149
|
* control is merely roomy, where a stretched native one was broken.
|
|
119
150
|
*
|
|
120
151
|
* So the height a caller can move belongs to a box the control sits in, and
|
|
121
|
-
* the control keeps
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* the
|
|
152
|
+
* the control keeps its own. Untouched, the footprint is exactly as tall as
|
|
153
|
+
* the control — AppKit's height, or a button's taller one when its label
|
|
154
|
+
* wrapped — and the caller's style, applied after it, moves the footprint
|
|
155
|
+
* alone. A row's align-stretch moves it too, and the control stays centred
|
|
156
|
+
* in the slot, which is where a control that cannot stretch belongs beside a
|
|
157
|
+
* taller neighbour. The control stretches across it (align-stretch, the
|
|
158
|
+
* default), so a footprint given a width is a bezel that wide.
|
|
126
159
|
*
|
|
127
160
|
* The control, not the footprint, stays the *control*: the role, the
|
|
128
161
|
* handlers, the focus ring and the ref go on it, so a press in the slack
|
|
129
162
|
* above a 22pt popup in a 46pt hole does nothing at all — as it does in
|
|
130
163
|
* AppKit — rather than firing a button with no visible answer to the press.
|
|
131
164
|
*/
|
|
132
|
-
export
|
|
133
|
-
return { height: nat.height, justifyContent: 'center' };
|
|
134
|
-
}
|
|
165
|
+
export const NATIVE_FOOTPRINT = Object.freeze({ justifyContent: 'center' });
|
|
135
166
|
|
|
136
167
|
/**
|
|
137
168
|
* The rest of what a native control's own box says about its size: nothing
|
package/src/frames.js
CHANGED
|
@@ -53,6 +53,12 @@ export function clearPendingFrame(node) {
|
|
|
53
53
|
* - **Every window answers for itself.** The set is app-wide, so a click
|
|
54
54
|
* that dirtied both a menu and the window under it paints both, each
|
|
55
55
|
* gated on its own window's frame clock.
|
|
56
|
+
* - **Nor while a `<glarea>` of it waits on the clock.** A window's frame
|
|
57
|
+
* paints the overlay a surface's children are drawn on, and the clock
|
|
58
|
+
* draws the surface's own frame after it, in the same tick (#641). A
|
|
59
|
+
* window that says it owes one (`surfaceFrameOwed`) keeps its paced
|
|
60
|
+
* frame: painted now, the overlay would go out a refresh or more ahead
|
|
61
|
+
* of the GL frame it was painted for.
|
|
56
62
|
*
|
|
57
63
|
* A window whose scheduled frame this pays off keeps that scheduled
|
|
58
64
|
* callback; it runs and finds `needsPaint` false, which is already a cheap
|
|
@@ -68,6 +74,7 @@ export function flushPendingFrames() {
|
|
|
68
74
|
// Same for a headless mock window that models no frame clock at all.
|
|
69
75
|
if (typeof wnd?.frameInFlight !== 'function') continue;
|
|
70
76
|
if (wnd.frameInFlight()) continue;
|
|
77
|
+
if (wnd.surfaceFrameOwed?.()) continue;
|
|
71
78
|
node.flush();
|
|
72
79
|
}
|
|
73
80
|
}
|
|
@@ -104,8 +104,9 @@ export const mainAxisOf = (node) => {
|
|
|
104
104
|
/**
|
|
105
105
|
* Whether `child` is one the floors are **written on** along `axis`: a flex
|
|
106
106
|
* item on its container's main axis whose author left the minimum to the
|
|
107
|
-
* content
|
|
108
|
-
*
|
|
107
|
+
* content, and which flex could squeeze below it. The same test
|
|
108
|
+
* `writeFloors` applies, asked ahead of time — it is what decides whether a
|
|
109
|
+
* stale extent is one anybody will read.
|
|
109
110
|
*/
|
|
110
111
|
export function receivesFloor(child, axis) {
|
|
111
112
|
const parent = child.parent;
|
|
@@ -118,7 +119,37 @@ export function receivesFloor(child, axis) {
|
|
|
118
119
|
return false;
|
|
119
120
|
}
|
|
120
121
|
const own = axis === 'width' ? 'minWidth' : 'minHeight';
|
|
121
|
-
return typeof child.style[own] !== 'number';
|
|
122
|
+
return typeof child.style[own] !== 'number' && !floorIsNamedSize(child, axis);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether this item is its named size on `axis` in every pass, so that the
|
|
127
|
+
* floor a measurement would write is that same number and can never bind:
|
|
128
|
+
* a number for its size along the axis, as its flex basis (`flexBasis` unset
|
|
129
|
+
* or `'auto'`), `flexShrink: 0` and no `flexGrow`. Flex then takes its basis
|
|
130
|
+
* from that number and neither shrinks nor grows it, and measuring it is work
|
|
131
|
+
* nobody reads. A table's cells, at their columns' widths, are the case that
|
|
132
|
+
* paid: every row that scrolled into a `<Table>` was laid out alone for their
|
|
133
|
+
* floors before its frame could be, over half of a fling's frame time.
|
|
134
|
+
*
|
|
135
|
+
* A differential run over random trees, laid out with and without the
|
|
136
|
+
* rule, found why each clause is there. An auto basis is yoga's own
|
|
137
|
+
* measurement of the content, which can come out short of what the content
|
|
138
|
+
* needs, and the floor is what holds the item open. A named size that can
|
|
139
|
+
* grow is written as the size it grew to in the measuring pass, which the
|
|
140
|
+
* floor then keeps. And a `flexBasis` of its own is where the basis comes
|
|
141
|
+
* from instead of the size, and a percentage one is a different length in a
|
|
142
|
+
* measuring pass than in the real one.
|
|
143
|
+
*/
|
|
144
|
+
function floorIsNamedSize(child, axis) {
|
|
145
|
+
const style = child.style;
|
|
146
|
+
const basis = style.flexBasis;
|
|
147
|
+
return (
|
|
148
|
+
style.flexShrink === 0 &&
|
|
149
|
+
!(style.flexGrow > 0) &&
|
|
150
|
+
(basis === undefined || basis === 'auto') &&
|
|
151
|
+
typeof (axis === 'width' ? style.width : style.height) === 'number'
|
|
152
|
+
);
|
|
122
153
|
}
|
|
123
154
|
|
|
124
155
|
/**
|
|
@@ -848,7 +879,12 @@ export function writeFloors(node, axis) {
|
|
|
848
879
|
// A floor of 0 is what yoga does anyway, and an author who named their
|
|
849
880
|
// own `minWidth`/`minHeight` has already answered — overwriting it would
|
|
850
881
|
// put a measurement of ours above a number they wrote.
|
|
851
|
-
if (
|
|
882
|
+
if (
|
|
883
|
+
axisIsMain &&
|
|
884
|
+
inFlow(child) &&
|
|
885
|
+
typeof child.style[own] !== 'number' &&
|
|
886
|
+
!floorIsNamedSize(child, axis)
|
|
887
|
+
) {
|
|
852
888
|
const extent = horizontal ? child._floorW : child._floorH;
|
|
853
889
|
if (extent > 0) floor = extent;
|
|
854
890
|
}
|
package/src/win32/a11y.js
CHANGED
|
@@ -33,24 +33,21 @@ import {
|
|
|
33
33
|
} from '../a11y.js';
|
|
34
34
|
import { synthesizeClick } from '../events.js';
|
|
35
35
|
import { onApp } from '../trace-registry.js';
|
|
36
|
+
import { A11yPacer } from '../a11ypace.js';
|
|
36
37
|
|
|
37
38
|
const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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;
|
|
40
|
+
// How often commits may push the mirror is src/a11ypace.js's to say, for
|
|
41
|
+
// this bridge and the AT-SPI one alike: at once after a quiet spell, and for
|
|
42
|
+
// a stream of commits (a pan, a drag, a value ticking) once per interval,
|
|
43
|
+
// the last push catching up. UIA's clients are almost always listening on a
|
|
44
|
+
// desktop Windows (the touch keyboard, the text services and others ask for
|
|
45
|
+
// the tree), so pushing is not the rare case the listening gate makes it
|
|
46
|
+
// elsewhere. For a graph pane whose every item moves on every step of a pan,
|
|
47
|
+
// a push per commit was 1.6 ms of each frame. A screen reader reads positions
|
|
48
|
+
// on demand and does not need sixty of them a second. What it does wait on
|
|
49
|
+
// is pushed at once: a focus change (`hooks.focus`), an announcement, and a
|
|
50
|
+
// change to the focused element itself (`_touch`).
|
|
54
51
|
|
|
55
52
|
// --------------------------------------------------------------------------
|
|
56
53
|
// The one translation that is Windows' own
|
|
@@ -316,9 +313,12 @@ export class Win32Accessibility {
|
|
|
316
313
|
this._pushed = new Set();
|
|
317
314
|
this._unsubscribes = [];
|
|
318
315
|
this.dead = false;
|
|
319
|
-
/** When
|
|
320
|
-
this.
|
|
321
|
-
|
|
316
|
+
/** When commits may push (src/a11ypace.js). */
|
|
317
|
+
this._pacer = new A11yPacer();
|
|
318
|
+
/** The focused node, and whether a commit changed it: its own changes
|
|
319
|
+
* are what a screen reader is waiting to hear, and are not paced. */
|
|
320
|
+
this._focused = null;
|
|
321
|
+
this._urgentPending = false;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
_idOf(node) {
|
|
@@ -454,23 +454,21 @@ export class Win32Accessibility {
|
|
|
454
454
|
}
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
/** A commit's push, at
|
|
457
|
+
/** A commit's push, paced (src/a11ypace.js), and at once where it
|
|
458
|
+
* changed the focused element. */
|
|
458
459
|
_commitFlush() {
|
|
459
460
|
if (this.dead || this._dirty.size === 0) return;
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
this.
|
|
465
|
-
return;
|
|
461
|
+
const urgent = this._urgentPending;
|
|
462
|
+
this._urgentPending = false;
|
|
463
|
+
if (urgent) this._pacer.cancel();
|
|
464
|
+
if (urgent || this._pacer.ready(() => this._pacedFlush())) {
|
|
465
|
+
this._pacedFlush();
|
|
466
466
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}, wait);
|
|
473
|
-
this._trailing.unref?.();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
_pacedFlush() {
|
|
470
|
+
this._pacer.pushed();
|
|
471
|
+
this.flush();
|
|
474
472
|
}
|
|
475
473
|
|
|
476
474
|
/** A window's HWND exists now, so what is owed for it can be pushed. */
|
|
@@ -501,9 +499,19 @@ export class Win32Accessibility {
|
|
|
501
499
|
return root && this.toplevels.includes(root) ? root : null;
|
|
502
500
|
}
|
|
503
501
|
|
|
504
|
-
|
|
502
|
+
/** `own`: the change is the node's own (its props or text), which is
|
|
503
|
+
* urgent where the node is, or is inside, the focused element. */
|
|
504
|
+
_touch(node, own = false) {
|
|
505
505
|
const win = this._toplevelOf(node);
|
|
506
|
-
if (win)
|
|
506
|
+
if (!win) return;
|
|
507
|
+
this._dirty.add(win);
|
|
508
|
+
if (!own || this._focused === null || this._urgentPending) return;
|
|
509
|
+
for (let n = node; n; n = n.parent) {
|
|
510
|
+
if (n === this._focused) {
|
|
511
|
+
this._urgentPending = true;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
507
515
|
}
|
|
508
516
|
|
|
509
517
|
// ---- what the shell asks for ------------------------------------------
|
|
@@ -594,10 +602,11 @@ export class Win32Accessibility {
|
|
|
594
602
|
};
|
|
595
603
|
hooks.attached = (parent) => this._touch(parent);
|
|
596
604
|
hooks.detach = (parent) => this._touch(parent);
|
|
597
|
-
hooks.propsChanged = (node) => this._touch(node);
|
|
598
|
-
hooks.textContent = (chunk) => this._touch(chunk.parent ?? chunk);
|
|
599
|
-
hooks.textState = (node) => this._touch(node);
|
|
605
|
+
hooks.propsChanged = (node) => this._touch(node, true);
|
|
606
|
+
hooks.textContent = (chunk) => this._touch(chunk.parent ?? chunk, true);
|
|
607
|
+
hooks.textState = (node) => this._touch(node, true);
|
|
600
608
|
hooks.focus = (previous, next) => {
|
|
609
|
+
this._focused = next ?? null;
|
|
601
610
|
const win = this._toplevelOf(next ?? previous);
|
|
602
611
|
if (!win) return;
|
|
603
612
|
this._dirty.add(win);
|
|
@@ -637,8 +646,7 @@ export class Win32Accessibility {
|
|
|
637
646
|
bury() {
|
|
638
647
|
if (this.dead) return;
|
|
639
648
|
this.dead = true;
|
|
640
|
-
|
|
641
|
-
this._trailing = null;
|
|
649
|
+
this._pacer.cancel();
|
|
642
650
|
for (const key of Object.keys(hooks)) hooks[key] = null;
|
|
643
651
|
for (const unsubscribe of this._unsubscribes) unsubscribe();
|
|
644
652
|
this._unsubscribes = [];
|