Add accessible text, relations and live regions; keep dormant a11y free

Text:
- Ranged text access in code points (length, range, line bounds) that
  never copies more than requested, plus a text revision (document UUID
  and modification) so UIA and macOS cache their UTF-16 copies.
- Report document edits as exact change records, up to four per widget
  and frame, then collapse to one whole-text change. Resets, loads and
  document swaps report a whole-text change.
- The removed text is available only while an edit is being notified
  (TextDocument::getNotifiedRemovedText()); DocumentContentChange stays
  { range, text }, so queued records (LSP) copy nothing extra.
- Suppressed text changes are keyed by node ref, so a document shared
  with another window still reports its edits.
- UITextView reports NameChanged; UITextInput overrides onTextChanged()
  to report ValueChanged.

Relations, live regions and shortcuts:
- aria-labelledby, aria-describedby and aria-live (API and CSS). Ids a
  client has read are remembered; renaming, adding or removing their
  widgets notifies the dependents. setId() copies the old id only when
  it is remembered.
- Live regions and UISceneNode::announceForAccessibility(). A newer
  change from the same widget replaces its pending announcement, and
  hidden sources are dropped.
- Menu item shortcuts (std::string text and key) exposed as AT-SPI key
  bindings, the UIA accelerator key and macOS command characters.
- ScrollTo for anything inside a scroll view
  (UIScrollView::scrollIntoView()). Icon-only buttons are named from
  their tooltip.

Dormant path (no client attached):
- Widget notifications are inline: one relaxed atomic load of a
  process-wide count of active root scenes, no call.
- Scene destruction and scene or host changes never create a manager.
  Without a client, notify() queues nothing and widget deletion does
  not register parents to announce it. While only the root has an
  identity, deletion skips the ancestor walk unless a remembered
  relation target may be affected.
- When the last client disconnects, model-view sources release their
  queried rows. Persistent handles count their registrations, so
  handles held elsewhere survive (PersistentModelIndex::release()).
- Spin boxes no longer hide their input explicitly, and
  AccessibilityProperties shrinks from 144 to 136 bytes.

Backends:
- AT-SPI: object:announcement, word and line granularity, ranged
  reads, SetTextContents reported as its minimal diff. Watch
  disconnects per client (NameOwnerChanged arg0 plus a non-blocking
  NameHasOwner check) instead of for every bus name. Without a scene
  thread pool, initialize on a detached thread that shares ownership,
  so teardown never waits on D-Bus timeouts.
- UIA: notification events (resolved at runtime, Windows 10 1709+),
  text ranges served from the revision cache, and a per-frame provider
  drain that skips the lock when nothing is queued.
- macOS: announcements, a UTF-16 text cache per revision, and command
  key attributes.
- EEPP_ACCESSIBILITY_TRACE=1 logs client activity transitions.

Fixes:
- UICodeEditor::isType() no longer dispatches to a subclass's
  getType(), so a UITextEdit is a code editor again.
- ecode: dropped files open only in document editors, not in other
  code editors such as a commit message box.

Tools, tests and docs:
- Widget inspector: role names, shortcut, relation and live rows, and
  an accessibility audit (unnamed controls, broken relations).
- Accessibility example: live status, an icon-only button, and edit and
  reset actions that test_atspi.py checks end to end.
- Regression tests for text records, relations, live regions,
  disconnect and reconnect, scene lifecycle and id changes.
- Add the AccessibilityDormant benchmark (C++ new/new[] counted on the
  measuring thread) and benchmark_accessibility_dormant.py, which fails
  on missing measurements and compares against other builds with
  --compare LABEL=PATH.
- Document the dormant-path rules, what survives a disconnection, and
  the remaining platform limitations.
This commit is contained in:
Martín Lucas Golini
2026-10-10 02:01:52 -03:00
parent 1dd0870187
commit 67660d4c13
55 changed files with 4118 additions and 420 deletions
+93 -13
View File
@@ -75,9 +75,26 @@ need to be materialized as `UIWidget` objects merely for accessibility.
While a cell is being edited, its editor widget is exposed as the only child of the virtual row
(lists and trees) or cell (tables) it edits, and keyboard focus is reported on the editor.
Text editing support currently covers `UITextInput` and `UITextEdit`, including caret and selection
queries/actions and native change notifications. `UICodeEditor` and an end-to-end screen-reader
workflow in ecode are not part of this initial foundation.
Text editing support covers `UITextInput`, `UITextEdit` and `UICodeEditor`, including caret and
selection queries/actions and native change notifications. A code editor with a file is named after
that file unless it has a label. Setting its text through an assistive client replaces the document
contents as one undoable edit, so the file path and undo history are kept.
Text queries read only the range a client asks for, through a line index that is rebuilt once per
edit, so moving the caret in a large file does not copy the file. Edits reach Linux clients as exact
insert and delete records taken from the document. A wholesale replacement (a reset, a reload, or
many edits in one frame, such as replace-all) is reported once as "everything removed, everything
inserted". Windows and macOS need UTF-16 offsets, so they convert the text once per edit and reuse
that copy for every query until the next edit.
Controls whose content is their own name, value and state (buttons, check boxes, radio buttons,
labels, tabs, menu items, sliders, spin boxes, progress bars and images) do not expose their
internal widgets, so a button's text is read once, as its name, and not again as a child label.
Menu items expose their keyboard shortcut (AT-SPI key binding, UIA `AcceleratorKey`, macOS menu
command character and modifiers). Any widget inside a `UIScrollView` offers a scroll-into-view
action, which scrolls every enclosing scroll view the least distance needed. The same behavior is
available to applications as `UIScrollView::scrollIntoView( node )`.
## Accessible names and descriptions
@@ -90,9 +107,26 @@ Use `aria-label` when a widget's visible content does not provide a useful name.
<TextEdit id="description"
aria-label="Description"
aria-description="Multiline editor. Press Mod Tab to move to the next control." />
aria-description="Multiline editor. Press Control Tab to move to the next control." />
```
When the name is visible elsewhere, point at it instead of repeating it. `aria-labelledby` names a
widget after the widget with that id, and `aria-describedby` uses another widget's text as the
description. Both ids are resolved from the scene root. When the referenced widget's text changes,
or it is added, removed or given another id, assistive clients are told that the dependent widget's
name or description changed:
```xml
<TextView id="user-label" text="User name" />
<TextInput aria-labelledby="user-label" aria-describedby="user-hint" />
<TextView id="user-hint" text="The name you sign in with" />
```
Names resolve in this order: `aria-labelledby`, `aria-label`, the widget's own text (button text,
label text, window title, code editor file name), then its tooltip. An icon-only button with a
tooltip is therefore named by the tooltip. A tooltip that did not become the name is used as the
description when no other description is set.
Use `aria-hidden="true"` for decorative or redundant widgets that should not appear in the native
accessibility hierarchy:
@@ -107,8 +141,12 @@ These properties are also available through C++:
widget->setAccessibilityLabel( "Project name" );
widget->setAccessibilityDescription( "Enter the project display name." );
widget->setAccessibilityHidden( false );
widget->setAccessibilityLabelledBy( "user-label" );
widget->setAccessibilityDescribedBy( "user-hint" );
```
Setting a property to its current value does nothing, so re-applying styles is free.
Prefer a concise label that identifies the control. A description should add information rather
than repeat the label or visible text.
@@ -147,7 +185,35 @@ Standard widgets notify the accessibility manager when relevant UI state changes
- model and child hierarchy changes.
Bounds are queried on demand. Routine position and size changes do not emit per-widget native
layout notifications; this avoids event floods while scrolling or running layout.
layout notifications; this avoids event floods while scrolling or running layout. A window resize is
announced once, on the window root.
### Announcements and live regions
Use an announcement for an event the user should hear without moving focus, such as a finished
build:
```cpp
sceneNode->announceForAccessibility( "Build finished" );
sceneNode->announceForAccessibility( "Build failed", AccessibilityLive::Assertive );
```
`Polite` waits until the screen reader finishes speaking; `Assertive` interrupts it. Announcements
use UIA notifications (Windows 10 1709 and later), the AT-SPI `object:announcement` signal
(at-spi2-core 2.46 and later, read by Orca) and `NSAccessibilityAnnouncementRequestedNotification`
on macOS. Older systems ignore them.
For text that changes in place, such as a status bar, mark it as a live region instead:
```xml
<TextView id="status" text="Ready" aria-live="polite" />
```
Name and value changes of a widget inside a live region (`polite` or `assertive`) are announced,
unless the widget or one of its ancestors is `aria-hidden`.
Several changes to the same widget in one frame produce one announcement with the final text, so a
progress label updated every frame does not flood the screen reader. Both APIs are free while no
assistive client is active.
Applications using the standard widget APIs receive these notifications automatically. A custom
control that changes semantic state outside the standard paths may notify its scene accessibility
@@ -162,9 +228,9 @@ destroyed.
Accessible controls should be keyboard focusable and have a logical focus order. Standard Tab and
Shift+Tab navigation moves between controls.
Multiline text editors reserve Tab for inserting indentation. Use Mod+Tab to move to the next
focusable widget and Mod+Shift+Tab to move to the previous one. `Mod` means Command on macOS and
Control on other desktop platforms.
Multiline text editors reserve Tab for inserting indentation. Use Control+Tab to move to the next
focusable widget and Control+Shift+Tab to move to the previous one. This matches AppKit on macOS,
where Command+Tab is reserved for switching applications.
Screen readers may provide an additional browse or navigation mode. Their commands and speech
settings are controlled by the screen reader and are separate from eepp's keyboard focus handling.
@@ -172,8 +238,14 @@ settings are controlled by the screen reader and are separate from eepp's keyboa
## Testing an application
The `eepp-ui-accessibility` example contains representative controls, model-backed views, dynamic
metadata, and multiple native windows. Build and run the optimized release target before using a
platform accessibility tool.
metadata, a live region, and multiple native windows. Build and run the optimized release target
before using a platform accessibility tool.
The widget inspector (`UIWidgetInspector`) has an Accessibility tab with the selected widget's
resolved role, name, description, value, shortcut, relations, states and actions, and an Audit tab
that lists focusable controls without an accessible name and `aria-labelledby` /
`aria-describedby` ids that match nothing. Selecting an issue selects the widget in the tree. The
same check is available to tests as `AccessibilityWidgetResolver::audit( root )`.
### Linux
@@ -222,8 +294,13 @@ and destruction.
## Performance behavior
Native accessibility backends are query-centered and create provider objects lazily. When no
native client has interacted with the application, widget notifications take a cached inactive
fast path and avoid resolving accessibility data or walking the widget hierarchy.
native client has interacted with the application, widget notifications take an inline inactive
fast path: one atomic load, no call, no allocation, and no accessibility data resolved. Editing
text, building widgets (spin boxes included), deleting widget hierarchies and updating models
allocate no more than they would without accessibility, as measured by C++ `new` on the UI thread
(see the architecture guide's benchmark notes). When the last client disconnects, rows it
queried are released, so model updates stop paying for them; a client connecting later starts
fresh.
Model child counts and individual child queries do not instantiate every accessible row. Queried
rows retain identities across insertion and removal, and model sibling indexes are resolved
@@ -245,7 +322,10 @@ must not depend on rendering, and native queries or notifications must never sta
The first implementation focuses on the semantics used by standard eepp desktop widgets. Native
platform APIs contain additional advanced interfaces that are not yet exposed uniformly, including
some rich-document formatting, complex table metadata, and container-specific scrolling APIs.
some rich-document formatting, complex table metadata, and container scroll patterns (scroll
position and paging, as opposed to scrolling an element into view). HTML content (`UIRichText`,
headings, links, lists) is not yet exposed as semantic elements. Relations are used to compute names
and descriptions but are not published as native relation sets.
Do not advertise a native pattern or capability unless its required behavior is implemented.
Assistive technologies can still navigate the semantic hierarchy and operate the supported
+133 -14
View File
@@ -135,10 +135,47 @@ materializing a whole child list.
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. AT-SPI emits D-Bus signals right away, except text
changes, which are diffed once per frame. UIA and macOS queue the event and raise it from their
next `update()`, on the UI thread and outside any widget or model callback. The manager's queue
is cleared at the end of every `AccessibilityManager::update()`.
5. The backend emits the native notification. AT-SPI emits D-Bus signals right away (caret and
selection changes are compared once per frame). UIA and macOS queue the event and raise it from
their next `update()`, on the UI thread and outside any widget or model callback. The manager's
queue is cleared at the end of every `AccessibilityManager::update()`.
Text edits take an extra step before their `ValueChanged`. `UICodeEditor` and `UITextInput` pass
the document's `DocumentContentChange` (range and inserted text) to
`UIWidget::notifyAccessibilityTextChanged()`. The removed text is not part of the change record,
so consumers that queue records (LSP clients) never copy it: the manager reads it, while the
document is still notifying, from `TextDocument::getNotifiedRemovedText()`. `AccessibilityManager::onTextChanged()` turns the
first four per element and frame into exact `AccessibilityTextChange` records (code-point offset,
removed, inserted). After that, and for resets, loads, reloads and document swaps, it sends one
whole-text change for the rest of the frame, so a replace-all costs one notification.
`AccessibilityBackend::onTextChanged()` receives them; AT-SPI turns exact ones into `text-changed`
signals immediately and a whole-text one into delete-all plus insert-all at its next update. A
backend returns false for a change it ignores, and an ignored change does not count toward the
frame's four. AT-SPI's `SetTextContents` reports its replacement as one minimal diff, so it
suppresses the element's records through `AccessibilityManager::setSuppressedTextChanges()` while
the action runs. Node refs are manager-local, so an editor in another window sharing the document
still reports the change.
Announcements take a parallel path. `UISceneNode::announceForAccessibility()` and live regions (a
`NameChanged` or `ValueChanged` from a widget with an `aria-live` ancestor and no `aria-hidden`
one) append to `AccessibilityManager`'s announcement queue. A live-region entry remembers its
source widget, and a later change from the same widget in the same frame replaces its text instead
of queueing another entry. `AccessibilityManager::update()` hands the queue to
`AccessibilityBackend::announce()` after the backend's own `update()`, so the frame's focus and
state changes reach the client first. Live-region entries are checked again at delivery and
dropped when their source was destroyed, hidden or made `aria-hidden` in the meantime.
Names and descriptions that come from `aria-labelledby` / `aria-describedby` depend on another
widget. When a client reads such a widget's info, the manager remembers the referenced id (ids,
not pointers, so no entry can dangle). A name change of a widget with a remembered id, an id
change, or the arrival, removal or deletion of a subtree containing one, makes the manager scan
the scene for dependents and notify `NameChanged` or `DescriptionChanged` for them. While no
client has read a dependent, the check is one lookup in an empty set. `UIWidget::setId()` asks
`AccessibilityManager::isRelationTarget()` (an allocation-free hash lookup) before renaming, and
keeps a copy of the old id only when it is remembered. Dependents are notified after the id has
changed, for the old id and then the new one (which may complete a relation that pointed at a
missing widget), so a backend that reads the node from inside the notification sees the new
relation.
## Rules the implementation relies on
@@ -148,17 +185,46 @@ These are the invariants that past bugs came from. Keep them when changing the c
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.
- `UIWidget::notifyAccessibilityEvent()`, `notifyAccessibilityTextChanged()` and
`notifyAccessibilityWholeTextChanged()` are inline. They test one process-wide counter of root
scenes with an active client (`UIWidget::isAccessibilityActive()`, a relaxed atomic load) and
only then call the out-of-line `deliver*()` implementation, which checks the scene's own bit
(`UISceneNode::hasActiveAccessibilityClients()`, `AccessibilityClientActive`). Callers that
compute a payload before notifying test `isAccessibilityActive()` first. Pick the event by
overriding, not by checking the type at the call site: `UITextView::onTextChanged()` reports
`NameChanged` and `UITextInput` overrides it to report `ValueChanged`. Release code for a dormant
`UITextView::onTextChanged()` is one load, one test and a jump.
- The bit is set when a backend reports a client (`hasActiveNativeClients()`, refreshed in
`UISceneNode::update()`) or when a native query arrives (`onNativeClientObserved()`).
`UISceneNode::update()`) or when a native query arrives (`onNativeClientObserved()`). Every
change goes through `UISceneNode::setAccessibilityClientActive()`, which keeps the process-wide
counter in sync and, when the last client goes away, calls
`AccessibilityManager::onClientsDisconnected()` (see [rule 6](#6-what-survives-a-disconnection)).
- 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.
- Without a client, `AccessibilityManager::notify()` queues nothing: only identity invalidation
(`Destroyed` without a related node) still reaches the backend, which detaches native wrappers
on it. Removing a widget a disconnected client had seen invalidates its identity without
resolving or registering its parent to announce the removal. While only the scene root has an
identity, deleting any other widget returns before walking its ancestors, unless a client is
active and has read a relation (`aria-labelledby`, `aria-describedby`) whose target may be in
the deleted subtree.
- Lifecycle bookkeeping never creates a manager: destruction, scene and host changes use
`UISceneNode::getExistingAccessibilityManager()`, and the const `getAccessibilityManager()` does
not create one at any nesting level.
- 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.
- `UISceneNode::announceForAccessibility()` returns before doing anything while no client is
active, but a caller that formats its message first still pays for that formatting.
`benchmark_accessibility_inactive.py` guards this path in CI.
`benchmark_accessibility_inactive.py` compares the enabled and environment-disabled backends of the
same build: both run the same compiled-in hooks, so it cannot show costs they share.
`benchmark_accessibility_dormant.py` measures those directly and can compare against the same
benchmark built against another tree (`--compare LABEL=PATH`), such as an exported develop
revision. Its allocation counts and bytes cover ordinary C++ `new`/`new[]` on the measuring thread
only: not `malloc`, other threads or background work such as AT-SPI initialization. "No more
allocations than without accessibility" means exactly that measurement. A run passes only when
every sample exits cleanly and reports every expected measurement, guarded scenarios included.
### 2. Teardown happens once, top-down
@@ -187,7 +253,8 @@ sequence that happened. Only adjacent query-again hints (`ModelChanged`, `Childr
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, per frame:
- AT-SPI diffs each changed text once.
- AT-SPI compares each text's caret and selection once, and reports a whole-text replacement
once.
- UIA raises one focus change, for the final keyboard focus.
- macOS keeps only the last focus change, and posts every other non-structural notification at
most once per (event, element, related element). AppKit notifications carry no state, so a
@@ -213,7 +280,28 @@ The manager, the resolver and every widget are UI-thread only.
- 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.
where the value is needed. Text interfaces use `getTextLength()`, `getTextRange()` and
`getTextLineBounds()`, which read only what they return. Offsets convert through a line index of
the document (`lineStarts()` in the resolver), keyed by `getTextRevision()`: the document's UUID
and modification id, which every content change bumps. Backends that need a converted copy
(UTF-16 for UIA and AppKit) cache it per revision.
- Leaf controls (`AccessibilityWidgetResolver::isLeafRole()`) have no exposed children. Events and
focus from inside one are reported on the control (`getLeafOwner()`), so the inner widgets never
get identities.
### 6. What survives a disconnection
When a scene's last client disconnects, `onClientsDisconnected()` resets every model-view source:
queried rows are dropped together with their persistent model registrations, and backends detach
their native wrappers (`onSourceInvalidated()`). Without that, the model would keep updating one
persistent handle per queried row on every insert, delete and move for as long as it lives.
Identities are never reused, so a reconnecting client starts from fresh row identities and a stale
one stays invalid. Widget identities and metadata (labels, relations, live regions) are kept.
The model only drops a persistent handle when all its registrations are released
(`PersistentModelIndex::release()`); handles other consumers registered are unaffected. A queried
row's registration is released when its node is dropped, so pruned, reset and destroyed sources do
not leave handles behind either.
## Backends
@@ -223,11 +311,17 @@ One `AtSpi::AtSpiApplication` is shared by all windows of the process and owns a
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>`.
A peer other than the registry that calls the application becomes a client. Its disconnection is
watched with a `NameOwnerChanged` match on its own unique name, not a bus-wide match that would
wake the application for every program joining or leaving the bus. A non-blocking `NameHasOwner`
check sent after the match covers a client that left before the match was installed: the bus
processes both requests in order.
| 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`). |
| `accessibilitybackendatspievents.cpp` | Manager events to `org.a11y.atspi.Event.*` signals; text change records to `text-changed`, and once-per-frame caret and selection comparison (`text-caret-moved`, `text-selection-changed`). Keeps no text contents. |
| `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`). |
@@ -267,6 +361,11 @@ every backend: `role()` and `roleName()` in `accessibilitybackendatspitree.cpp`,
AT-SPI `appendStates()` / `actionName()`, UIA patterns in the provider's `QueryInterface()` and
`GetPatternProvider()`, macOS action names and attributes.
**Announce something.** Call `UISceneNode::announceForAccessibility()`, or mark the widget that
changes with `aria-live`. A backend implements `AccessibilityBackend::announce()`; AT-SPI emits
`Announcement` on the root object, UIA calls `UiaRaiseNotificationEvent` (looked up at runtime),
macOS posts `NSAccessibilityAnnouncementRequestedNotification` on the window.
**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`.
@@ -284,22 +383,42 @@ or wrong.
| 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. |
| Dormant allocations and costs | `src/benchmarks/accessibility_dormant_benchmark.cpp` (`eepp-benchmarks --filter='AccessibilityDormant.*'`), summarized by `projects/scripts/benchmark_accessibility_dormant.py [--compare LABEL=PATH]` | Locally, Release builds. Allocation counts are deterministic and asserted where they guard a fix; times are indicative. |
| 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;
- the runtime inspector's *Accessibility* tab shows exactly what a widget projects, and its *Audit*
tab (`AccessibilityWidgetResolver::audit()`) lists unnamed focusable controls and broken
`aria-labelledby` / `aria-describedby` ids;
- `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.
`dbus-run-session` as CI does;
- `EEPP_ACCESSIBILITY_TRACE=1` prints client activity transitions, and AT-SPI client connections
and disconnections, to stderr. It shows what activates an application that has no screen reader
running.
## 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.
Any process that queries one of the application's accessibility elements (not only screen
readers) activates it for the rest of its life.
- Windows refreshes `UiaClientsAreListening()` on every scene update; a cheaper cadence needs
native validation that listener changes are still observed promptly.
- Linux connects to the accessibility bus at startup, whether or not a client will ever attach,
so applications are discoverable by tools started later and by clients that never set the
`org.a11y.Status` flags. Gating the connection on those flags (as Qt does when no X11 root
window atom publishes the bus) would trade that discovery for startup work; it is a policy
choice, not a transparent optimization. Initialization runs off the UI thread: on the scene's
thread pool, or on a detached thread when the scene has none. Either way the task shares
ownership of the AT-SPI application, so tearing the backend down never waits for pending D-Bus
calls or their timeouts; whichever owner releases it last destroys it.
- The line index is rebuilt in full after each edit (about 3 ms for a 100,000-line document),
paid only while a client is querying that document.
- HTML content (`UIRichText`, headings, links, lists) is not exposed as semantic elements.
- Only one cell editor (the view's active editor) is exposed inside a model view.