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
package/src/atspi.js
ADDED
|
@@ -0,0 +1,1953 @@
|
|
|
1
|
+
// The AT-SPI2 bridge: the accessible tree, live on the accessibility bus.
|
|
2
|
+
//
|
|
3
|
+
// Linux accessibility does not go over the X protocol at all. An application
|
|
4
|
+
// exposes a tree of objects on a separate D-Bus instance — the *accessibility
|
|
5
|
+
// bus* — implementing the org.a11y.atspi.* interfaces (at-spi2-core's xml/ is
|
|
6
|
+
// the wire spec), and registers itself with the AT-SPI registry daemon.
|
|
7
|
+
// Assistive technology (Orca, Accerciser, magnifiers) walks that tree and
|
|
8
|
+
// listens for its signals. Toolkit-less applications are expected to drive
|
|
9
|
+
// this themselves rather than inherit it from GTK — which is exactly what
|
|
10
|
+
// this file is.
|
|
11
|
+
//
|
|
12
|
+
// ## No mirror
|
|
13
|
+
//
|
|
14
|
+
// There is no shadow tree to keep in sync. Every method call is answered
|
|
15
|
+
// from the live node tree through the model in a11y.js (`a11yChildren`,
|
|
16
|
+
// `a11yStates`, `node.abs`, …), so the answer cannot be stale. The bridge
|
|
17
|
+
// keeps only three things per exported node: its object path, which
|
|
18
|
+
// interfaces it was exported with, and the last name/states/text it *told*
|
|
19
|
+
// the bus about — the snapshot that turns "something changed" from the
|
|
20
|
+
// renderer's hooks into the precise AT-SPI event diff.
|
|
21
|
+
//
|
|
22
|
+
// ## The ladder (docs/accessibility.md)
|
|
23
|
+
//
|
|
24
|
+
// start() climbs: transport installed → session bus → org.a11y.Bus knows an
|
|
25
|
+
// address → connect → Embed with the registry. Every rung ends in a silent
|
|
26
|
+
// "off" on machines with no accessibility stack — ssh, CI, containers,
|
|
27
|
+
// macOS/Windows X servers — and the renderer's hook slots stay null, so the
|
|
28
|
+
// app pays one property read per event for a bridge that is not there.
|
|
29
|
+
// AT_SPI_BUS_ADDRESS overrides the discovery, which is also the test seam.
|
|
30
|
+
//
|
|
31
|
+
// Like the rest of the D-Bus layer, a dead accessibility bus is not
|
|
32
|
+
// resurrected: the slots are cleared and the app simply stops being
|
|
33
|
+
// accessible until restarted, the same contract bus.js documents.
|
|
34
|
+
|
|
35
|
+
import { addressFor, loadTransport } from './bus.js';
|
|
36
|
+
import {
|
|
37
|
+
hooks,
|
|
38
|
+
ATSPI_ROLE,
|
|
39
|
+
ATSPI_STATE,
|
|
40
|
+
ATSPI_STATE_NICK,
|
|
41
|
+
ATSPI_ROLE_NICK,
|
|
42
|
+
atspiRoleOf,
|
|
43
|
+
a11yErased,
|
|
44
|
+
a11yPruned,
|
|
45
|
+
a11yChildren,
|
|
46
|
+
a11yParent,
|
|
47
|
+
a11yIndexIn,
|
|
48
|
+
sceneChildrenOf,
|
|
49
|
+
knownSceneItems,
|
|
50
|
+
a11yName,
|
|
51
|
+
a11yDescription,
|
|
52
|
+
a11yStates,
|
|
53
|
+
a11yValue,
|
|
54
|
+
a11yAttributes,
|
|
55
|
+
a11yActivatable,
|
|
56
|
+
isTextControl,
|
|
57
|
+
isNativeTextControl,
|
|
58
|
+
acceptsTextEdits,
|
|
59
|
+
hasTextInterface,
|
|
60
|
+
textStateOf,
|
|
61
|
+
inPreedit,
|
|
62
|
+
diffChars,
|
|
63
|
+
} from './a11y.js';
|
|
64
|
+
import { onApp } from './trace-registry.js';
|
|
65
|
+
import { synthesizeClick } from './events.js';
|
|
66
|
+
import { callHandler } from './errors.js';
|
|
67
|
+
|
|
68
|
+
const ROOT_PATH = '/org/a11y/atspi/accessible/root';
|
|
69
|
+
const NODE_PATH = '/org/a11y/atspi/accessible/';
|
|
70
|
+
const NULL_PATH = '/org/a11y/atspi/null';
|
|
71
|
+
const CACHE_PATH = '/org/a11y/atspi/cache';
|
|
72
|
+
const REGISTRY_NAME = 'org.a11y.atspi.Registry';
|
|
73
|
+
|
|
74
|
+
const IFACE = {
|
|
75
|
+
ACCESSIBLE: 'org.a11y.atspi.Accessible',
|
|
76
|
+
APPLICATION: 'org.a11y.atspi.Application',
|
|
77
|
+
COMPONENT: 'org.a11y.atspi.Component',
|
|
78
|
+
ACTION: 'org.a11y.atspi.Action',
|
|
79
|
+
VALUE: 'org.a11y.atspi.Value',
|
|
80
|
+
TEXT: 'org.a11y.atspi.Text',
|
|
81
|
+
EDITABLE_TEXT: 'org.a11y.atspi.EditableText',
|
|
82
|
+
SOCKET: 'org.a11y.atspi.Socket',
|
|
83
|
+
CACHE: 'org.a11y.atspi.Cache',
|
|
84
|
+
EVENT_OBJECT: 'org.a11y.atspi.Event.Object',
|
|
85
|
+
EVENT_WINDOW: 'org.a11y.atspi.Event.Window',
|
|
86
|
+
EVENT_FOCUS: 'org.a11y.atspi.Event.Focus',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// AtspiCoordType / AtspiComponentLayer, from the same enums as a11y.js's
|
|
90
|
+
// tables.
|
|
91
|
+
const COORD_SCREEN = 0;
|
|
92
|
+
const COORD_WINDOW = 1;
|
|
93
|
+
const COORD_PARENT = 2;
|
|
94
|
+
const LAYER_WIDGET = 3;
|
|
95
|
+
const LAYER_POPUP = 5;
|
|
96
|
+
const LAYER_WINDOW = 7;
|
|
97
|
+
|
|
98
|
+
/** The version at-spi2-core reports for current releases; ATs use it only
|
|
99
|
+
* to gate features far newer than anything here. */
|
|
100
|
+
const ATSPI_VERSION = '2.1';
|
|
101
|
+
|
|
102
|
+
const EVENT_SIGNATURE = 'siiva{sv}';
|
|
103
|
+
const CACHE_ITEM_SIGNATURE = '((so)(so)(so)iiassusau)';
|
|
104
|
+
|
|
105
|
+
// --------------------------------------------------------------------------
|
|
106
|
+
// Interface descriptors (dbus-native shape). Signatures are copied from
|
|
107
|
+
// at-spi2-core's xml/, which is the authoritative wire spec.
|
|
108
|
+
// --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const readOnly = (type) => ({ type, access: 'read' });
|
|
111
|
+
|
|
112
|
+
const ACCESSIBLE_DESC = {
|
|
113
|
+
name: IFACE.ACCESSIBLE,
|
|
114
|
+
methods: {
|
|
115
|
+
GetChildAtIndex: ['i', '(so)'],
|
|
116
|
+
GetChildren: ['', 'a(so)'],
|
|
117
|
+
GetIndexInParent: ['', 'i'],
|
|
118
|
+
GetRelationSet: ['', 'a(ua(so))'],
|
|
119
|
+
GetRole: ['', 'u'],
|
|
120
|
+
GetRoleName: ['', 's'],
|
|
121
|
+
GetLocalizedRoleName: ['', 's'],
|
|
122
|
+
GetState: ['', 'au'],
|
|
123
|
+
GetAttributes: ['', 'a{ss}'],
|
|
124
|
+
GetApplication: ['', '(so)'],
|
|
125
|
+
GetInterfaces: ['', 'as'],
|
|
126
|
+
},
|
|
127
|
+
properties: {
|
|
128
|
+
Name: readOnly('s'),
|
|
129
|
+
Description: readOnly('s'),
|
|
130
|
+
Parent: readOnly('(so)'),
|
|
131
|
+
ChildCount: readOnly('i'),
|
|
132
|
+
Locale: readOnly('s'),
|
|
133
|
+
AccessibleId: readOnly('s'),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const APPLICATION_DESC = {
|
|
138
|
+
name: IFACE.APPLICATION,
|
|
139
|
+
methods: {
|
|
140
|
+
GetLocale: ['u', 's'],
|
|
141
|
+
GetApplicationBusAddress: ['', 's'],
|
|
142
|
+
},
|
|
143
|
+
properties: {
|
|
144
|
+
ToolkitName: readOnly('s'),
|
|
145
|
+
Version: readOnly('s'),
|
|
146
|
+
AtspiVersion: readOnly('s'),
|
|
147
|
+
Id: 'i',
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const SOCKET_DESC = {
|
|
152
|
+
name: IFACE.SOCKET,
|
|
153
|
+
methods: {
|
|
154
|
+
Embedded: ['s', ''],
|
|
155
|
+
Unembed: ['(so)', ''],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const COMPONENT_DESC = {
|
|
160
|
+
name: IFACE.COMPONENT,
|
|
161
|
+
methods: {
|
|
162
|
+
Contains: ['iiu', 'b'],
|
|
163
|
+
GetAccessibleAtPoint: ['iiu', '(so)'],
|
|
164
|
+
GetExtents: ['u', '(iiii)'],
|
|
165
|
+
GetPosition: ['u', 'ii'],
|
|
166
|
+
GetSize: ['', 'ii'],
|
|
167
|
+
GetLayer: ['', 'u'],
|
|
168
|
+
GrabFocus: ['', 'b'],
|
|
169
|
+
GetAlpha: ['', 'd'],
|
|
170
|
+
ScrollTo: ['u', 'b'],
|
|
171
|
+
ScrollToPoint: ['uii', 'b'],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const ACTION_DESC = {
|
|
176
|
+
name: IFACE.ACTION,
|
|
177
|
+
methods: {
|
|
178
|
+
GetDescription: ['i', 's'],
|
|
179
|
+
GetName: ['i', 's'],
|
|
180
|
+
GetLocalizedName: ['i', 's'],
|
|
181
|
+
GetKeyBinding: ['i', 's'],
|
|
182
|
+
GetActions: ['', 'a(sss)'],
|
|
183
|
+
DoAction: ['i', 'b'],
|
|
184
|
+
},
|
|
185
|
+
properties: { NActions: readOnly('i') },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const VALUE_DESC = {
|
|
189
|
+
name: IFACE.VALUE,
|
|
190
|
+
properties: {
|
|
191
|
+
MinimumValue: readOnly('d'),
|
|
192
|
+
MaximumValue: readOnly('d'),
|
|
193
|
+
MinimumIncrement: readOnly('d'),
|
|
194
|
+
CurrentValue: 'd',
|
|
195
|
+
Text: readOnly('s'),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const TEXT_DESC = {
|
|
200
|
+
name: IFACE.TEXT,
|
|
201
|
+
methods: {
|
|
202
|
+
GetText: ['ii', 's'],
|
|
203
|
+
GetStringAtOffset: ['iu', 'sii'],
|
|
204
|
+
GetTextAtOffset: ['iu', 'sii'],
|
|
205
|
+
GetTextBeforeOffset: ['iu', 'sii'],
|
|
206
|
+
GetTextAfterOffset: ['iu', 'sii'],
|
|
207
|
+
GetCharacterAtOffset: ['i', 'i'],
|
|
208
|
+
SetCaretOffset: ['i', 'b'],
|
|
209
|
+
GetAttributes: ['i', 'a{ss}ii'],
|
|
210
|
+
GetDefaultAttributes: ['', 'a{ss}'],
|
|
211
|
+
GetAttributeValue: ['is', 's'],
|
|
212
|
+
GetCharacterExtents: ['iu', 'iiii'],
|
|
213
|
+
GetRangeExtents: ['iiu', 'iiii'],
|
|
214
|
+
GetOffsetAtPoint: ['iiu', 'i'],
|
|
215
|
+
GetNSelections: ['', 'i'],
|
|
216
|
+
GetSelection: ['i', 'ii'],
|
|
217
|
+
AddSelection: ['ii', 'b'],
|
|
218
|
+
RemoveSelection: ['i', 'b'],
|
|
219
|
+
SetSelection: ['iii', 'b'],
|
|
220
|
+
},
|
|
221
|
+
properties: {
|
|
222
|
+
CharacterCount: readOnly('i'),
|
|
223
|
+
CaretOffset: readOnly('i'),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const EDITABLE_TEXT_DESC = {
|
|
228
|
+
name: IFACE.EDITABLE_TEXT,
|
|
229
|
+
methods: {
|
|
230
|
+
SetTextContents: ['s', 'b'],
|
|
231
|
+
InsertText: ['isi', 'b'],
|
|
232
|
+
CopyText: ['ii', ''],
|
|
233
|
+
CutText: ['ii', 'b'],
|
|
234
|
+
DeleteText: ['ii', 'b'],
|
|
235
|
+
PasteText: ['i', 'b'],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const CACHE_DESC = {
|
|
240
|
+
name: IFACE.CACHE,
|
|
241
|
+
methods: {
|
|
242
|
+
GetItems: ['', `a${CACHE_ITEM_SIGNATURE}`],
|
|
243
|
+
},
|
|
244
|
+
signals: {
|
|
245
|
+
AddAccessible: [CACHE_ITEM_SIGNATURE, 'nodeAdded'],
|
|
246
|
+
RemoveAccessible: ['(so)', 'nodeRemoved'],
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// --------------------------------------------------------------------------
|
|
251
|
+
// Helpers over the node tree. `textStateOf`/`diffChars` live in a11y.js —
|
|
252
|
+
// they answer "what would an AT read here", which the test spy asks too.
|
|
253
|
+
// --------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function clampOffset(offset, length) {
|
|
256
|
+
return Math.max(0, Math.min(offset | 0, length));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The attribute run around an offset, `[attributes, start, end)`.
|
|
261
|
+
*
|
|
262
|
+
* A composition is drawn underlined and `underline` is the only registered
|
|
263
|
+
* AT-SPI attribute that says so, so that is what the preedit run carries.
|
|
264
|
+
* `composition` beside it is this renderer's own: AT-SPI registers nothing
|
|
265
|
+
* for a preedit, and an attribute a reader does not know is one it ignores,
|
|
266
|
+
* which is a better failure than a run claiming to be nothing special.
|
|
267
|
+
* Everything outside the composition is one unattributed run, bounded by
|
|
268
|
+
* the composition's edges so an AT walking runs finds them.
|
|
269
|
+
*/
|
|
270
|
+
function attributeRun(state, offset) {
|
|
271
|
+
const total = state.chars.length;
|
|
272
|
+
const at = clampOffset(offset, total);
|
|
273
|
+
const pre = state.preedit;
|
|
274
|
+
if (!pre) return [[], 0, total];
|
|
275
|
+
const start = pre.offset;
|
|
276
|
+
const end = pre.offset + pre.length;
|
|
277
|
+
if (at >= start && at < end) {
|
|
278
|
+
return [
|
|
279
|
+
[
|
|
280
|
+
['underline', 'single'],
|
|
281
|
+
['composition', 'true'],
|
|
282
|
+
],
|
|
283
|
+
start,
|
|
284
|
+
end,
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
return at < start ? [[], 0, start] : [[], end, total];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* An offset an AT handed back, in the node's **value** space — what it was
|
|
292
|
+
* told is the displayed string, composition included. An offset inside the
|
|
293
|
+
* composition answers where the composition starts: a preedit is one thing,
|
|
294
|
+
* not a run of characters to put a caret between or edit around.
|
|
295
|
+
*/
|
|
296
|
+
function valueOffset(node, offset) {
|
|
297
|
+
const { chars } = textStateOf(node);
|
|
298
|
+
const at = clampOffset(offset, chars.length);
|
|
299
|
+
return node._valueIndex ? node._valueIndex(at) : at;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const isWordChar = (ch) => /\S/.test(ch);
|
|
303
|
+
|
|
304
|
+
/** [start, end) of the word containing (or after) `offset`. */
|
|
305
|
+
function wordRange(chars, offset) {
|
|
306
|
+
const n = chars.length;
|
|
307
|
+
let i = clampOffset(offset, n);
|
|
308
|
+
if (i >= n) i = n - 1;
|
|
309
|
+
if (i < 0) return [0, 0];
|
|
310
|
+
if (!isWordChar(chars[i])) {
|
|
311
|
+
// between words: the range is the whitespace run, as GTK reports it
|
|
312
|
+
let s = i;
|
|
313
|
+
while (s > 0 && !isWordChar(chars[s - 1])) s--;
|
|
314
|
+
let e = i;
|
|
315
|
+
while (e < n && !isWordChar(chars[e])) e++;
|
|
316
|
+
return [s, e];
|
|
317
|
+
}
|
|
318
|
+
let s = i;
|
|
319
|
+
while (s > 0 && isWordChar(chars[s - 1])) s--;
|
|
320
|
+
let e = i;
|
|
321
|
+
while (e < n && isWordChar(chars[e])) e++;
|
|
322
|
+
return [s, e];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Hard-line ranges [start, end) with the newline included in its line —
|
|
326
|
+
* what GtkTextView reports for LINE_START. Soft wraps are not lines here;
|
|
327
|
+
* see docs/accessibility.md. */
|
|
328
|
+
function lineRanges(chars) {
|
|
329
|
+
const ranges = [];
|
|
330
|
+
let start = 0;
|
|
331
|
+
for (let i = 0; i < chars.length; i++) {
|
|
332
|
+
if (chars[i] === '\n') {
|
|
333
|
+
ranges.push([start, i + 1]);
|
|
334
|
+
start = i + 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
ranges.push([start, chars.length]);
|
|
338
|
+
return ranges;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function lineRangeAt(chars, offset) {
|
|
342
|
+
const ranges = lineRanges(chars);
|
|
343
|
+
for (const range of ranges) {
|
|
344
|
+
if (offset < range[1]) return range;
|
|
345
|
+
}
|
|
346
|
+
return ranges[ranges.length - 1];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A granular range around `offset`. `granularity` follows AtspiTextGranularity
|
|
351
|
+
* (0 char, 1 word, 2 sentence, 3 line, 4 paragraph); the legacy
|
|
352
|
+
* AtspiTextBoundaryType values GetTextAtOffset carries are translated by the
|
|
353
|
+
* caller. Sentences and paragraphs answer as lines — hard lines are the only
|
|
354
|
+
* paragraph structure a `<textarea>` has.
|
|
355
|
+
*/
|
|
356
|
+
function granularRange(chars, offset, granularity) {
|
|
357
|
+
const n = chars.length;
|
|
358
|
+
const at = clampOffset(offset, n);
|
|
359
|
+
switch (granularity) {
|
|
360
|
+
case 0:
|
|
361
|
+
return at >= n ? [n, n] : [at, at + 1];
|
|
362
|
+
case 1:
|
|
363
|
+
return wordRange(chars, at);
|
|
364
|
+
default:
|
|
365
|
+
return lineRangeAt(chars, at);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Legacy AtspiTextBoundaryType → granularity (start/end variants collapse:
|
|
370
|
+
* the range is the same, only legacy iteration order differed). */
|
|
371
|
+
function boundaryToGranularity(type) {
|
|
372
|
+
if (type === 0) return 0; // CHAR
|
|
373
|
+
if (type === 1 || type === 2) return 1; // WORD_START / WORD_END
|
|
374
|
+
return 3; // SENTENCE_* and LINE_* answer as lines
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The best guess at what the desktop calls this program. */
|
|
378
|
+
function programName() {
|
|
379
|
+
const scriptPath = process.argv?.[1];
|
|
380
|
+
if (typeof scriptPath === 'string' && scriptPath !== '') {
|
|
381
|
+
const base = scriptPath.split(/[\\/]/).pop();
|
|
382
|
+
if (base) return base.replace(/\.[cm]?jsx?$/, '');
|
|
383
|
+
}
|
|
384
|
+
return process.title || 'node';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function localeString() {
|
|
388
|
+
return (
|
|
389
|
+
process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || 'C'
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --------------------------------------------------------------------------
|
|
394
|
+
// Per-interface implementation prototypes. One tiny `{ b, n }` object per
|
|
395
|
+
// (node, interface) is created at export time; everything else lives here.
|
|
396
|
+
// dbus-native reads properties straight off the object (prototype getters
|
|
397
|
+
// included) and calls methods with the message appended, which none of
|
|
398
|
+
// these need — the node is bound at export.
|
|
399
|
+
// --------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
const AccessibleImpl = {
|
|
402
|
+
get Name() {
|
|
403
|
+
return a11yName(this.n);
|
|
404
|
+
},
|
|
405
|
+
get Description() {
|
|
406
|
+
return a11yDescription(this.n);
|
|
407
|
+
},
|
|
408
|
+
get Parent() {
|
|
409
|
+
return this.b.parentRef(this.n);
|
|
410
|
+
},
|
|
411
|
+
get ChildCount() {
|
|
412
|
+
return a11yChildren(this.n).length;
|
|
413
|
+
},
|
|
414
|
+
get Locale() {
|
|
415
|
+
return localeString();
|
|
416
|
+
},
|
|
417
|
+
get AccessibleId() {
|
|
418
|
+
return '';
|
|
419
|
+
},
|
|
420
|
+
GetChildAtIndex(index) {
|
|
421
|
+
const child = a11yChildren(this.n)[index];
|
|
422
|
+
return child ? this.b.refFor(child) : this.b.nullRef();
|
|
423
|
+
},
|
|
424
|
+
GetChildren() {
|
|
425
|
+
return a11yChildren(this.n).map((child) => this.b.refFor(child));
|
|
426
|
+
},
|
|
427
|
+
GetIndexInParent() {
|
|
428
|
+
const parent = a11yParent(this.n);
|
|
429
|
+
if (!parent) return this.b.toplevels.indexOf(this.n);
|
|
430
|
+
return a11yIndexIn(parent, this.n);
|
|
431
|
+
},
|
|
432
|
+
GetRelationSet() {
|
|
433
|
+
return [];
|
|
434
|
+
},
|
|
435
|
+
GetRole() {
|
|
436
|
+
return atspiRoleOf(this.n);
|
|
437
|
+
},
|
|
438
|
+
// The AT-SPI role name, not the ARIA one the app wrote — see
|
|
439
|
+
// ATSPI_ROLE_NICK. The ARIA role is reported through `xml-roles` in
|
|
440
|
+
// GetAttributes, as the browsers do.
|
|
441
|
+
GetRoleName() {
|
|
442
|
+
return ATSPI_ROLE_NICK[atspiRoleOf(this.n)] ?? 'unknown';
|
|
443
|
+
},
|
|
444
|
+
GetLocalizedRoleName() {
|
|
445
|
+
// No localisation here: answering the untranslated name is what a
|
|
446
|
+
// toolkit with no message catalogue should do, and libatspi falls back
|
|
447
|
+
// to its own translations anyway.
|
|
448
|
+
return ATSPI_ROLE_NICK[atspiRoleOf(this.n)] ?? 'unknown';
|
|
449
|
+
},
|
|
450
|
+
GetState() {
|
|
451
|
+
return a11yStates(this.n);
|
|
452
|
+
},
|
|
453
|
+
GetAttributes() {
|
|
454
|
+
return a11yAttributes(this.n);
|
|
455
|
+
},
|
|
456
|
+
GetApplication() {
|
|
457
|
+
return this.b.rootRef();
|
|
458
|
+
},
|
|
459
|
+
GetInterfaces() {
|
|
460
|
+
return this.b.interfacesOf(this.n);
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/** The innermost scene item covering a window-coordinate point, or the node
|
|
465
|
+
* itself. Last one wins: items are declared in the order they were drawn,
|
|
466
|
+
* so the one on top is the one the user sees. */
|
|
467
|
+
function deepestItemAt(node, x, y) {
|
|
468
|
+
let found = node;
|
|
469
|
+
for (const item of sceneChildrenOf(node)) {
|
|
470
|
+
if (item.destroyed || a11yPruned(item)) continue;
|
|
471
|
+
const r = item.abs;
|
|
472
|
+
if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
found = deepestItemAt(item, x, y);
|
|
476
|
+
}
|
|
477
|
+
return found;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const ComponentImpl = {
|
|
481
|
+
Contains(x, y) {
|
|
482
|
+
const r = this.b.extentsOf(this.n, COORD_SCREEN);
|
|
483
|
+
return x >= r[0] && y >= r[1] && x < r[0] + r[2] && y < r[1] + r[3];
|
|
484
|
+
},
|
|
485
|
+
GetAccessibleAtPoint(x, y, coordType) {
|
|
486
|
+
const node = this.n;
|
|
487
|
+
const win = node.isWindow ? node : node.root;
|
|
488
|
+
if (!win) return this.b.nullRef();
|
|
489
|
+
let wx = x;
|
|
490
|
+
let wy = y;
|
|
491
|
+
if (coordType !== COORD_WINDOW) {
|
|
492
|
+
const origin = this.b.screenOriginOf(win);
|
|
493
|
+
wx -= origin.x;
|
|
494
|
+
wy -= origin.y;
|
|
495
|
+
}
|
|
496
|
+
let hit = win.hitTest?.(wx, wy) ?? null;
|
|
497
|
+
while (hit && (a11yErased(hit) || a11yPruned(hit))) hit = hit.parent;
|
|
498
|
+
// Hit testing stops at the retained tree, so an element that draws a
|
|
499
|
+
// scene answers with itself; the item under the point is what a
|
|
500
|
+
// magnifier following the pointer is actually over.
|
|
501
|
+
if (hit) hit = deepestItemAt(hit, wx, wy);
|
|
502
|
+
return hit ? this.b.refFor(hit) : this.b.nullRef();
|
|
503
|
+
},
|
|
504
|
+
GetExtents(coordType) {
|
|
505
|
+
return this.b.extentsOf(this.n, coordType);
|
|
506
|
+
},
|
|
507
|
+
GetPosition(coordType) {
|
|
508
|
+
const r = this.b.extentsOf(this.n, coordType);
|
|
509
|
+
return [r[0], r[1]];
|
|
510
|
+
},
|
|
511
|
+
GetSize() {
|
|
512
|
+
const r = this.b.extentsOf(this.n, COORD_WINDOW);
|
|
513
|
+
return [r[2], r[3]];
|
|
514
|
+
},
|
|
515
|
+
GetLayer() {
|
|
516
|
+
if (this.n.isPopup) return LAYER_POPUP;
|
|
517
|
+
if (this.n.isWindow) return LAYER_WINDOW;
|
|
518
|
+
return LAYER_WIDGET;
|
|
519
|
+
},
|
|
520
|
+
GrabFocus() {
|
|
521
|
+
return this.b.grabFocus(this.n);
|
|
522
|
+
},
|
|
523
|
+
GetAlpha() {
|
|
524
|
+
return 1.0;
|
|
525
|
+
},
|
|
526
|
+
ScrollTo() {
|
|
527
|
+
if (this.b.sceneAction(this.n, 'scroll')) return true;
|
|
528
|
+
// A scene item has no layout of its own, so the most core can reveal is
|
|
529
|
+
// the element that drew it — an element that can do better says so
|
|
530
|
+
// through the action above.
|
|
531
|
+
const target = this.n.a11yOwner ?? this.n;
|
|
532
|
+
// `isScroller()`, not "has the method": every `<box>` carries
|
|
533
|
+
// `scrollIntoView` now, and one that is not a scroll container would end
|
|
534
|
+
// the walk having revealed nothing
|
|
535
|
+
for (let p = target.parent; p; p = p.parent) {
|
|
536
|
+
if (p.isScroller?.()) {
|
|
537
|
+
p.scrollIntoView(target);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
},
|
|
543
|
+
ScrollToPoint() {
|
|
544
|
+
return false;
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const ACTIVATE = 'activate';
|
|
549
|
+
|
|
550
|
+
const ActionImpl = {
|
|
551
|
+
get NActions() {
|
|
552
|
+
return 1;
|
|
553
|
+
},
|
|
554
|
+
GetName(index) {
|
|
555
|
+
return index === 0 ? ACTIVATE : '';
|
|
556
|
+
},
|
|
557
|
+
GetLocalizedName(index) {
|
|
558
|
+
return index === 0 ? ACTIVATE : '';
|
|
559
|
+
},
|
|
560
|
+
GetDescription() {
|
|
561
|
+
return '';
|
|
562
|
+
},
|
|
563
|
+
GetKeyBinding() {
|
|
564
|
+
return '';
|
|
565
|
+
},
|
|
566
|
+
GetActions() {
|
|
567
|
+
return [[ACTIVATE, '', '']];
|
|
568
|
+
},
|
|
569
|
+
DoAction(index) {
|
|
570
|
+
if (index !== 0) return false;
|
|
571
|
+
return this.b.activate(this.n);
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const ValueImpl = {
|
|
576
|
+
get MinimumValue() {
|
|
577
|
+
return a11yValue(this.n)?.min ?? 0;
|
|
578
|
+
},
|
|
579
|
+
get MaximumValue() {
|
|
580
|
+
return a11yValue(this.n)?.max ?? 0;
|
|
581
|
+
},
|
|
582
|
+
get MinimumIncrement() {
|
|
583
|
+
return 0;
|
|
584
|
+
},
|
|
585
|
+
get CurrentValue() {
|
|
586
|
+
return a11yValue(this.n)?.now ?? 0;
|
|
587
|
+
},
|
|
588
|
+
/** An AT writing the value — Orca adjusting a slider. Routed to the
|
|
589
|
+
* component through `onAccessibilityAction`, the seam a widget wires its
|
|
590
|
+
* own state setter into. */
|
|
591
|
+
set CurrentValue(next) {
|
|
592
|
+
const node = this.n;
|
|
593
|
+
const handler = node.props?.onAccessibilityAction;
|
|
594
|
+
if (typeof handler !== 'function') return;
|
|
595
|
+
callHandler(node, 'onAccessibilityAction', handler, {
|
|
596
|
+
action: 'setValue',
|
|
597
|
+
value: Number(next),
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
get Text() {
|
|
601
|
+
return a11yValue(this.n)?.text ?? '';
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const TextImpl = {
|
|
606
|
+
get CharacterCount() {
|
|
607
|
+
return textStateOf(this.n).chars.length;
|
|
608
|
+
},
|
|
609
|
+
get CaretOffset() {
|
|
610
|
+
return textStateOf(this.n).caret;
|
|
611
|
+
},
|
|
612
|
+
GetText(start, end) {
|
|
613
|
+
const { chars } = textStateOf(this.n);
|
|
614
|
+
const s = clampOffset(start, chars.length);
|
|
615
|
+
const e = end < 0 ? chars.length : clampOffset(end, chars.length);
|
|
616
|
+
return chars.slice(s, e).join('');
|
|
617
|
+
},
|
|
618
|
+
GetStringAtOffset(offset, granularity) {
|
|
619
|
+
const { chars } = textStateOf(this.n);
|
|
620
|
+
const [s, e] = granularRange(chars, offset, granularity);
|
|
621
|
+
return [chars.slice(s, e).join(''), s, e];
|
|
622
|
+
},
|
|
623
|
+
GetTextAtOffset(offset, type) {
|
|
624
|
+
return TextImpl.GetStringAtOffset.call(
|
|
625
|
+
this,
|
|
626
|
+
offset,
|
|
627
|
+
boundaryToGranularity(type),
|
|
628
|
+
);
|
|
629
|
+
},
|
|
630
|
+
GetTextBeforeOffset(offset, type) {
|
|
631
|
+
const { chars } = textStateOf(this.n);
|
|
632
|
+
const granularity = boundaryToGranularity(type);
|
|
633
|
+
const [s] = granularRange(chars, offset, granularity);
|
|
634
|
+
if (s <= 0) return ['', 0, 0];
|
|
635
|
+
const [ps, pe] = granularRange(chars, s - 1, granularity);
|
|
636
|
+
return [chars.slice(ps, pe).join(''), ps, pe];
|
|
637
|
+
},
|
|
638
|
+
GetTextAfterOffset(offset, type) {
|
|
639
|
+
const { chars } = textStateOf(this.n);
|
|
640
|
+
const granularity = boundaryToGranularity(type);
|
|
641
|
+
const [, e] = granularRange(chars, offset, granularity);
|
|
642
|
+
if (e >= chars.length) return ['', chars.length, chars.length];
|
|
643
|
+
const [ns, ne] = granularRange(chars, e, granularity);
|
|
644
|
+
return [chars.slice(ns, ne).join(''), ns, ne];
|
|
645
|
+
},
|
|
646
|
+
GetCharacterAtOffset(offset) {
|
|
647
|
+
const { chars } = textStateOf(this.n);
|
|
648
|
+
if (offset < 0 || offset >= chars.length) return 0;
|
|
649
|
+
return chars[offset].codePointAt(0);
|
|
650
|
+
},
|
|
651
|
+
SetCaretOffset(offset) {
|
|
652
|
+
return this.b.setCaret(this.n, offset);
|
|
653
|
+
},
|
|
654
|
+
GetAttributes(offset) {
|
|
655
|
+
return attributeRun(textStateOf(this.n), offset);
|
|
656
|
+
},
|
|
657
|
+
GetDefaultAttributes() {
|
|
658
|
+
return [];
|
|
659
|
+
},
|
|
660
|
+
GetAttributeValue(offset, name) {
|
|
661
|
+
const [attrs] = attributeRun(textStateOf(this.n), offset);
|
|
662
|
+
for (const [key, value] of attrs) {
|
|
663
|
+
if (key === name) return value;
|
|
664
|
+
}
|
|
665
|
+
return '';
|
|
666
|
+
},
|
|
667
|
+
GetCharacterExtents(offset, coordType) {
|
|
668
|
+
return this.b.characterExtents(this.n, offset, coordType);
|
|
669
|
+
},
|
|
670
|
+
GetRangeExtents(start, end, coordType) {
|
|
671
|
+
const a = this.b.characterExtents(this.n, start, coordType);
|
|
672
|
+
const z = this.b.characterExtents(
|
|
673
|
+
this.n,
|
|
674
|
+
Math.max(start, end - 1),
|
|
675
|
+
coordType,
|
|
676
|
+
);
|
|
677
|
+
const x = Math.min(a[0], z[0]);
|
|
678
|
+
const y = Math.min(a[1], z[1]);
|
|
679
|
+
return [
|
|
680
|
+
x,
|
|
681
|
+
y,
|
|
682
|
+
Math.max(a[0] + a[2], z[0] + z[2]) - x,
|
|
683
|
+
Math.max(a[1] + a[3], z[1] + z[3]) - y,
|
|
684
|
+
];
|
|
685
|
+
},
|
|
686
|
+
GetOffsetAtPoint(x, y, coordType) {
|
|
687
|
+
return this.b.offsetAtPoint(this.n, x, y, coordType);
|
|
688
|
+
},
|
|
689
|
+
GetNSelections() {
|
|
690
|
+
const [s, e] = textStateOf(this.n).selection;
|
|
691
|
+
return s !== e ? 1 : 0;
|
|
692
|
+
},
|
|
693
|
+
GetSelection(index) {
|
|
694
|
+
if (index !== 0) return [0, 0];
|
|
695
|
+
return textStateOf(this.n).selection;
|
|
696
|
+
},
|
|
697
|
+
AddSelection(start, end) {
|
|
698
|
+
return this.b.setTextSelection(this.n, start, end);
|
|
699
|
+
},
|
|
700
|
+
SetSelection(index, start, end) {
|
|
701
|
+
if (index !== 0) return false;
|
|
702
|
+
return this.b.setTextSelection(this.n, start, end);
|
|
703
|
+
},
|
|
704
|
+
RemoveSelection(index) {
|
|
705
|
+
if (index !== 0) return false;
|
|
706
|
+
return this.b.setCaret(this.n, textStateOf(this.n).caret);
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Every write is one replacement — `editText(node, start, end, text)` —
|
|
712
|
+
* because that is the one primitive both sides can implement: the
|
|
713
|
+
* built-ins have `_commit`, and a registered element implements
|
|
714
|
+
* `a11yReplaceText`, which it needs anyway for its own editing.
|
|
715
|
+
*/
|
|
716
|
+
const EditableTextImpl = {
|
|
717
|
+
SetTextContents(newContents) {
|
|
718
|
+
const { chars } = textStateOf(this.n);
|
|
719
|
+
return this.b.editText(this.n, 0, chars.length, newContents);
|
|
720
|
+
},
|
|
721
|
+
InsertText(position, text, length) {
|
|
722
|
+
const insert = Array.from(String(text))
|
|
723
|
+
.slice(0, length < 0 ? undefined : length)
|
|
724
|
+
.join('');
|
|
725
|
+
return this.b.editText(this.n, position, position, insert);
|
|
726
|
+
},
|
|
727
|
+
DeleteText(start, end) {
|
|
728
|
+
return this.b.editText(this.n, start, end, '');
|
|
729
|
+
},
|
|
730
|
+
CopyText(start, end) {
|
|
731
|
+
this.b.copyTextRange(this.n, start, end);
|
|
732
|
+
return null;
|
|
733
|
+
},
|
|
734
|
+
CutText(start, end) {
|
|
735
|
+
this.b.copyTextRange(this.n, start, end);
|
|
736
|
+
return EditableTextImpl.DeleteText.call(this, start, end);
|
|
737
|
+
},
|
|
738
|
+
PasteText(position) {
|
|
739
|
+
const node = this.n;
|
|
740
|
+
if (!this.b.editable(node)) return false;
|
|
741
|
+
// the clipboard round trip is the built-ins' own: an element with a
|
|
742
|
+
// seam of its own is handed the text, not asked to fetch it
|
|
743
|
+
if (typeof node._pasteFrom !== 'function') return false;
|
|
744
|
+
node._moveCaret?.(valueOffset(node, position), false);
|
|
745
|
+
node._pasteFrom('CLIPBOARD');
|
|
746
|
+
return true;
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// --------------------------------------------------------------------------
|
|
751
|
+
// The bridge
|
|
752
|
+
// --------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
class AtspiBridge {
|
|
755
|
+
constructor(bus, toolkitVersion) {
|
|
756
|
+
this.bus = bus;
|
|
757
|
+
this.toolkitVersion = toolkitVersion;
|
|
758
|
+
this.dead = false;
|
|
759
|
+
/** @type {Map<object, {id: number, ifaces: string[], snapshot: object}>} */
|
|
760
|
+
this.exported = new Map();
|
|
761
|
+
this.nextId = 1;
|
|
762
|
+
/** Toplevel `<window>` nodes across every root, in mount order — the
|
|
763
|
+
* application's accessible children. */
|
|
764
|
+
this.toplevels = [];
|
|
765
|
+
this.desktop = [REGISTRY_NAME, ROOT_PATH];
|
|
766
|
+
this.appId = -1;
|
|
767
|
+
this._unsubscribes = [];
|
|
768
|
+
// batched renderer notifications, flushed per commit / microtask
|
|
769
|
+
this._pending = {
|
|
770
|
+
attach: new Set(),
|
|
771
|
+
remove: [],
|
|
772
|
+
props: new Set(),
|
|
773
|
+
text: new Set(),
|
|
774
|
+
focus: [],
|
|
775
|
+
window: [],
|
|
776
|
+
};
|
|
777
|
+
this._flushScheduled = false;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---- refs and export -------------------------------------------------
|
|
781
|
+
|
|
782
|
+
nullRef() {
|
|
783
|
+
return [this.bus.name, NULL_PATH];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
rootRef() {
|
|
787
|
+
return [this.bus.name, ROOT_PATH];
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pathOf(node) {
|
|
791
|
+
const entry = this.exported.get(node);
|
|
792
|
+
return entry ? NODE_PATH + entry.id : NULL_PATH;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The ref for a node, exporting it on the way if the bus has never seen
|
|
796
|
+
* it. Everything that hands a node to the bus goes through here, so an AT
|
|
797
|
+
* can call back on any ref it was ever given. */
|
|
798
|
+
refFor(node) {
|
|
799
|
+
this.ensureExported(node);
|
|
800
|
+
return [this.bus.name, this.pathOf(node)];
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
parentRef(node) {
|
|
804
|
+
const parent = a11yParent(node);
|
|
805
|
+
return parent ? this.refFor(parent) : this.rootRef();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
interfacesOf(node) {
|
|
809
|
+
return this.exported.get(node)?.ifaces ?? this._ifaceSetFor(node);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
_ifaceSetFor(node) {
|
|
813
|
+
const ifaces = [IFACE.ACCESSIBLE, IFACE.COMPONENT];
|
|
814
|
+
if (a11yActivatable(node)) ifaces.push(IFACE.ACTION);
|
|
815
|
+
if (a11yValue(node) !== null) ifaces.push(IFACE.VALUE);
|
|
816
|
+
if (hasTextInterface(node)) ifaces.push(IFACE.TEXT);
|
|
817
|
+
// EditableText only where an edit would actually land: a registered
|
|
818
|
+
// element that reports editable text but implements no write seam is
|
|
819
|
+
// readable and reports its own edits, and an interface whose every
|
|
820
|
+
// method answered false would be a lie an AT cannot see through
|
|
821
|
+
if (isTextControl(node) && acceptsTextEdits(node)) {
|
|
822
|
+
ifaces.push(IFACE.EDITABLE_TEXT);
|
|
823
|
+
}
|
|
824
|
+
return ifaces;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
_implFor(proto, node) {
|
|
828
|
+
return { __proto__: proto, b: this, n: node };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
_descriptorFor(name) {
|
|
832
|
+
switch (name) {
|
|
833
|
+
case IFACE.ACCESSIBLE:
|
|
834
|
+
return [ACCESSIBLE_DESC, AccessibleImpl];
|
|
835
|
+
case IFACE.COMPONENT:
|
|
836
|
+
return [COMPONENT_DESC, ComponentImpl];
|
|
837
|
+
case IFACE.ACTION:
|
|
838
|
+
return [ACTION_DESC, ActionImpl];
|
|
839
|
+
case IFACE.VALUE:
|
|
840
|
+
return [VALUE_DESC, ValueImpl];
|
|
841
|
+
case IFACE.TEXT:
|
|
842
|
+
return [TEXT_DESC, TextImpl];
|
|
843
|
+
case IFACE.EDITABLE_TEXT:
|
|
844
|
+
return [EDITABLE_TEXT_DESC, EditableTextImpl];
|
|
845
|
+
default:
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
ensureExported(node) {
|
|
851
|
+
if (this.dead || this.exported.has(node)) return;
|
|
852
|
+
const id = this.nextId++;
|
|
853
|
+
const ifaces = this._ifaceSetFor(node);
|
|
854
|
+
const path = NODE_PATH + id;
|
|
855
|
+
const entry = { id, ifaces, snapshot: null };
|
|
856
|
+
// registered before the interfaces export, so a getter that recurses
|
|
857
|
+
// through refFor (Parent, GetChildren) sees the entry and terminates
|
|
858
|
+
this.exported.set(node, entry);
|
|
859
|
+
for (const name of ifaces) {
|
|
860
|
+
const [desc, proto] = this._descriptorFor(name);
|
|
861
|
+
this.bus.exportInterface(this._implFor(proto, node), path, desc);
|
|
862
|
+
}
|
|
863
|
+
entry.snapshot = this._snapshot(node);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
_unexport(node) {
|
|
867
|
+
const entry = this.exported.get(node);
|
|
868
|
+
if (!entry) return;
|
|
869
|
+
this.exported.delete(node);
|
|
870
|
+
const path = NODE_PATH + entry.id;
|
|
871
|
+
this.emitCacheRemove([this.bus.name, path]);
|
|
872
|
+
this.bus.unexportInterface(path);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
_snapshot(node) {
|
|
876
|
+
const snapshot = {
|
|
877
|
+
name: a11yName(node),
|
|
878
|
+
description: a11yDescription(node),
|
|
879
|
+
states: a11yStates(node),
|
|
880
|
+
value: a11yValue(node)?.now ?? null,
|
|
881
|
+
ifaces: this.exported.get(node)?.ifaces ?? [],
|
|
882
|
+
text: null,
|
|
883
|
+
// the drawn children, which arrive and leave without a mount to
|
|
884
|
+
// notice them; the shared empty list for everything that draws none
|
|
885
|
+
scene: sceneChildrenOf(node),
|
|
886
|
+
};
|
|
887
|
+
if (hasTextInterface(node)) {
|
|
888
|
+
const { chars, caret, selection, preedit } = textStateOf(node);
|
|
889
|
+
snapshot.text = { chars, caret, selection, preedit };
|
|
890
|
+
}
|
|
891
|
+
return snapshot;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ---- geometry --------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
screenOriginOf(win) {
|
|
897
|
+
const wnd = win?.window;
|
|
898
|
+
if (!wnd) return { x: 0, y: 0 };
|
|
899
|
+
return wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** [x, y, width, height] in the asked-for coordinate system. A window is
|
|
903
|
+
* its whole surface; a drawn node is its layout rect. */
|
|
904
|
+
extentsOf(node, coordType) {
|
|
905
|
+
let rect;
|
|
906
|
+
const win = node.isWindow ? node : node.root;
|
|
907
|
+
if (node.isWindow) {
|
|
908
|
+
const wnd = node.window;
|
|
909
|
+
rect = { x: 0, y: 0, width: wnd?.width ?? 0, height: wnd?.height ?? 0 };
|
|
910
|
+
} else {
|
|
911
|
+
rect = node.abs ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
912
|
+
}
|
|
913
|
+
let { x, y } = rect;
|
|
914
|
+
if (coordType === COORD_SCREEN) {
|
|
915
|
+
const origin = this.screenOriginOf(win);
|
|
916
|
+
x += origin.x;
|
|
917
|
+
y += origin.y;
|
|
918
|
+
} else if (coordType === COORD_PARENT && !node.isWindow) {
|
|
919
|
+
const parent = a11yParent(node);
|
|
920
|
+
if (parent && !parent.isWindow && parent.abs) {
|
|
921
|
+
x -= parent.abs.x;
|
|
922
|
+
y -= parent.abs.y;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return [x, y, rect.width, rect.height];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Where the text control paints its characters. An element that answers
|
|
929
|
+
* `_placedValue` — every field in core does — has already resolved this,
|
|
930
|
+
* scroll and reading direction included, and it is the same answer the
|
|
931
|
+
* caret and the highlight are drawn from; the box minus its scroll is the
|
|
932
|
+
* fallback for one that only answers the geometry accessors. */
|
|
933
|
+
_textOrigin(node) {
|
|
934
|
+
const placed = node._placedValue?.();
|
|
935
|
+
if (placed) return { x: placed.x, y: placed.y };
|
|
936
|
+
const content = node.contentBox?.() ?? node.abs;
|
|
937
|
+
return {
|
|
938
|
+
x: content.x - (node._scrollX ?? 0),
|
|
939
|
+
y: content.y - (node._scrollY ?? 0),
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
characterExtents(node, offset, coordType) {
|
|
944
|
+
const layout = node._valueLayout?.();
|
|
945
|
+
if (!layout || typeof layout.caretPosition !== 'function') {
|
|
946
|
+
return this.extentsOf(node, coordType);
|
|
947
|
+
}
|
|
948
|
+
const { chars } = textStateOf(node);
|
|
949
|
+
const at = clampOffset(offset, chars.length);
|
|
950
|
+
const pos = layout.caretPosition(at);
|
|
951
|
+
const next = layout.caretPosition(Math.min(at + 1, chars.length));
|
|
952
|
+
const origin = this._textOrigin(node);
|
|
953
|
+
let x = origin.x + pos.x;
|
|
954
|
+
let y = origin.y + pos.y;
|
|
955
|
+
const width = next.line === pos.line && next.x > pos.x ? next.x - pos.x : 0;
|
|
956
|
+
if (coordType === COORD_SCREEN) {
|
|
957
|
+
const screen = this.screenOriginOf(node.isWindow ? node : node.root);
|
|
958
|
+
x += screen.x;
|
|
959
|
+
y += screen.y;
|
|
960
|
+
}
|
|
961
|
+
return [
|
|
962
|
+
Math.round(x),
|
|
963
|
+
Math.round(y),
|
|
964
|
+
Math.round(width),
|
|
965
|
+
Math.round(pos.height),
|
|
966
|
+
];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
offsetAtPoint(node, x, y, coordType) {
|
|
970
|
+
const layout = node._valueLayout?.();
|
|
971
|
+
if (!layout || typeof layout.indexAt !== 'function') return -1;
|
|
972
|
+
let wx = x;
|
|
973
|
+
let wy = y;
|
|
974
|
+
if (coordType === COORD_SCREEN) {
|
|
975
|
+
const screen = this.screenOriginOf(node.isWindow ? node : node.root);
|
|
976
|
+
wx -= screen.x;
|
|
977
|
+
wy -= screen.y;
|
|
978
|
+
}
|
|
979
|
+
const origin = this._textOrigin(node);
|
|
980
|
+
return layout.indexAt(wx - origin.x, wy - origin.y);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ---- AT-initiated behaviour -----------------------------------------
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Offer an action on a scene item to the element that drew it. `true`
|
|
987
|
+
* means the element handled it; anything else — including not
|
|
988
|
+
* implementing the seam — falls through to core's default, which is why
|
|
989
|
+
* an element may answer only the actions it has an answer for.
|
|
990
|
+
*/
|
|
991
|
+
sceneAction(node, action) {
|
|
992
|
+
const owner = node.a11yOwner;
|
|
993
|
+
if (!owner || owner.destroyed) return false;
|
|
994
|
+
return owner.a11ySceneAction?.(node.a11yId, action) === true;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** GrabFocus. An element owns the keyboard among the items it drew — the
|
|
998
|
+
* window's focus manager only ever holds the element — so the item asks
|
|
999
|
+
* it first and otherwise focuses the element itself, which is where the
|
|
1000
|
+
* keys that move between items go anyway. */
|
|
1001
|
+
grabFocus(node) {
|
|
1002
|
+
if (this.sceneAction(node, 'focus')) return true;
|
|
1003
|
+
const target = node.a11yOwner ?? node;
|
|
1004
|
+
if (typeof target.focus !== 'function') return false;
|
|
1005
|
+
target.focus();
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** DoAction("activate"): a synthetic click through the same dispatch a
|
|
1010
|
+
* real press uses — capture, bubble, default actions, discrete-priority
|
|
1011
|
+
* commit and paint — so an AT's activation is indistinguishable from the
|
|
1012
|
+
* user's, and from the keyboard's, which reaches `synthesizeClick` too
|
|
1013
|
+
* (events.js). On a scene item it is the click a mouse user would make on
|
|
1014
|
+
* what was drawn there: the element hit-tests its own scene already, so
|
|
1015
|
+
* the item's rect is the whole address, and an element whose activation
|
|
1016
|
+
* is not a click says so through `a11ySceneAction`. */
|
|
1017
|
+
activate(node) {
|
|
1018
|
+
if (node.a11yOwner) {
|
|
1019
|
+
if (this.sceneAction(node, 'activate')) return true;
|
|
1020
|
+
if (node.destroyed) return false;
|
|
1021
|
+
return synthesizeClick(node.a11yOwner, node.abs);
|
|
1022
|
+
}
|
|
1023
|
+
return synthesizeClick(node, node.abs);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
editable(node) {
|
|
1027
|
+
if (!isTextControl(node) || node.destroyed) return false;
|
|
1028
|
+
if (node.props?.disabled || node.props?.['aria-readonly'] === true) {
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1031
|
+
return acceptsTextEdits(node);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Replace `[start, end)` — in the displayed string, which is what the AT
|
|
1036
|
+
* was told — with `text`. Insert, delete and set-the-lot are all this
|
|
1037
|
+
* with one end moved, and a registered element answers it whole through
|
|
1038
|
+
* `a11yReplaceText` rather than through five methods it would have to
|
|
1039
|
+
* keep consistent with each other.
|
|
1040
|
+
*/
|
|
1041
|
+
editText(node, start, end, text) {
|
|
1042
|
+
if (!this.editable(node)) return false;
|
|
1043
|
+
const { chars: shown } = textStateOf(node);
|
|
1044
|
+
const from = clampOffset(Math.min(start, end), shown.length);
|
|
1045
|
+
const to = clampOffset(Math.max(start, end), shown.length);
|
|
1046
|
+
if (!isNativeTextControl(node)) {
|
|
1047
|
+
return node.a11yReplaceText(from, to, String(text)) !== false;
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof node._commit !== 'function') return false;
|
|
1050
|
+
const a = valueOffset(node, from);
|
|
1051
|
+
const b = valueOffset(node, to);
|
|
1052
|
+
const chars = node._chars();
|
|
1053
|
+
const insert = Array.from(String(text));
|
|
1054
|
+
chars.splice(a, b - a, ...insert);
|
|
1055
|
+
node._commit(chars, a + insert.length);
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Put the caret at an offset, selecting nothing. Legitimate on a
|
|
1060
|
+
* read-only element too — caret browsing through a document is a read,
|
|
1061
|
+
* not an edit. */
|
|
1062
|
+
setCaret(node, offset) {
|
|
1063
|
+
return this.setTextSelection(node, offset, offset);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
setTextSelection(node, start, end) {
|
|
1067
|
+
if (node.destroyed) return false;
|
|
1068
|
+
if (typeof node.a11ySetSelection === 'function') {
|
|
1069
|
+
const { chars } = textStateOf(node);
|
|
1070
|
+
return (
|
|
1071
|
+
node.a11ySetSelection(
|
|
1072
|
+
clampOffset(start, chars.length),
|
|
1073
|
+
clampOffset(end, chars.length),
|
|
1074
|
+
) !== false
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
if (!isNativeTextControl(node)) return false;
|
|
1078
|
+
node._anchor = valueOffset(node, start);
|
|
1079
|
+
node._caret = valueOffset(node, end);
|
|
1080
|
+
node._repaint?.();
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
copyTextRange(node, start, end) {
|
|
1085
|
+
if (!isNativeTextControl(node) || node.destroyed) return;
|
|
1086
|
+
if (typeof node._copySelection !== 'function') return;
|
|
1087
|
+
const caret = node._caret;
|
|
1088
|
+
const anchor = node._anchor;
|
|
1089
|
+
const a = valueOffset(node, start);
|
|
1090
|
+
const b = valueOffset(node, end);
|
|
1091
|
+
node._anchor = Math.min(a, b);
|
|
1092
|
+
node._caret = Math.max(a, b);
|
|
1093
|
+
try {
|
|
1094
|
+
node._copySelection('CLIPBOARD');
|
|
1095
|
+
} finally {
|
|
1096
|
+
node._caret = caret;
|
|
1097
|
+
node._anchor = anchor;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ---- signals ---------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
_signal(path, iface, member, detail, detail1, detail2, anyData) {
|
|
1104
|
+
if (this.dead) return;
|
|
1105
|
+
try {
|
|
1106
|
+
this.bus.sendSignal(path, iface, member, EVENT_SIGNATURE, [
|
|
1107
|
+
detail,
|
|
1108
|
+
detail1 | 0,
|
|
1109
|
+
detail2 | 0,
|
|
1110
|
+
anyData ?? ['i', 0],
|
|
1111
|
+
[],
|
|
1112
|
+
]);
|
|
1113
|
+
} catch {
|
|
1114
|
+
// a marshalling failure on one event must not take down dispatch;
|
|
1115
|
+
// the tree itself stays queryable
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
emitObject(node, member, detail = '', d1 = 0, d2 = 0, anyData = null) {
|
|
1120
|
+
this._signal(
|
|
1121
|
+
this.pathOf(node),
|
|
1122
|
+
IFACE.EVENT_OBJECT,
|
|
1123
|
+
member,
|
|
1124
|
+
detail,
|
|
1125
|
+
d1,
|
|
1126
|
+
d2,
|
|
1127
|
+
anyData,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
emitWindow(node, member) {
|
|
1132
|
+
this._signal(this.pathOf(node), IFACE.EVENT_WINDOW, member, '', 0, 0, [
|
|
1133
|
+
's',
|
|
1134
|
+
a11yName(node),
|
|
1135
|
+
]);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
emitStateChanged(node, stateBit, on) {
|
|
1139
|
+
const nick = ATSPI_STATE_NICK[stateBit];
|
|
1140
|
+
if (!nick) return;
|
|
1141
|
+
this.emitObject(node, 'StateChanged', nick, on ? 1 : 0, 0, ['i', 0]);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
_cacheItemFor(node) {
|
|
1145
|
+
return [
|
|
1146
|
+
this.refFor(node),
|
|
1147
|
+
this.rootRef(),
|
|
1148
|
+
this.parentRef(node),
|
|
1149
|
+
AccessibleImpl.GetIndexInParent.call({ b: this, n: node }),
|
|
1150
|
+
a11yChildren(node).length,
|
|
1151
|
+
this.interfacesOf(node),
|
|
1152
|
+
a11yName(node),
|
|
1153
|
+
atspiRoleOf(node),
|
|
1154
|
+
a11yDescription(node),
|
|
1155
|
+
a11yStates(node),
|
|
1156
|
+
];
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
emitCacheAdd(node) {
|
|
1160
|
+
if (this.dead) return;
|
|
1161
|
+
try {
|
|
1162
|
+
this.bus.sendSignal(
|
|
1163
|
+
CACHE_PATH,
|
|
1164
|
+
IFACE.CACHE,
|
|
1165
|
+
'AddAccessible',
|
|
1166
|
+
CACHE_ITEM_SIGNATURE,
|
|
1167
|
+
[this._cacheItemFor(node)],
|
|
1168
|
+
);
|
|
1169
|
+
} catch {
|
|
1170
|
+
// as _signal
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
emitCacheRemove(ref) {
|
|
1175
|
+
if (this.dead) return;
|
|
1176
|
+
try {
|
|
1177
|
+
this.bus.sendSignal(CACHE_PATH, IFACE.CACHE, 'RemoveAccessible', '(so)', [
|
|
1178
|
+
ref,
|
|
1179
|
+
]);
|
|
1180
|
+
} catch {
|
|
1181
|
+
// as _signal
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ---- the queue -------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
_schedule() {
|
|
1188
|
+
if (this._flushScheduled || this.dead) return;
|
|
1189
|
+
this._flushScheduled = true;
|
|
1190
|
+
queueMicrotask(() => this.flush());
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Is this node under a toplevel the bridge is presenting? Detached
|
|
1194
|
+
* subtrees under construction are not, which is what keeps the initial
|
|
1195
|
+
* bottom-up build of a tree from queueing one event per node. */
|
|
1196
|
+
_live(node) {
|
|
1197
|
+
let n = node;
|
|
1198
|
+
while (n) {
|
|
1199
|
+
if (!n.parent) return this.toplevels.includes(n);
|
|
1200
|
+
n = n.parent;
|
|
1201
|
+
}
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
queueAttach(node) {
|
|
1206
|
+
this._pending.attach.add(node);
|
|
1207
|
+
this._schedule();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
queueRemove(parent, child, index) {
|
|
1211
|
+
this._pending.remove.push({ parent, child, index });
|
|
1212
|
+
this._schedule();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
queueProps(node) {
|
|
1216
|
+
this._pending.props.add(node);
|
|
1217
|
+
this._schedule();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
queueText(node) {
|
|
1221
|
+
this._pending.text.add(node);
|
|
1222
|
+
this._schedule();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
queueFocus(previous, next) {
|
|
1226
|
+
this._pending.focus.push({ previous, next });
|
|
1227
|
+
this._schedule();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
queueWindowFocus(win, focused) {
|
|
1231
|
+
this._pending.window.push({ win, focused });
|
|
1232
|
+
this._schedule();
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
flush() {
|
|
1236
|
+
if (this.dead) return;
|
|
1237
|
+
this._flushScheduled = false;
|
|
1238
|
+
const p = this._pending;
|
|
1239
|
+
if (
|
|
1240
|
+
p.attach.size === 0 &&
|
|
1241
|
+
p.remove.length === 0 &&
|
|
1242
|
+
p.props.size === 0 &&
|
|
1243
|
+
p.text.size === 0 &&
|
|
1244
|
+
p.focus.length === 0 &&
|
|
1245
|
+
p.window.length === 0
|
|
1246
|
+
) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
this._pending = {
|
|
1250
|
+
attach: new Set(),
|
|
1251
|
+
remove: [],
|
|
1252
|
+
props: new Set(),
|
|
1253
|
+
text: new Set(),
|
|
1254
|
+
focus: [],
|
|
1255
|
+
window: [],
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
// Removals first: a node that left and came back in one batch reads as
|
|
1259
|
+
// its add, never as a stale remove.
|
|
1260
|
+
for (const { parent, child, index } of p.remove) {
|
|
1261
|
+
const stillIn =
|
|
1262
|
+
parent === null
|
|
1263
|
+
? this.toplevels.includes(child)
|
|
1264
|
+
: child.parent === parent;
|
|
1265
|
+
if (stillIn) continue;
|
|
1266
|
+
const wasExported = this.exported.has(child);
|
|
1267
|
+
const childPath = this.pathOf(child);
|
|
1268
|
+
const childRef = [this.bus.name, childPath];
|
|
1269
|
+
// unexport the whole subtree the AT may hold refs into — the drawn
|
|
1270
|
+
// children included, read from what the element last said rather than
|
|
1271
|
+
// by asking one that is being torn down; each exported node also
|
|
1272
|
+
// tells the client caches it is gone
|
|
1273
|
+
const walk = (n) => {
|
|
1274
|
+
for (const c of n.children ?? []) walk(c);
|
|
1275
|
+
for (const item of knownSceneItems(n)) walk(item);
|
|
1276
|
+
this._unexport(n);
|
|
1277
|
+
};
|
|
1278
|
+
walk(child);
|
|
1279
|
+
// Whatever the AT never saw, it does not need to hear about.
|
|
1280
|
+
if (!wasExported) continue;
|
|
1281
|
+
if (child.isWindow && !child.parent) {
|
|
1282
|
+
this._signal(childPath, IFACE.EVENT_WINDOW, 'Destroy', '', 0, 0, [
|
|
1283
|
+
's',
|
|
1284
|
+
'',
|
|
1285
|
+
]);
|
|
1286
|
+
}
|
|
1287
|
+
if (parent === null || this.exported.has(parent)) {
|
|
1288
|
+
this._signal(
|
|
1289
|
+
parent === null ? ROOT_PATH : this.pathOf(parent),
|
|
1290
|
+
IFACE.EVENT_OBJECT,
|
|
1291
|
+
'ChildrenChanged',
|
|
1292
|
+
'remove',
|
|
1293
|
+
index,
|
|
1294
|
+
0,
|
|
1295
|
+
['(so)', childRef],
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Attaches, collapsed to the topmost node per subtree: the AT descends
|
|
1301
|
+
// from there on its own.
|
|
1302
|
+
for (const node of p.attach) {
|
|
1303
|
+
if (node.destroyed || !this._live(node)) continue;
|
|
1304
|
+
let covered = false;
|
|
1305
|
+
for (let a = node.parent; a; a = a.parent) {
|
|
1306
|
+
if (p.attach.has(a)) {
|
|
1307
|
+
covered = true;
|
|
1308
|
+
break;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (covered) continue;
|
|
1312
|
+
const wasExported = this.exported.has(node);
|
|
1313
|
+
const parent = a11yParent(node);
|
|
1314
|
+
const index = parent
|
|
1315
|
+
? a11yIndexIn(parent, node)
|
|
1316
|
+
: this.toplevels.indexOf(node);
|
|
1317
|
+
if (!wasExported) this.emitCacheAdd(node);
|
|
1318
|
+
this._signal(
|
|
1319
|
+
parent ? this.pathOf(parent) : ROOT_PATH,
|
|
1320
|
+
IFACE.EVENT_OBJECT,
|
|
1321
|
+
'ChildrenChanged',
|
|
1322
|
+
'add',
|
|
1323
|
+
index,
|
|
1324
|
+
0,
|
|
1325
|
+
['(so)', this.refFor(node)],
|
|
1326
|
+
);
|
|
1327
|
+
if (node.isWindow && !node.parent) this.emitWindow(node, 'Create');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
for (const node of p.props) this.syncNode(node);
|
|
1331
|
+
for (const node of p.text) this.syncText(node);
|
|
1332
|
+
|
|
1333
|
+
for (const { previous, next } of p.focus) {
|
|
1334
|
+
if (previous && this.exported.has(previous) && !previous.destroyed) {
|
|
1335
|
+
this.emitStateChanged(previous, ATSPI_STATE.FOCUSED, false);
|
|
1336
|
+
this._refreshSnapshotStates(previous);
|
|
1337
|
+
}
|
|
1338
|
+
if (next && !next.destroyed && this._live(next)) {
|
|
1339
|
+
this.ensureExported(next);
|
|
1340
|
+
this.emitStateChanged(next, ATSPI_STATE.FOCUSED, true);
|
|
1341
|
+
this._signal(this.pathOf(next), IFACE.EVENT_FOCUS, 'Focus', '', 0, 0, [
|
|
1342
|
+
'i',
|
|
1343
|
+
0,
|
|
1344
|
+
]);
|
|
1345
|
+
this._refreshSnapshotStates(next);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const lastWindow = new Map();
|
|
1350
|
+
for (const { win, focused } of p.window) lastWindow.set(win, focused);
|
|
1351
|
+
for (const [win, focused] of lastWindow) {
|
|
1352
|
+
if (win.destroyed || !this.exported.has(win)) continue;
|
|
1353
|
+
this.emitWindow(win, focused ? 'Activate' : 'Deactivate');
|
|
1354
|
+
this.emitStateChanged(win, ATSPI_STATE.ACTIVE, focused);
|
|
1355
|
+
this._refreshSnapshotStates(win);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
_refreshSnapshotStates(node) {
|
|
1360
|
+
const entry = this.exported.get(node);
|
|
1361
|
+
if (entry?.snapshot) entry.snapshot.states = a11yStates(node);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Recompute what the bus was last told about a node and emit the exact
|
|
1365
|
+
* difference. Every "some prop changed" notification lands here, which is
|
|
1366
|
+
* what makes the wiring correct without per-prop plumbing. */
|
|
1367
|
+
syncNode(node) {
|
|
1368
|
+
const entry = this.exported.get(node);
|
|
1369
|
+
if (!entry || node.destroyed) return;
|
|
1370
|
+
const before = entry.snapshot;
|
|
1371
|
+
// interface set can change (a value appearing, a handler added)
|
|
1372
|
+
const wanted = this._ifaceSetFor(node);
|
|
1373
|
+
if (wanted.join() !== entry.ifaces.join()) {
|
|
1374
|
+
const path = NODE_PATH + entry.id;
|
|
1375
|
+
for (const name of entry.ifaces) {
|
|
1376
|
+
if (!wanted.includes(name)) this.bus.unexportInterface(path, name);
|
|
1377
|
+
}
|
|
1378
|
+
for (const name of wanted) {
|
|
1379
|
+
if (!entry.ifaces.includes(name)) {
|
|
1380
|
+
const [desc, proto] = this._descriptorFor(name);
|
|
1381
|
+
this.bus.exportInterface(this._implFor(proto, node), path, desc);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
entry.ifaces = wanted;
|
|
1385
|
+
}
|
|
1386
|
+
const now = this._snapshot(node);
|
|
1387
|
+
now.ifaces = entry.ifaces;
|
|
1388
|
+
entry.snapshot = now;
|
|
1389
|
+
if (now.name !== before.name) {
|
|
1390
|
+
this.emitObject(node, 'PropertyChange', 'accessible-name', 0, 0, [
|
|
1391
|
+
's',
|
|
1392
|
+
now.name,
|
|
1393
|
+
]);
|
|
1394
|
+
}
|
|
1395
|
+
if (now.description !== before.description) {
|
|
1396
|
+
this.emitObject(node, 'PropertyChange', 'accessible-description', 0, 0, [
|
|
1397
|
+
's',
|
|
1398
|
+
now.description,
|
|
1399
|
+
]);
|
|
1400
|
+
}
|
|
1401
|
+
if (now.value !== before.value && now.value !== null) {
|
|
1402
|
+
this.emitObject(node, 'PropertyChange', 'accessible-value', 0, 0, [
|
|
1403
|
+
'd',
|
|
1404
|
+
now.value,
|
|
1405
|
+
]);
|
|
1406
|
+
}
|
|
1407
|
+
for (const half of [0, 1]) {
|
|
1408
|
+
let changed = (before.states[half] ^ now.states[half]) >>> 0;
|
|
1409
|
+
while (changed !== 0) {
|
|
1410
|
+
const low = changed & -changed;
|
|
1411
|
+
const stateBit = 31 - Math.clz32(low) + half * 32;
|
|
1412
|
+
this.emitStateChanged(node, stateBit, Boolean(now.states[half] & low));
|
|
1413
|
+
changed = (changed ^ low) >>> 0;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (before.text && now.text) this._diffText(node, before.text, now.text);
|
|
1417
|
+
if (before.scene.length > 0 || now.scene.length > 0) {
|
|
1418
|
+
this._diffScene(node, before.scene, now.scene);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* The items an element drew, as children arriving and leaving. Identity
|
|
1424
|
+
* is the element's own id, resolved in a11y.js, so a scene rebuilt every
|
|
1425
|
+
* frame produces events only where something actually changed — and a
|
|
1426
|
+
* surviving item is re-diffed through `syncNode`, the same path a
|
|
1427
|
+
* `<box>`'s props take, which is what gets its name and its selected
|
|
1428
|
+
* state onto the bus without a second diff living here.
|
|
1429
|
+
*/
|
|
1430
|
+
_diffScene(parent, before, now) {
|
|
1431
|
+
const kept = new Set(now);
|
|
1432
|
+
for (const item of before) {
|
|
1433
|
+
if (kept.has(item)) continue;
|
|
1434
|
+
if (!this.exported.has(item)) continue;
|
|
1435
|
+
const ref = [this.bus.name, this.pathOf(item)];
|
|
1436
|
+
const walk = (n) => {
|
|
1437
|
+
for (const c of n.children ?? []) walk(c);
|
|
1438
|
+
this._unexport(n);
|
|
1439
|
+
};
|
|
1440
|
+
walk(item);
|
|
1441
|
+
this._signal(
|
|
1442
|
+
this.pathOf(parent),
|
|
1443
|
+
IFACE.EVENT_OBJECT,
|
|
1444
|
+
'ChildrenChanged',
|
|
1445
|
+
'remove',
|
|
1446
|
+
// where it was: the retained children it sat behind, then its own
|
|
1447
|
+
// place in the scene it has just left
|
|
1448
|
+
a11yChildren(parent).length - now.length + before.indexOf(item),
|
|
1449
|
+
0,
|
|
1450
|
+
['(so)', ref],
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const gone = new Set(before);
|
|
1454
|
+
for (const item of now) {
|
|
1455
|
+
if (gone.has(item)) {
|
|
1456
|
+
this.syncNode(item);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
this.emitCacheAdd(item);
|
|
1460
|
+
this._signal(
|
|
1461
|
+
this.pathOf(parent),
|
|
1462
|
+
IFACE.EVENT_OBJECT,
|
|
1463
|
+
'ChildrenChanged',
|
|
1464
|
+
'add',
|
|
1465
|
+
a11yIndexIn(parent, item),
|
|
1466
|
+
0,
|
|
1467
|
+
['(so)', this.refFor(item)],
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
syncText(node) {
|
|
1473
|
+
const entry = this.exported.get(node);
|
|
1474
|
+
if (!entry || node.destroyed || !entry.snapshot?.text) return;
|
|
1475
|
+
const before = entry.snapshot.text;
|
|
1476
|
+
const now = textStateOf(node);
|
|
1477
|
+
entry.snapshot.text = now;
|
|
1478
|
+
this._diffText(node, before, now);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* The difference as AT-SPI events, with the composition's own churn
|
|
1483
|
+
* marked `:system` — the detail suffix Gecko established for a change the
|
|
1484
|
+
* user did not type, and which at-spi2-core's event dispatch already
|
|
1485
|
+
* parses. A preedit appearing, growing or being abandoned is exactly
|
|
1486
|
+
* that: nothing was typed, a decoration is on the screen.
|
|
1487
|
+
*
|
|
1488
|
+
* The **commit** stays a plain `insert`. `dead_acute` then `e` deletes
|
|
1489
|
+
* the `´` — inside the old preedit, so `delete:system` — and inserts `é`,
|
|
1490
|
+
* which is in no preedit and is what the user actually typed. A reader
|
|
1491
|
+
* that suppresses system text says "é" and nothing else, which is the
|
|
1492
|
+
* distinction this path exists to draw.
|
|
1493
|
+
*/
|
|
1494
|
+
_diffText(node, before, now) {
|
|
1495
|
+
const diff = diffChars(before.chars, now.chars);
|
|
1496
|
+
if (diff) {
|
|
1497
|
+
if (diff.removed.length > 0) {
|
|
1498
|
+
// what it replaced belonged to the *previous* state's composition
|
|
1499
|
+
const system = inPreedit(before, diff.offset, diff.removed.length);
|
|
1500
|
+
this.emitObject(
|
|
1501
|
+
node,
|
|
1502
|
+
'TextChanged',
|
|
1503
|
+
system ? 'delete:system' : 'delete',
|
|
1504
|
+
diff.offset,
|
|
1505
|
+
diff.removed.length,
|
|
1506
|
+
['s', diff.removed.join('')],
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (diff.inserted.length > 0) {
|
|
1510
|
+
const system = inPreedit(now, diff.offset, diff.inserted.length);
|
|
1511
|
+
this.emitObject(
|
|
1512
|
+
node,
|
|
1513
|
+
'TextChanged',
|
|
1514
|
+
system ? 'insert:system' : 'insert',
|
|
1515
|
+
diff.offset,
|
|
1516
|
+
diff.inserted.length,
|
|
1517
|
+
['s', diff.inserted.join('')],
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
if (now.caret !== before.caret) {
|
|
1522
|
+
this.emitObject(node, 'TextCaretMoved', '', now.caret, 0, ['i', 0]);
|
|
1523
|
+
}
|
|
1524
|
+
const selectionChanged =
|
|
1525
|
+
now.selection[0] !== before.selection[0] ||
|
|
1526
|
+
now.selection[1] !== before.selection[1];
|
|
1527
|
+
if (selectionChanged) {
|
|
1528
|
+
this.emitObject(node, 'TextSelectionChanged', '', 0, 0, ['i', 0]);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// ---- the application root -------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
exportRoot() {
|
|
1535
|
+
const bridge = this;
|
|
1536
|
+
const rootAccessible = {
|
|
1537
|
+
get Name() {
|
|
1538
|
+
return programName();
|
|
1539
|
+
},
|
|
1540
|
+
get Description() {
|
|
1541
|
+
return '';
|
|
1542
|
+
},
|
|
1543
|
+
get Parent() {
|
|
1544
|
+
return bridge.desktop;
|
|
1545
|
+
},
|
|
1546
|
+
get ChildCount() {
|
|
1547
|
+
return bridge.toplevels.length;
|
|
1548
|
+
},
|
|
1549
|
+
get Locale() {
|
|
1550
|
+
return localeString();
|
|
1551
|
+
},
|
|
1552
|
+
get AccessibleId() {
|
|
1553
|
+
return '';
|
|
1554
|
+
},
|
|
1555
|
+
GetChildAtIndex(index) {
|
|
1556
|
+
const win = bridge.toplevels[index];
|
|
1557
|
+
return win ? bridge.refFor(win) : bridge.nullRef();
|
|
1558
|
+
},
|
|
1559
|
+
GetChildren() {
|
|
1560
|
+
return bridge.toplevels.map((win) => bridge.refFor(win));
|
|
1561
|
+
},
|
|
1562
|
+
GetIndexInParent() {
|
|
1563
|
+
return -1;
|
|
1564
|
+
},
|
|
1565
|
+
GetRelationSet() {
|
|
1566
|
+
return [];
|
|
1567
|
+
},
|
|
1568
|
+
GetRole() {
|
|
1569
|
+
return ATSPI_ROLE.APPLICATION;
|
|
1570
|
+
},
|
|
1571
|
+
GetRoleName() {
|
|
1572
|
+
return 'application';
|
|
1573
|
+
},
|
|
1574
|
+
GetLocalizedRoleName() {
|
|
1575
|
+
return 'application';
|
|
1576
|
+
},
|
|
1577
|
+
GetState() {
|
|
1578
|
+
return [0, 0];
|
|
1579
|
+
},
|
|
1580
|
+
GetAttributes() {
|
|
1581
|
+
return [['toolkit', 'react-x11']];
|
|
1582
|
+
},
|
|
1583
|
+
GetApplication() {
|
|
1584
|
+
return bridge.rootRef();
|
|
1585
|
+
},
|
|
1586
|
+
GetInterfaces() {
|
|
1587
|
+
return [IFACE.ACCESSIBLE, IFACE.APPLICATION, IFACE.SOCKET];
|
|
1588
|
+
},
|
|
1589
|
+
};
|
|
1590
|
+
const application = {
|
|
1591
|
+
ToolkitName: 'react-x11',
|
|
1592
|
+
get Version() {
|
|
1593
|
+
return bridge.toolkitVersion;
|
|
1594
|
+
},
|
|
1595
|
+
AtspiVersion: ATSPI_VERSION,
|
|
1596
|
+
get Id() {
|
|
1597
|
+
return bridge.appId;
|
|
1598
|
+
},
|
|
1599
|
+
set Id(value) {
|
|
1600
|
+
bridge.appId = value | 0;
|
|
1601
|
+
},
|
|
1602
|
+
GetLocale() {
|
|
1603
|
+
return localeString();
|
|
1604
|
+
},
|
|
1605
|
+
GetApplicationBusAddress() {
|
|
1606
|
+
return '';
|
|
1607
|
+
},
|
|
1608
|
+
};
|
|
1609
|
+
const socket = {
|
|
1610
|
+
Embedded() {
|
|
1611
|
+
return null;
|
|
1612
|
+
},
|
|
1613
|
+
Unembed() {
|
|
1614
|
+
return null;
|
|
1615
|
+
},
|
|
1616
|
+
};
|
|
1617
|
+
const cache = {
|
|
1618
|
+
GetItems() {
|
|
1619
|
+
const items = [bridge._rootCacheItem(rootAccessible)];
|
|
1620
|
+
const walk = (node) => {
|
|
1621
|
+
items.push(bridge._cacheItemFor(node));
|
|
1622
|
+
for (const child of a11yChildren(node)) walk(child);
|
|
1623
|
+
};
|
|
1624
|
+
for (const win of bridge.toplevels) walk(win);
|
|
1625
|
+
return items;
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
this.bus.exportInterface(rootAccessible, ROOT_PATH, ACCESSIBLE_DESC);
|
|
1629
|
+
this.bus.exportInterface(application, ROOT_PATH, APPLICATION_DESC);
|
|
1630
|
+
this.bus.exportInterface(socket, ROOT_PATH, SOCKET_DESC);
|
|
1631
|
+
this.bus.exportInterface(cache, CACHE_PATH, CACHE_DESC);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** The cache item for the application root itself, which is not a node. */
|
|
1635
|
+
_rootCacheItem(rootAccessible) {
|
|
1636
|
+
return [
|
|
1637
|
+
this.rootRef(),
|
|
1638
|
+
this.rootRef(),
|
|
1639
|
+
this.desktop,
|
|
1640
|
+
-1,
|
|
1641
|
+
this.toplevels.length,
|
|
1642
|
+
rootAccessible.GetInterfaces(),
|
|
1643
|
+
rootAccessible.Name,
|
|
1644
|
+
ATSPI_ROLE.APPLICATION,
|
|
1645
|
+
'',
|
|
1646
|
+
[0, 0],
|
|
1647
|
+
];
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// ---- registration ----------------------------------------------------
|
|
1651
|
+
|
|
1652
|
+
async embed() {
|
|
1653
|
+
const reply = await this.bus.invoke({
|
|
1654
|
+
destination: REGISTRY_NAME,
|
|
1655
|
+
path: ROOT_PATH,
|
|
1656
|
+
interface: IFACE.SOCKET,
|
|
1657
|
+
member: 'Embed',
|
|
1658
|
+
signature: '(so)',
|
|
1659
|
+
body: [[this.bus.name, ROOT_PATH]],
|
|
1660
|
+
});
|
|
1661
|
+
if (Array.isArray(reply) && reply.length === 2) this.desktop = reply;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** The registry restarting is survivable: watch its name and re-embed
|
|
1665
|
+
* under the new owner, exactly as the GTK bridge does. */
|
|
1666
|
+
async watchRegistry() {
|
|
1667
|
+
const rule =
|
|
1668
|
+
"type='signal',sender='org.freedesktop.DBus'," +
|
|
1669
|
+
"interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
|
|
1670
|
+
`arg0='${REGISTRY_NAME}'`;
|
|
1671
|
+
await this.bus.addMatch(rule);
|
|
1672
|
+
const key = this.bus.mangle(
|
|
1673
|
+
'/org/freedesktop/DBus',
|
|
1674
|
+
'org.freedesktop.DBus',
|
|
1675
|
+
'NameOwnerChanged',
|
|
1676
|
+
);
|
|
1677
|
+
this.bus.signals.on(key, (body) => {
|
|
1678
|
+
const newOwner = body?.[2];
|
|
1679
|
+
if (newOwner) this.embed().catch(() => {});
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// ---- wiring into the renderer ---------------------------------------
|
|
1684
|
+
|
|
1685
|
+
install() {
|
|
1686
|
+
hooks.rootMounted = (win) => {
|
|
1687
|
+
if (this.toplevels.includes(win)) return;
|
|
1688
|
+
this.toplevels.push(win);
|
|
1689
|
+
this.queueAttach(win);
|
|
1690
|
+
};
|
|
1691
|
+
hooks.rootUnmounted = (win) => {
|
|
1692
|
+
const index = this.toplevels.indexOf(win);
|
|
1693
|
+
if (index === -1) return;
|
|
1694
|
+
this.toplevels.splice(index, 1);
|
|
1695
|
+
this.queueRemove(null, win, index);
|
|
1696
|
+
};
|
|
1697
|
+
hooks.attached = (parent, child) => {
|
|
1698
|
+
if (!this._live(parent)) return;
|
|
1699
|
+
this.queueAttach(child);
|
|
1700
|
+
};
|
|
1701
|
+
hooks.detach = (parent, child) => {
|
|
1702
|
+
if (!this.exported.has(child) && !this._live(parent)) return;
|
|
1703
|
+
this.queueRemove(parent, child, a11yIndexIn(parent, child));
|
|
1704
|
+
};
|
|
1705
|
+
hooks.propsChanged = (node) => {
|
|
1706
|
+
if (this.exported.has(node)) this.queueProps(node);
|
|
1707
|
+
};
|
|
1708
|
+
hooks.textContent = (chunk) => {
|
|
1709
|
+
let text = chunk.parent;
|
|
1710
|
+
while (text && text.kind === 'text' && text.isSpan) text = text.parent;
|
|
1711
|
+
if (!text) return;
|
|
1712
|
+
if (this.exported.has(text)) this.queueText(text);
|
|
1713
|
+
// labels feed names-from-contents anywhere above them
|
|
1714
|
+
for (let a = text; a; a = a11yParent(a)) {
|
|
1715
|
+
if (this.exported.has(a)) this.queueProps(a);
|
|
1716
|
+
if (a.isWindow) break;
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
hooks.textState = (node) => {
|
|
1720
|
+
if (this.exported.has(node)) this.queueText(node);
|
|
1721
|
+
};
|
|
1722
|
+
hooks.focus = (previous, next) => {
|
|
1723
|
+
this.queueFocus(previous, next);
|
|
1724
|
+
};
|
|
1725
|
+
hooks.windowFocus = (win, focused) => {
|
|
1726
|
+
this.queueWindowFocus(win, focused);
|
|
1727
|
+
};
|
|
1728
|
+
hooks.commit = () => this.flush();
|
|
1729
|
+
hooks.announce = (text, opts) => {
|
|
1730
|
+
// spoken from the active window, which is the context the user is in
|
|
1731
|
+
const target =
|
|
1732
|
+
this.toplevels.find((win) => win.events?.windowFocused) ??
|
|
1733
|
+
this.toplevels[0] ??
|
|
1734
|
+
null;
|
|
1735
|
+
if (target) this.ensureExported(target);
|
|
1736
|
+
this._signal(
|
|
1737
|
+
target ? this.pathOf(target) : ROOT_PATH,
|
|
1738
|
+
IFACE.EVENT_OBJECT,
|
|
1739
|
+
'Announcement',
|
|
1740
|
+
'',
|
|
1741
|
+
opts?.assertive ? 2 : 1, // AtspiLive: 1 polite, 2 assertive
|
|
1742
|
+
0,
|
|
1743
|
+
['s', String(text)],
|
|
1744
|
+
);
|
|
1745
|
+
return true;
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
// toplevels that mounted before the bridge finished connecting
|
|
1749
|
+
this._unsubscribes.push(
|
|
1750
|
+
onApp((app) => {
|
|
1751
|
+
for (const win of app._rootChildren ?? []) {
|
|
1752
|
+
hooks.rootMounted(win);
|
|
1753
|
+
}
|
|
1754
|
+
}),
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
bury() {
|
|
1759
|
+
if (this.dead) return;
|
|
1760
|
+
this.dead = true;
|
|
1761
|
+
for (const key of Object.keys(hooks)) hooks[key] = null;
|
|
1762
|
+
for (const unsubscribe of this._unsubscribes) unsubscribe();
|
|
1763
|
+
this._unsubscribes = [];
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// --------------------------------------------------------------------------
|
|
1768
|
+
// The ladder
|
|
1769
|
+
// --------------------------------------------------------------------------
|
|
1770
|
+
|
|
1771
|
+
let bridge = null;
|
|
1772
|
+
|
|
1773
|
+
/** Wait for a fresh dbus-native client to finish its handshake and its
|
|
1774
|
+
* Hello, or throw with the reason it could not. */
|
|
1775
|
+
async function connected(bus) {
|
|
1776
|
+
await new Promise((resolve, reject) => {
|
|
1777
|
+
const conn = bus.connection;
|
|
1778
|
+
const cleanup = () => {
|
|
1779
|
+
conn.removeListener('connect', onConnect);
|
|
1780
|
+
conn.removeListener('error', onError);
|
|
1781
|
+
conn.removeListener('close', onClose);
|
|
1782
|
+
};
|
|
1783
|
+
const onConnect = () => {
|
|
1784
|
+
cleanup();
|
|
1785
|
+
resolve();
|
|
1786
|
+
};
|
|
1787
|
+
const onError = (err) => {
|
|
1788
|
+
cleanup();
|
|
1789
|
+
reject(err);
|
|
1790
|
+
};
|
|
1791
|
+
const onClose = () => {
|
|
1792
|
+
cleanup();
|
|
1793
|
+
reject(new Error('the bus closed during the handshake'));
|
|
1794
|
+
};
|
|
1795
|
+
conn.once('connect', onConnect);
|
|
1796
|
+
conn.on('error', onError);
|
|
1797
|
+
conn.once('close', onClose);
|
|
1798
|
+
});
|
|
1799
|
+
// one round trip so bus.name (the Hello reply) is settled
|
|
1800
|
+
await bus.listNames();
|
|
1801
|
+
if (bus.name == null) {
|
|
1802
|
+
throw new Error('the peer answered but never assigned a unique name');
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Ask the session bus where the accessibility bus is. `org.a11y.Bus` is
|
|
1808
|
+
* D-Bus-activatable, so this also starts the launcher on desktops where it
|
|
1809
|
+
* is not already running.
|
|
1810
|
+
*
|
|
1811
|
+
* Deliberately **not** the shared `sessionBus()` connection. Discovery is
|
|
1812
|
+
* one call at process startup; riding the shared machinery would leave the
|
|
1813
|
+
* warm shared socket open as the side effect of a probe, and would race
|
|
1814
|
+
* anything that swaps `DBUS_SESSION_BUS_ADDRESS` and resets the shared
|
|
1815
|
+
* state under an in-flight climb — which is exactly what a test harness
|
|
1816
|
+
* (this repo's own included) does between cases. A private connection,
|
|
1817
|
+
* closed before this returns, cannot interfere with anyone.
|
|
1818
|
+
*/
|
|
1819
|
+
async function accessibilityBusAddress(dbus) {
|
|
1820
|
+
if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
|
|
1821
|
+
const busAddress = addressFor('session');
|
|
1822
|
+
if (!busAddress && process.platform !== 'darwin') return null;
|
|
1823
|
+
let sbus = null;
|
|
1824
|
+
try {
|
|
1825
|
+
sbus = dbus.createClient({ busAddress });
|
|
1826
|
+
await connected(sbus);
|
|
1827
|
+
const address = await sbus.invoke({
|
|
1828
|
+
destination: 'org.a11y.Bus',
|
|
1829
|
+
path: '/org/a11y/bus',
|
|
1830
|
+
interface: 'org.a11y.Bus',
|
|
1831
|
+
member: 'GetAddress',
|
|
1832
|
+
});
|
|
1833
|
+
return typeof address === 'string' && address !== '' ? address : null;
|
|
1834
|
+
} catch {
|
|
1835
|
+
return null;
|
|
1836
|
+
} finally {
|
|
1837
|
+
if (sbus) {
|
|
1838
|
+
silenceAndEnd(sbus);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function silenceAndEnd(abus) {
|
|
1844
|
+
try {
|
|
1845
|
+
abus.connection.removeAllListeners('error');
|
|
1846
|
+
abus.connection.on('error', () => {});
|
|
1847
|
+
abus.connection.end?.();
|
|
1848
|
+
} catch {
|
|
1849
|
+
// it was never usable; nothing to say
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Climb the ladder and return the live bridge, or null. Called once per
|
|
1855
|
+
* process through `startA11y()`; every rung that fails reports why when
|
|
1856
|
+
* REACT_X11_A11Y is set and stays silent otherwise — "no accessibility
|
|
1857
|
+
* stack here" is a normal configuration, not an error.
|
|
1858
|
+
*/
|
|
1859
|
+
export async function start() {
|
|
1860
|
+
if (bridge) return bridge.dead ? null : bridge;
|
|
1861
|
+
const loud = Boolean(process.env.REACT_X11_A11Y);
|
|
1862
|
+
const off = (why, cause) => {
|
|
1863
|
+
if (loud) {
|
|
1864
|
+
console.warn(
|
|
1865
|
+
`react-x11: accessibility off — ${why}` +
|
|
1866
|
+
(cause ? ` (${cause.message ?? cause})` : ''),
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
return null;
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
let dbus;
|
|
1873
|
+
try {
|
|
1874
|
+
dbus = await loadTransport();
|
|
1875
|
+
} catch (cause) {
|
|
1876
|
+
return off('dbus-native is not installed (npm i dbus-native)', cause);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
const address = await accessibilityBusAddress(dbus);
|
|
1880
|
+
if (!address) {
|
|
1881
|
+
return off(
|
|
1882
|
+
'no accessibility bus (no session bus, or no at-spi on this desktop)',
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
let abus;
|
|
1887
|
+
try {
|
|
1888
|
+
abus = dbus.createClient({ busAddress: address });
|
|
1889
|
+
} catch (cause) {
|
|
1890
|
+
return off(`could not parse the accessibility bus address`, cause);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
try {
|
|
1894
|
+
await connected(abus);
|
|
1895
|
+
} catch (cause) {
|
|
1896
|
+
silenceAndEnd(abus);
|
|
1897
|
+
return off('could not connect to the accessibility bus', cause);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Never hold the process open: the bridge is a passenger, not cargo. If
|
|
1901
|
+
// the app has no other work, exiting is correct and the registry sees the
|
|
1902
|
+
// name drop.
|
|
1903
|
+
abus.connection.stream?.unref?.();
|
|
1904
|
+
|
|
1905
|
+
let version = '0.0.0';
|
|
1906
|
+
try {
|
|
1907
|
+
const { createRequire } = await import('node:module');
|
|
1908
|
+
version = createRequire(import.meta.url)('../package.json').version;
|
|
1909
|
+
} catch {
|
|
1910
|
+
// bundled; the toolkit version is cosmetic
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
const b = new AtspiBridge(abus, version);
|
|
1914
|
+
abus.connection.on('error', () => b.bury());
|
|
1915
|
+
abus.connection.once('close', () => b.bury());
|
|
1916
|
+
|
|
1917
|
+
b.exportRoot();
|
|
1918
|
+
try {
|
|
1919
|
+
await b.embed();
|
|
1920
|
+
} catch (cause) {
|
|
1921
|
+
// A bus with no registry is unusual but not fatal: the watch below
|
|
1922
|
+
// embeds the moment one appears.
|
|
1923
|
+
if (loud) {
|
|
1924
|
+
console.warn(
|
|
1925
|
+
'react-x11: accessibility registry not reachable yet:',
|
|
1926
|
+
cause?.message ?? cause,
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
b.watchRegistry().catch(() => {});
|
|
1931
|
+
b.install();
|
|
1932
|
+
bridge = b;
|
|
1933
|
+
if (loud) {
|
|
1934
|
+
console.warn(
|
|
1935
|
+
`react-x11: accessibility bridge live on ${abus.name} (${address})`,
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
return b;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/** Test seam: tear the bridge down and forget it, so the next start() can
|
|
1942
|
+
* climb the ladder again against a different bus. */
|
|
1943
|
+
export async function _stopForTests() {
|
|
1944
|
+
if (!bridge) return;
|
|
1945
|
+
const b = bridge;
|
|
1946
|
+
bridge = null;
|
|
1947
|
+
b.bury();
|
|
1948
|
+
try {
|
|
1949
|
+
await b.bus.close();
|
|
1950
|
+
} catch {
|
|
1951
|
+
// already gone
|
|
1952
|
+
}
|
|
1953
|
+
}
|