Files
eepp/docs/articles/uiaccessibilityarchitecture.md
T
Martín Lucas Golini 4baa4ae4b1 Split accessibility backends, fix CI regressions and document architecture
CI fixes for the previous commit:
- Windows: announce one BoundsChanged on the window root when the scene
  resizes, so UIA raises LayoutInvalidated again. Widgets still do not
  report their own bounds changes.
- macOS: collapse identical pending notifications per element within one
  update, so one text selection change posts a single
  AXSelectedTextChanged. Created/Destroyed keep every occurrence, in order.
- MinGW: link the UIA test harness with -municode and uuid, which fixes
  the nightly MinGW build.

Backends:
- Split the AT-SPI backend into a private header plus connection, events,
  tree/marshalling and method files. Break the 600-line D-Bus dispatcher
  into one handler per interface.
- Split the UIA backend into a private header plus core, element provider
  and text range files.
- Share hasState/hasAction in accessibilitybackend.hpp instead of three
  per-backend copies.

CI and docs:
- Run test_atspi.py --multi-window on Linux: closing a secondary window
  while an AT-SPI client stays connected.
- Add docs/articles/uiaccessibilityarchitecture.md (layer map, files,
  invariants, per-backend layout, common tasks, test matrix) and link it
  from the articles index and the accessibility guide.
- Add a unit test for the single layout notification on scene resize.
2026-10-09 01:02:11 -03:00

299 lines
18 KiB
Markdown

# UI Accessibility Architecture
This article is for people working on eepp's accessibility implementation. For the application
facing API (roles, labels, policies and testing an application) read
[UI accessibility](uiaccessibility.md) first.
The implementation has two halves:
- a small, platform independent **core** that projects the live widget tree into an accessible
tree on demand;
- three **native backends** that translate that projection to AT-SPI (Linux/FreeBSD),
NSAccessibility (macOS) and UI Automation (Windows).
Most day to day work (new widgets, new roles, lifecycle fixes) happens in the core. The backends
mostly change when a platform feature is added.
## Map
```text
Screen reader / inspector / automation client
|
AT-SPI (D-Bus) | NSAccessibility | UI Automation (COM)
|
+------------------v-------------------------------------------+
| Native backend (one per platform) accessibilitybackend*|
| - answers native queries by calling the manager |
| - turns manager events into native notifications |
+------------------+-------------------------------------------+
| AccessibilityNodeRef, AccessibilityNodeInfo,
| AccessibilityPendingEvent (accessibility.hpp)
+------------------v-------------------------------------------+
| AccessibilityManager (one per top-level UISceneNode) |
| - node identities, tree navigation, focus, hit testing |
| - event queue, teardown and ownership rules |
+--------+-------------------------+---------------------------+
| |
+--------v-----------+ +---------v------------------------------+
| Widget resolver | | AccessibilitySource (virtual nodes) |
| role/name/state/ | | AccessibilityModelViewSource: rows, |
| actions/text of a | | cells and the cell editor of list, |
| live UIWidget | | table and tree views |
+--------^-----------+ +----------------------------------------+
|
UIWidget hooks: notifyAccessibilityEvent(), ~UIWidget, Node::childRemove,
UISceneNode lifecycle, widget-specific state changes
```
### Files
| Area | Files | What lives there |
| --- | --- | --- |
| Public types | `include/eepp/ui/accessibility/accessibility.hpp` | Roles, states, actions, events, `AccessibilityNodeRef`, `AccessibilityNodeInfo`. |
| Manager | `accessibilitymanager.{hpp,cpp}` | Identities, navigation, focus, hit testing, `notify()`, teardown. |
| Widget semantics | `accessibilitywidgetresolver.{hpp,cpp}` | Role, name, value, range, text, state and actions for each standard widget type; focus projection; event routing. |
| Virtual nodes | `accessibilitysource.hpp`, `accessibilitymodelviewsource.{hpp,cpp}` | The source interface and its model-view implementation. |
| Backend interface | `src/eepp/ui/accessibility/accessibilitybackend.{hpp,cpp}` | `AccessibilityBackend`, the null backend, shared helpers. |
| AT-SPI | `accessibilitybackendatspi.hpp` and `accessibilitybackendatspi{,events,tree,methods}.cpp` | See [AT-SPI](#at-spi-linux-and-freebsd). |
| UI Automation | `accessibilitybackenduia.hpp` and `accessibilitybackenduia{,provider,text}.cpp` | See [UI Automation](#ui-automation-windows). |
| NSAccessibility | `accessibilitybackendmacos.mm` | See [NSAccessibility](#nsaccessibility-macos). |
| Widget hooks | `uiwidget.cpp`, `uiscenenode.cpp`, `scene/node.cpp`, and the individual widgets | Notifications at the point where state changes. |
| Inspector | `src/eepp/ui/tools/uiwidgetinspector.cpp` | The inspector's *Accessibility* tab shows the projected node of the selected widget. |
All backend and source files are private (`src/`), so they can change freely without affecting
the public API.
## Core concepts
### Node references
Every accessible node is an `AccessibilityNodeRef { source, id }`.
- `source == 1` (the manager's widget source) means `id` names a live `UIWidget`. The manager
keeps `mWidgetIds` / `mWidgets` to map between the two.
- Any other `source` is an `AccessibilitySource` that resolves its own ids. Today the only source
is the model-view source created for each `UIAbstractTableView` (list, table and tree views).
Identities are **lazy**: a widget gets an id the first time a client reaches it (`getNodeRef()`).
Ids are never reused, so a stale reference held by a client simply stops resolving
(`isValid()` returns false) instead of pointing at a different widget.
### What is exposed
A widget is an accessible element when its resolved role is not `None`
(`UIWidget::isAccessibilityElement()`). Containers with role `None` are transparent: their
element descendants are reported as children of the nearest element ancestor. A widget marked
hidden (`aria-hidden`) hides its whole subtree.
`AccessibilityWidgetResolver` derives semantics from the widget type (`isType()` chains) unless
the application overrode them through the widget's accessibility properties. It is the single
place that knows how a `UISpinBox`, `UIComboBox`, `UITextEdit`, and so on map to roles, states and
actions.
### Model views
List, table and tree views recycle their cell widgets, so those widgets are implementation
detail. The model-view source exposes rows (and cells for tables) as virtual nodes keyed by
`PersistentModelIndex`, so identities follow rows when the model inserts or removes them.
- Child counts and child access are lazy: a 100k row model creates nodes only for the rows a
client asks for. Enumerating every child is still O(rows), as in other toolkits.
- While a cell is edited, the editor widget is a real widget exposed as the only child of the
virtual row (lists and trees) or cell (tables) it edits.
- `AccessibilityWidgetResolver::getOwningModelView()` is the one test for "is this widget an
implementation child of a view". Use it rather than walking ancestors by hand.
### Focus
Focus is projected, not reported raw: the focused node may be an implementation child (a spin
box's text input) or a cell widget. `AccessibilityWidgetResolver::getFocusOwner()` walks from the
event dispatcher's focus node to the element that represents it. Both
`AccessibilityManager::getKeyboardFocusedNode()` and the `Focused` state use it, so they always
agree. Ancestors that merely contain the focus are not `Focused`.
## The two flows
### Queries (client to eepp)
1. The client asks the backend (D-Bus method, `NSAccessibility` attribute or COM call).
2. The backend translates the native object to an `AccessibilityNodeRef` and calls the manager:
`getNodeInfo()`, `getParent()`, `getChildCount()` / `getChild()`, `getIndexInParent()`,
`hitTest()`, `getKeyboardFocusedNode()`, `performAction()`.
3. The manager resolves the ref to a widget (resolver) or to a source.
Queries always run on the UI thread (see [Threading](#threading)). Use the cheapest query that
answers the question: `getNodeInfo( ref, false, false )` skips copying the value and computing
text offsets, which matters for large text documents; `getChild()` and `getIndexInParent()` avoid
materializing a whole child list.
### Events (eepp to client)
1. A widget changes and calls `notifyAccessibilityEvent( event )`.
2. If no native client is active, this returns immediately (see [the gate](#1-the-dormant-fast-path)).
3. `AccessibilityWidgetResolver::getEventTarget()` routes the event to its semantic owner (a
dropdown's state belongs to its combo box; an implementation child's focus belongs to its
view) or drops it (implementation children, detached widgets).
4. `AccessibilityManager::notify()` invalidates caches, appends the event to the frame's queue and
hands it to the backend immediately through `AccessibilityBackend::onEvent()`.
5. The backend emits the native notification, either right away (AT-SPI, UIA) or on the next
`update()` (macOS, and AT-SPI text diffs). The manager's queue is cleared at the end of every
`AccessibilityManager::update()`.
## Rules the implementation relies on
These are the invariants that past bugs came from. Keep them when changing the code.
### 1. The dormant fast path
Applications without an assistive client must pay close to nothing.
- `UIWidget::notifyAccessibilityEvent()` first checks `UISceneNode::hasActiveAccessibilityClients()`,
a cached bit (`AccessibilityClientActive`). Nothing else runs while it is clear.
- The bit is set when a backend reports a client (`hasActiveNativeClients()`, refreshed in
`UISceneNode::update()`) or when a native query arrives (`onNativeClientObserved()`).
- Backends decide what "active" means. AT-SPI tracks client bus names and clears the bit when they
disconnect. UIA requires window-specific activity. macOS has no disconnect signal, so the bit
stays set after the first query.
- Do not add per-frame or per-layout work that runs before this check. Bounds are queried on
demand; there is deliberately no per-widget bounds notification.
`benchmark_accessibility_inactive.py` guards this path in CI.
### 2. Teardown happens once, top-down
Widget destruction is where most of the dangerous bugs were.
- `~UIWidget` calls `AccessibilityManager::onWidgetDelete( this )` **before** `onClose()`, then sets
`NODE_FLAG_DESTROYING`. The manager invalidates the whole subtree at once
(`removeSubtreeIdentities()`), so descendants' destructors find nothing to do and never touch a
partially destroyed ancestor.
- Anything that walks ancestors must stop at a destroying node: `getNodeRef()`, `getParent()`,
`getEventTarget()` and `getFocusOwner()` all do.
- Deleting something no client has seen sends nothing and allocates no identity. Use
`getWidgetParent()`, which does not register the widget, when you only need the parent of a widget
that is going away.
- `~UISceneNode` destroys its manager (and therefore the native backend) **before** deleting its
children, while the root is still alive, and then clears `mRoot`. A closing scene
(`NODE_FLAG_CLOSE`) returns no manager from either `getAccessibilityManager()` overload.
- Nested scenes (`UISceneNode` inside another scene) share the host's manager. Moving or deleting
them goes through `onSubtreeRemoved()`.
### 3. Event order is meaningful
`notify()` hands each event to the backend synchronously, so the backend sees exactly the
sequence that happened. Only adjacent query-again hints (`ModelChanged`, `ChildrenChanged`,
`BoundsChanged`) with identical arguments are coalesced. Never coalesce state or structural
events: dropping the second half of add/remove/add or on/off/on leaves clients with stale state.
Backends that need batching do it themselves (AT-SPI diffs text once per frame, macOS keeps only
the last focus change).
### 4. Threading
The manager, the resolver and every widget are UI-thread only.
- **AT-SPI**: an I/O thread only `poll()`s the D-Bus socket and wakes the event loop. Messages are
read and dispatched on the UI thread in `AtSpiApplication::update()`, at most 256 per frame.
- **UIA**: COM calls arrive on arbitrary threads. Every provider method goes through
`UIAutomationProviderContext::invoke()`, which runs the callback directly on the UI thread or
posts a window message and waits up to `ProviderCallTimeoutMilliseconds`. The pending request
holds a COM reference on the provider so a timed-out call cannot free it mid-callback.
`UiaDisconnectProvider` is deferred to `update()` because outgoing COM calls can re-enter the
STA during widget destruction.
- **macOS**: AppKit is main-thread only; events are queued and delivered on the next `update()`.
### 5. Cost is proportional to what the client asks for
- Never walk the whole widget tree or the whole model per event or per query. Prefer the event
dispatcher's focus node, `getIndexInParent()` and source lookups over scanning children.
- Building `AccessibilityNodeInfo` with the value copies the text of a text widget; request it only
where the value is needed.
## Backends
### AT-SPI (Linux and FreeBSD)
One `AtSpi::AtSpiApplication` is shared by all windows of the process and owns a private
connection to the accessibility bus. libdbus is loaded at runtime (`DBusLibrary`), so there is no
build-time D-Bus dependency. Object paths encode `/org/eepp/a11y/<manager>/<source>/<id>`.
| File | Contents |
| --- | --- |
| `accessibilitybackendatspi.hpp` | `DBusLibrary` and the `AtSpiApplication` declaration, with members grouped by implementing file. |
| `accessibilitybackendatspi.cpp` | Connection setup and registry embedding, I/O thread, per-frame dispatch, client tracking, `AtSpiAccessibilityBackend`. |
| `accessibilitybackendatspievents.cpp` | Manager events to `org.a11y.atspi.Event.*` signals; text snapshots and once-per-frame text diffs (`text-changed`, `text-caret-moved`, `text-selection-changed`). |
| `accessibilitybackendatspitree.cpp` | Path/ref conversion, navigation, role and state mapping, action names, D-Bus marshalling helpers. |
| `accessibilitybackendatspimethods.cpp` | Incoming calls: `handleMessage()` routes to one handler per interface (`Accessible`, `Component`, `Action`, `Text`, `EditableText`, `Application`, `Properties`, `Cache`). |
### UI Automation (Windows)
Each top-level window has a `Uia::UIAutomationProviderContext`, created with the backend, which
subclasses the window to answer `WM_GETOBJECT` and caches one `UIAutomationProvider` per node.
| File | Contents |
| --- | --- |
| `accessibilitybackenduia.hpp` | The context (including `invoke()`), shared structs, helper declarations, provider and text range declarations. |
| `accessibilitybackenduia.cpp` | Provider cache and invalidation, window subclass, manager events to UIA events, `UIAutomationAccessibilityBackend`. |
| `accessibilitybackenduiaprovider.cpp` | The element provider: fragment navigation, properties and every control pattern; role and `VARIANT` helpers. |
| `accessibilitybackenduiatext.cpp` | `ITextRangeProvider`, UTF-16/code point conversion and text unit navigation. |
### NSAccessibility (macOS)
`accessibilitybackendmacos.mm` holds `MacAccessibilityState` (one per window: element cache and
manager access), `EEPPMacAccessibilityElement` (an `NSAccessibilityElement` subclass per node),
`MacAccessibilityRegistry` (process-wide window list and event delivery) and the backend class.
It is still a single file because it can only be built and tested on a macOS host.
## Common tasks
**Expose a new standard widget.** Add its role, name, state, actions and, if applicable, value,
range or text to `accessibilitywidgetresolver.cpp`. Call `notifyAccessibilityEvent()` from the
widget where its semantic state changes (for example `StateChanged` when it is checked). If it
contains implementation children, make sure they resolve to the widget (role `None`, hidden, or
routed in `getEventTarget()`), and add a unit test in `accessibility_tests.cpp`.
**Add a role.** Add it to `AccessibilityRole` (`accessibility.hpp`), map it in the resolver, then in
every backend: `role()` and `roleName()` in `accessibilitybackendatspitree.cpp`, `controlType()` in
`accessibilitybackenduiaprovider.cpp`, and `nativeRole()` / `nativeSubrole()` in
`accessibilitybackendmacos.mm`.
**Add an action or state.** Add the enum value, produce it in the resolver, then map it per backend:
AT-SPI `appendStates()` / `actionName()`, UIA patterns in the provider's `QueryInterface()` and
`GetPatternProvider()`, macOS action names and attributes.
**Answer a new AT-SPI method.** Add it to the matching `handle<Interface>()` in
`accessibilitybackendatspimethods.cpp`. Return `1` for "not handled" so the dispatcher replies with
`UnknownMethod`.
**Add a virtual node type.** Implement `AccessibilitySource` and create it from
`AccessibilityManager::sourceFor()`. Only implement the optional hooks (`getIndexInParent()`,
`getSelectedChildren()`, `getEmbeddedWidgetParent()`) when the default fallback would be too slow
or wrong.
## Testing and debugging
| Layer | How it is tested | Where it runs |
| --- | --- | --- |
| Core (manager, resolver, model-view source, lifecycle) | `src/tests/unit_tests/accessibility_tests.cpp`, using the null backend and `onNativeClientObserved()` to open the notification gate. | Every platform's unit test job. |
| macOS backend semantics | `src/tests/unit_tests/accessibility_macos_tests.mm` | macOS unit tests. |
| AT-SPI end to end | `projects/scripts/test_atspi.py` (default, `--multi-window`, `--close-primary`) and `benchmark_atspi.py` | Linux CI, inside `dbus-run-session` and Xvfb. |
| Inactive overhead | `projects/scripts/benchmark_accessibility_inactive.py` | Linux CI. |
| UIA end to end | `src/tests/windows_accessibility/main.cpp` (`eepp-windows-accessibility-tests`) | Windows CI. Under Wine it builds and finds the window, but Wine's UIA client lacks several APIs, so most checks report `E_NOTIMPL`. |
| NSAccessibility end to end | `projects/scripts/test_macos_accessibility.swift`; `benchmark_macos_accessibility.swift` locally | macOS CI. |
Useful tools:
- the runtime inspector's *Accessibility* tab shows exactly what a widget projects;
- `EEPP_DISABLE_ACCESSIBILITY=1` runs with the null backend, the baseline for performance and
behavior comparisons;
- Accerciser (Linux), Accessibility Inspector (macOS) and Inspect / Accessibility Insights
(Windows) show the native tree;
- to run the AT-SPI scripts without touching your desktop session, wrap them in
`dbus-run-session` as CI does.
## Known limitations
- Enumerating all children of a model view is O(rows).
- macOS cannot detect that a client went away, so its active bit stays set after the first query.
- `UICodeEditor` is not exposed; only `UITextInput` and `UITextEdit` provide text.
- Only one cell editor (the view's active editor) is exposed inside a model view.