react-x11 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// The assistive-technology spy: what a screen reader would be told, as an
|
|
2
|
+
// in-process event log — no D-Bus, no bus, no async gap between the app
|
|
3
|
+
// changing and the assertion seeing it.
|
|
4
|
+
//
|
|
5
|
+
// ## Why this exists next to the real bridge
|
|
6
|
+
//
|
|
7
|
+
// The AT-SPI bridge (src/atspi.js) consumes the renderer through a set of
|
|
8
|
+
// hook slots (src/a11y.js) and a pure semantic model. Everything an AT
|
|
9
|
+
// learns — focus moved, a state flipped, text changed, an announcement —
|
|
10
|
+
// crosses those hooks before it ever becomes D-Bus. So a spy that fills
|
|
11
|
+
// the same slots observes the same contract with none of the transport:
|
|
12
|
+
// it runs on the mock backend, on Node 20 where dbus-native is not
|
|
13
|
+
// installed, on macOS, and it is synchronous where the wire is not. The
|
|
14
|
+
// bridge's own wire behaviour is covered separately (test/atspi.test.js,
|
|
15
|
+
// against an in-process message bus); an application's tests should not
|
|
16
|
+
// pay D-Bus latency to check their own labels.
|
|
17
|
+
//
|
|
18
|
+
// Because the slots are the seam, the spy also keeps the renderer honest:
|
|
19
|
+
// a regression that stops a hook firing fails these tests exactly as it
|
|
20
|
+
// would silence a real screen reader.
|
|
21
|
+
//
|
|
22
|
+
// ## Two layers per entry
|
|
23
|
+
//
|
|
24
|
+
// Every log entry is a precise fact (`{ type: 'state', state: 'checked',
|
|
25
|
+
// on: true, node }`) plus a `summary` string ("state: checked"). Assert on
|
|
26
|
+
// the facts when exactness matters; assert on `transcript()` when the
|
|
27
|
+
// question is "what would a user have been told" — which is also the form
|
|
28
|
+
// that catches the omission tree-shaped assertions structurally miss: a
|
|
29
|
+
// control nobody named renders as "(no accessible name)" instead of
|
|
30
|
+
// passing because no assertion mentioned its name.
|
|
31
|
+
//
|
|
32
|
+
// The utterances follow react-x11's **own documented model** (name, role,
|
|
33
|
+
// the states worth speaking, a value as a percentage) — deliberately not
|
|
34
|
+
// an imitation of Orca, whose wording is presentation policy that shifts
|
|
35
|
+
// between releases and verbosity settings. See docs/accessibility.md.
|
|
36
|
+
//
|
|
37
|
+
// ```js
|
|
38
|
+
// const { at, getByRole } = await renderX11(h(App), { a11y: true });
|
|
39
|
+
// await userEvent.tab();
|
|
40
|
+
// assert.equal(at.focused().utterance, 'Save, button');
|
|
41
|
+
// await userEvent.key(XK_space);
|
|
42
|
+
// assert.ok(at.since().some((e) => e.type === 'state' && e.state === 'checked'));
|
|
43
|
+
// ```
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
hooks,
|
|
47
|
+
ATSPI_ROLE_NICK,
|
|
48
|
+
ATSPI_STATE,
|
|
49
|
+
ATSPI_STATE_NICK,
|
|
50
|
+
atspiRoleOf,
|
|
51
|
+
a11yName,
|
|
52
|
+
a11yStates,
|
|
53
|
+
a11yValue,
|
|
54
|
+
a11yParent,
|
|
55
|
+
sceneChildrenOf,
|
|
56
|
+
hasTextInterface,
|
|
57
|
+
textStateOf,
|
|
58
|
+
inPreedit,
|
|
59
|
+
diffChars,
|
|
60
|
+
} from '../a11y.js';
|
|
61
|
+
|
|
62
|
+
/** The states a screen reader would speak, in speaking order, with the
|
|
63
|
+
* words this library's model uses for them. Everything else stays quiet —
|
|
64
|
+
* a reader that voiced every flag would be unusable. */
|
|
65
|
+
const SPOKEN_STATES = [
|
|
66
|
+
['checked', 'checked'],
|
|
67
|
+
['indeterminate', 'partially checked'],
|
|
68
|
+
['pressed', 'pressed'],
|
|
69
|
+
['expanded', 'expanded'],
|
|
70
|
+
['collapsed', 'collapsed'],
|
|
71
|
+
['selected', 'selected'],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One utterance from its parts — the shared formatter behind the spy, and
|
|
76
|
+
* behind `scripts/a11y-probe.mjs`, which feeds it values read over real
|
|
77
|
+
* D-Bus so the two can never drift apart.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} parts
|
|
80
|
+
* @param {string} parts.name the accessible name ('' when there is none)
|
|
81
|
+
* @param {string} [parts.role] the AT-SPI role name ("check box", "entry")
|
|
82
|
+
* @param {string[]} [parts.states] state nicks ("checked", "sensitive", …)
|
|
83
|
+
* @param {{now: number, min?: number, max?: number} | null} [parts.value]
|
|
84
|
+
* @returns {string} e.g. `"Volume, slider, 30 percent"`, or
|
|
85
|
+
* `"(no accessible name)"` when there is nothing to say — which is the
|
|
86
|
+
* defect this string exists to make loud.
|
|
87
|
+
*/
|
|
88
|
+
export function utteranceOf({ name, role, states = [], value = null }) {
|
|
89
|
+
const words = [];
|
|
90
|
+
if (name) words.push(name);
|
|
91
|
+
if (role && role !== 'filler') words.push(role);
|
|
92
|
+
// an empty states array means "not told", not "insensitive"
|
|
93
|
+
if (states.length > 0 && !states.includes('sensitive')) {
|
|
94
|
+
words.push('unavailable');
|
|
95
|
+
}
|
|
96
|
+
for (const [state, said] of SPOKEN_STATES) {
|
|
97
|
+
if (states.includes(state)) words.push(said);
|
|
98
|
+
}
|
|
99
|
+
if (value && typeof value.now === 'number') {
|
|
100
|
+
const span = (value.max ?? 0) - (value.min ?? 0);
|
|
101
|
+
words.push(
|
|
102
|
+
span > 0
|
|
103
|
+
? `${Math.round(((value.now - (value.min ?? 0)) / span) * 100)} percent`
|
|
104
|
+
: String(value.now),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return words.length ? words.join(', ') : '(no accessible name)';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The set state nicks of a node, from the model's two uint32s. */
|
|
111
|
+
function stateNicksOf(node) {
|
|
112
|
+
const [lo, hi] = a11yStates(node);
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const bit of Object.values(ATSPI_STATE)) {
|
|
115
|
+
const on = bit < 32 ? lo & (1 << bit) : hi & (1 << (bit - 32));
|
|
116
|
+
if (on) out.push(ATSPI_STATE_NICK[bit]);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The utterance for a live node, through the same model the bridge
|
|
122
|
+
* serves. */
|
|
123
|
+
export function nodeUtterance(node) {
|
|
124
|
+
return utteranceOf({
|
|
125
|
+
name: a11yName(node),
|
|
126
|
+
role: ATSPI_ROLE_NICK[atspiRoleOf(node)],
|
|
127
|
+
states: stateNicksOf(node),
|
|
128
|
+
value: a11yValue(node),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Internal kinds that are content, not accessibility objects — the same
|
|
133
|
+
* exclusion the tree projection makes. */
|
|
134
|
+
const CONTENT_KINDS = new Set(['textchunk', 'svgchild']);
|
|
135
|
+
|
|
136
|
+
export class A11ySpy {
|
|
137
|
+
constructor() {
|
|
138
|
+
/** Every entry, oldest first. Each has `type`, `summary`, and usually
|
|
139
|
+
* `node`; see the handlers below for the shapes. */
|
|
140
|
+
this.log = [];
|
|
141
|
+
this._sinceIndex = 0;
|
|
142
|
+
this._snapshots = new WeakMap();
|
|
143
|
+
this._toplevels = [];
|
|
144
|
+
this._installed = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- lifecycle -------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
install() {
|
|
150
|
+
if (this._installed) return this;
|
|
151
|
+
const occupied = Object.entries(hooks)
|
|
152
|
+
.filter(([, slot]) => slot !== null)
|
|
153
|
+
.map(([name]) => name);
|
|
154
|
+
if (occupied.length > 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
'react-x11/test: the a11y hook slots are already taken ' +
|
|
157
|
+
`(${occupied.join(', ')}) — the AT-SPI bridge is live in this ` +
|
|
158
|
+
'process. The spy and the bridge observe through the same seam, ' +
|
|
159
|
+
'so run spy tests without AT_SPI_BUS_ADDRESS/REACT_X11_A11Y=1 ' +
|
|
160
|
+
'(react-x11/test sets NO_AT_BRIDGE for exactly this reason).',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const spy = this;
|
|
164
|
+
const installed = {
|
|
165
|
+
rootMounted(win) {
|
|
166
|
+
if (!spy._toplevels.includes(win)) spy._toplevels.push(win);
|
|
167
|
+
spy._snapshotTree(win);
|
|
168
|
+
},
|
|
169
|
+
rootUnmounted(win) {
|
|
170
|
+
const at = spy._toplevels.indexOf(win);
|
|
171
|
+
if (at !== -1) spy._toplevels.splice(at, 1);
|
|
172
|
+
},
|
|
173
|
+
attached(parent, child) {
|
|
174
|
+
if (spy._live(parent)) spy._snapshotTree(child);
|
|
175
|
+
},
|
|
176
|
+
detach: null,
|
|
177
|
+
propsChanged(node) {
|
|
178
|
+
spy._diffNode(node);
|
|
179
|
+
},
|
|
180
|
+
textContent(chunk) {
|
|
181
|
+
// the chunk's string is the content of its <text>, whose name — and
|
|
182
|
+
// the name of any contents-named ancestor — may just have changed
|
|
183
|
+
let text = chunk.parent;
|
|
184
|
+
while (text && text.kind === 'text' && text.isSpan) text = text.parent;
|
|
185
|
+
if (!text) return;
|
|
186
|
+
for (let n = text; n; n = a11yParent(n)) {
|
|
187
|
+
spy._diffNode(n);
|
|
188
|
+
if (n.isWindow) break;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
textState(node) {
|
|
192
|
+
spy._diffNode(node);
|
|
193
|
+
},
|
|
194
|
+
focus(previous, next) {
|
|
195
|
+
// re-snapshot both ends so the FOCUSED bit never double-reports
|
|
196
|
+
// through a later props diff
|
|
197
|
+
if (previous && !previous.destroyed) spy._resnapshot(previous);
|
|
198
|
+
if (next && !next.destroyed) {
|
|
199
|
+
spy._resnapshot(next);
|
|
200
|
+
const utterance = nodeUtterance(next);
|
|
201
|
+
spy._push({
|
|
202
|
+
type: 'focus',
|
|
203
|
+
node: next,
|
|
204
|
+
utterance,
|
|
205
|
+
summary: `focus: ${utterance}`,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
spy._push({ type: 'blur', node: previous ?? null, summary: 'blur' });
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
windowFocus(win, focused) {
|
|
212
|
+
spy._resnapshot(win);
|
|
213
|
+
spy._push({
|
|
214
|
+
type: 'window',
|
|
215
|
+
node: win,
|
|
216
|
+
focused,
|
|
217
|
+
summary: `window: ${focused ? 'active' : 'inactive'}`,
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
commit: null,
|
|
221
|
+
announce(text, opts) {
|
|
222
|
+
spy._push({
|
|
223
|
+
type: 'announce',
|
|
224
|
+
text: String(text),
|
|
225
|
+
assertive: Boolean(opts?.assertive),
|
|
226
|
+
summary: `announce: ${text}`,
|
|
227
|
+
});
|
|
228
|
+
// an announcement the test observed *was* delivered
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
for (const [name, fn] of Object.entries(installed)) hooks[name] = fn;
|
|
233
|
+
this._installed = installed;
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Put the slots back. Idempotent, and careful not to evict somebody
|
|
238
|
+
* else's handlers if the test already replaced them. */
|
|
239
|
+
uninstall() {
|
|
240
|
+
if (!this._installed) return;
|
|
241
|
+
for (const [name, fn] of Object.entries(this._installed)) {
|
|
242
|
+
if (hooks[name] === fn) hooks[name] = null;
|
|
243
|
+
}
|
|
244
|
+
this._installed = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- the log ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/** Everything recorded so far, oldest first. */
|
|
250
|
+
events() {
|
|
251
|
+
return [...this.log];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The `summary` line of every entry — the "what was the user told"
|
|
255
|
+
* view, made for `assert.deepEqual`. */
|
|
256
|
+
transcript() {
|
|
257
|
+
return this.log.map((entry) => entry.summary);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Entries recorded since the previous `since()` (or `clear()`), so a
|
|
261
|
+
* test reads one interaction's worth at a time. */
|
|
262
|
+
since() {
|
|
263
|
+
const fresh = this.log.slice(this._sinceIndex);
|
|
264
|
+
this._sinceIndex = this.log.length;
|
|
265
|
+
return fresh;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
clear() {
|
|
269
|
+
this.log.length = 0;
|
|
270
|
+
this._sinceIndex = 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- live queries ----------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** The focused node as an AT would describe it, or null. With several
|
|
276
|
+
* roots, the window that actually holds the X focus wins. */
|
|
277
|
+
focused() {
|
|
278
|
+
const win =
|
|
279
|
+
this._toplevels.find((w) => w.events?.windowFocused) ??
|
|
280
|
+
this._toplevels[0];
|
|
281
|
+
const manager = win?.events?.focusManager ?? win?.events;
|
|
282
|
+
const node = manager?.focused ?? null;
|
|
283
|
+
if (!node || node.destroyed) return null;
|
|
284
|
+
return this._describe(node);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Every node the keyboard can reach, in Tab order — the same sequential
|
|
289
|
+
* order the event manager cycles, focus scopes included. The one-line
|
|
290
|
+
* audit this powers:
|
|
291
|
+
*
|
|
292
|
+
* ```js
|
|
293
|
+
* for (const stop of at.focusables()) {
|
|
294
|
+
* assert.notEqual(stop.utterance, '(no accessible name)');
|
|
295
|
+
* }
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
focusables() {
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const win of this._toplevels) {
|
|
301
|
+
const manager = win.events;
|
|
302
|
+
if (!manager?._tabbables) continue;
|
|
303
|
+
for (const node of manager._tabbables()) out.push(this._describe(node));
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_describe(node) {
|
|
309
|
+
return {
|
|
310
|
+
node,
|
|
311
|
+
name: a11yName(node),
|
|
312
|
+
role: ATSPI_ROLE_NICK[atspiRoleOf(node)],
|
|
313
|
+
states: stateNicksOf(node),
|
|
314
|
+
utterance: nodeUtterance(node),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- snapshots and diffing ------------------------------------------
|
|
319
|
+
|
|
320
|
+
_push(entry) {
|
|
321
|
+
this.log.push(entry);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_live(node) {
|
|
325
|
+
let n = node;
|
|
326
|
+
while (n?.parent) n = n.parent;
|
|
327
|
+
return n ? this._toplevels.includes(n) : false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_snap(node) {
|
|
331
|
+
return {
|
|
332
|
+
states: a11yStates(node),
|
|
333
|
+
name: a11yName(node),
|
|
334
|
+
value: a11yValue(node)?.now ?? null,
|
|
335
|
+
text: hasTextInterface(node) ? textStateOf(node) : null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_resnapshot(node) {
|
|
340
|
+
this._snapshots.set(node, this._snap(node));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_snapshotTree(node) {
|
|
344
|
+
if (node.destroyed || CONTENT_KINDS.has(node.kind)) return;
|
|
345
|
+
if (!this._snapshots.has(node)) this._resnapshot(node);
|
|
346
|
+
for (const child of node.children ?? []) this._snapshotTree(child);
|
|
347
|
+
// what an element drew is baselined with everything else, or the first
|
|
348
|
+
// thing that happens to an item — the selection landing on it — reads
|
|
349
|
+
// as a mount and says nothing
|
|
350
|
+
for (const item of sceneChildrenOf(node)) this._snapshotTree(item);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Recompute what an AT knows about a node and log the exact difference —
|
|
355
|
+
* the same snapshot-and-diff the bridge turns into D-Bus events, so any
|
|
356
|
+
* prop change produces the right entries with no per-prop wiring. A node
|
|
357
|
+
* seen for the first time only takes a baseline: a mount is not a
|
|
358
|
+
* change.
|
|
359
|
+
*/
|
|
360
|
+
_diffNode(node) {
|
|
361
|
+
if (node.destroyed || CONTENT_KINDS.has(node.kind)) return;
|
|
362
|
+
const before = this._snapshots.get(node);
|
|
363
|
+
const now = this._snap(node);
|
|
364
|
+
this._snapshots.set(node, now);
|
|
365
|
+
if (!before) return;
|
|
366
|
+
|
|
367
|
+
if (now.name !== before.name) {
|
|
368
|
+
this._push({
|
|
369
|
+
type: 'name',
|
|
370
|
+
node,
|
|
371
|
+
name: now.name,
|
|
372
|
+
summary: `name: ${now.name}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (now.value !== before.value && now.value !== null) {
|
|
376
|
+
this._push({
|
|
377
|
+
type: 'value',
|
|
378
|
+
node,
|
|
379
|
+
value: now.value,
|
|
380
|
+
summary: `value: ${now.value}`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
for (const half of [0, 1]) {
|
|
384
|
+
let changed = (before.states[half] ^ now.states[half]) >>> 0;
|
|
385
|
+
while (changed !== 0) {
|
|
386
|
+
const low = changed & -changed;
|
|
387
|
+
const bit = 31 - Math.clz32(low) + half * 32;
|
|
388
|
+
const on = Boolean(now.states[half] & low);
|
|
389
|
+
const nick = ATSPI_STATE_NICK[bit];
|
|
390
|
+
this._push({
|
|
391
|
+
type: 'state',
|
|
392
|
+
node,
|
|
393
|
+
state: nick,
|
|
394
|
+
on,
|
|
395
|
+
summary: `state: ${on ? '' : 'not '}${nick}`,
|
|
396
|
+
});
|
|
397
|
+
changed = (changed ^ low) >>> 0;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (before.text && now.text) {
|
|
401
|
+
const diff = diffChars(before.text.chars, now.text.chars);
|
|
402
|
+
// A composition's own churn is not an edit. A preedit appearing,
|
|
403
|
+
// growing or being abandoned is one `preedit` entry rather than the
|
|
404
|
+
// insert and delete it technically is on screen — and what the
|
|
405
|
+
// sequence finally *commits* stays an ordinary insert, because that
|
|
406
|
+
// is the character the user typed. The bridge draws the same line
|
|
407
|
+
// with the `:system` detail suffix; this is the same rule, spoken.
|
|
408
|
+
const wasComposing = before.text.preedit?.text ?? '';
|
|
409
|
+
const isComposing = now.text.preedit?.text ?? '';
|
|
410
|
+
if (
|
|
411
|
+
diff &&
|
|
412
|
+
diff.removed.length > 0 &&
|
|
413
|
+
!inPreedit(before.text, diff.offset, diff.removed.length)
|
|
414
|
+
) {
|
|
415
|
+
this._push({
|
|
416
|
+
type: 'text-delete',
|
|
417
|
+
node,
|
|
418
|
+
text: diff.removed.join(''),
|
|
419
|
+
offset: diff.offset,
|
|
420
|
+
summary: `delete: ${JSON.stringify(diff.removed.join(''))}`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (isComposing !== wasComposing) {
|
|
424
|
+
this._push({
|
|
425
|
+
type: 'preedit',
|
|
426
|
+
node,
|
|
427
|
+
text: isComposing,
|
|
428
|
+
offset: now.text.preedit?.offset ?? before.text.preedit?.offset ?? 0,
|
|
429
|
+
summary: isComposing
|
|
430
|
+
? `preedit: ${JSON.stringify(isComposing)}`
|
|
431
|
+
: 'preedit: cleared',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (
|
|
435
|
+
diff &&
|
|
436
|
+
diff.inserted.length > 0 &&
|
|
437
|
+
!inPreedit(now.text, diff.offset, diff.inserted.length)
|
|
438
|
+
) {
|
|
439
|
+
this._push({
|
|
440
|
+
type: 'text-insert',
|
|
441
|
+
node,
|
|
442
|
+
text: diff.inserted.join(''),
|
|
443
|
+
offset: diff.offset,
|
|
444
|
+
summary: `insert: ${JSON.stringify(diff.inserted.join(''))}`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (!diff && now.text.caret !== before.text.caret) {
|
|
448
|
+
// a caret move without an edit — arrows, Home/End, a click
|
|
449
|
+
this._push({
|
|
450
|
+
type: 'caret',
|
|
451
|
+
node,
|
|
452
|
+
offset: now.text.caret,
|
|
453
|
+
summary: `caret: ${now.text.caret}`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const [s0, e0] = before.text.selection;
|
|
457
|
+
const [s1, e1] = now.text.selection;
|
|
458
|
+
if ((s0 !== s1 || e0 !== e1) && s1 !== e1) {
|
|
459
|
+
this._push({
|
|
460
|
+
type: 'selection',
|
|
461
|
+
node,
|
|
462
|
+
start: s1,
|
|
463
|
+
end: e1,
|
|
464
|
+
summary: `selection: ${s1}..${e1}`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// An element that draws its own children reports them through the one
|
|
470
|
+
// notification it makes about itself (#304), so they are diffed here
|
|
471
|
+
// rather than through a feed of their own — a scene item is a node to
|
|
472
|
+
// everything above.
|
|
473
|
+
for (const item of sceneChildrenOf(node)) this._diffNode(item);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Install a fresh spy on the a11y hook slots and return it. Prefer
|
|
479
|
+
* `renderX11(element, { a11y: true })`, which installs before the mount —
|
|
480
|
+
* so the initial tree is baselined — and uninstalls in `cleanup()`.
|
|
481
|
+
*/
|
|
482
|
+
export function installA11ySpy() {
|
|
483
|
+
return new A11ySpy().install();
|
|
484
|
+
}
|