Skip to content

Platform Details

xa11y normalizes platform-specific APIs into a unified interface. This page documents how roles and actions map across platforms, and where behavior differs.

xa11y Role macOS (AX) Linux (AT-SPI) Windows (UIA)
Window AXWindow, AXDrawer window, frame WindowControlType
Application AXApplication application (synthesized per process; UIA has no Application accessible)
Button AXButton push button ButtonControlType
CheckBox AXCheckBox check box, check menu item CheckBoxControlType
RadioButton AXRadioButton radio button, radio menu item RadioButtonControlType
TextField AXTextField, AXSecureTextField entry, password text EditControlType
TextArea AXTextArea text EditControlType
StaticText AXStaticText label, static, caption TextControlType
ComboBox AXComboBox, AXPopUpButton combo box ComboBoxControlType
List AXList, AXOutline list, list box ListControlType, TreeControlType
ListItem (via AXRow) list item ListItemControlType
Menu AXMenu menu MenuControlType
MenuItem AXMenuItem, AXMenuBarItem menu item, tearoff menu item MenuItemControlType
MenuBar AXMenuBar, AXMenuBarExtra menu bar MenuBarControlType
Tab (subrole AXTabButton) page tab TabItemControlType
TabGroup AXTabGroup page tab list TabControlType
Table AXTable table, tree table TableControlType, DataGridControlType
TableRow (via AXRow) table row DataItemControlType (no cell signal)
TableCell AXCell table cell, column/row header HeaderItemControlType, DataItemControlType (with TableItem pattern, or parent DataItem)
Toolbar AXToolbar tool bar ToolBarControlType
ScrollBar AXScrollBar scroll bar ScrollBarControlType
Slider AXSlider slider SliderControlType
Image AXImage image, icon ImageControlType
Link AXLink link HyperlinkControlType
Group AXGroup, AXScrollArea, AXRadioGroup panel, section, form, scroll pane GroupControlType, PaneControlType
Dialog AXSheet (or subrole AXDialog) dialog, file chooser WindowControlType (with IsDialog)
Alert (subrole AXApplicationAlert) alert, notification (alert pattern)
ProgressBar AXProgressIndicator, AXBusyIndicator progress bar ProgressBarControlType
TreeItem AXDisclosureTriangle (or subrole AXOutlineRow) tree item TreeItemControlType
WebArea AXWebArea document web, document frame DocumentControlType
Heading AXHeading (or subrole) heading (landmark pattern)
Separator AXSplitter separator SeparatorControlType
SplitGroup AXSplitGroup split pane PaneControlType
Switch (subrole AXSwitch) (inferred) (inferred)
SpinButton AXIncrementor spin button SpinnerControlType
Tooltip AXToolTip tooltip, tool tip ToolTipControlType
Status AXStatusBar status bar StatusBarControlType
Navigation (landmark) landmark, navigation (landmark)

Roles not recognized by a platform map to Unknown.

xa11y Action macOS Linux (AT-SPI) Windows (UIA)
Press AXPress click, activate, press, invoke InvokePattern
Focus set AXFocused=true Component.GrabFocus SetFocus
Blur set AXFocused=false (not directly supported) (not directly supported)
Toggle AXPress (on checkbox) toggle, check, uncheck TogglePattern
Expand AXShowMenu / AXPress expand, open ExpandCollapsePattern.Expand
Collapse AXCancel / AXPress collapse, close ExpandCollapsePattern.Collapse
Select AXPress select SelectionItemPattern
SetValue set AXValue attribute Value.SetCurrentValue (numeric) / EditableText.ReplaceText (text) RangeValuePattern / ValuePattern
TypeText set AXSelectedText EditableText.InsertText ValuePattern (splice)
SetTextSelection set AXSelectedTextRange Text.SetSelection TextPattern range ops
Increment AXIncrement increment (or Value +step) RangeValuePattern (+step)
Decrement AXDecrement decrement (or Value -step) RangeValuePattern (-step)
ShowMenu AXShowMenu menu, showmenu, popup ExpandCollapsePattern
ScrollIntoView (no-op, no AX equivalent) Component.ScrollTo ScrollItemPattern

Window verbs map differently again, because each platform’s window control API is separate from its element-action interface:

xa11y Action macOS Linux (AT-SPI) Windows (UIA)
Activate activate app (set AXFrontmost), restore if minimized, then AXRaise Component.GrabFocus (same path as Focus) restore if minimized, then SetForegroundWindow and SetFocus
Minimize set AXMinimized=true (unsupported) WindowPattern.SetWindowVisualState(Minimized)
Maximize press the window’s AXZoomButton (unsupported) WindowPattern.SetWindowVisualState(Maximized)
Restore clear AXMinimized; clear AXFullScreen (the zoom press’s state) (unsupported) WindowPattern.SetWindowVisualState(Normal)
Close press the AXCloseButton (unsupported) WindowPattern.Close
MoveTo set AXPosition (unsupported) TransformPattern.Move (physical pixel conversion)
ResizeTo set AXSize (unsupported) TransformPattern.Resize (physical pixel conversion)

Window state reads follow the same split. minimized, maximized, and fullscreen are Option<bool>: None means unknown, either because the element is not a window or because the platform cannot report the state. macOS reads minimized and fullscreen (AXMinimized / AXFullScreen) on window-like elements; there is no AX attribute for the zoom state, so maximized is always None on macOS. After a maximize, fullscreen reports the state instead. Windows reads minimized and maximized from WindowPattern.CurrentWindowVisualState but cannot report fullscreen; Linux reads minimized from the AT-SPI ICONIFIED bit and cannot report maximized or fullscreen. modal is WindowPattern.CurrentIsModal on Windows and AXModal on macOS.

macOS has no direct equivalent, so this action is a no-op. On Linux it uses Component.ScrollTo with top-edge alignment. On Windows it uses ScrollItemPattern.ScrollIntoView.

AXRaise on a window only re-raises the window within the window list of its own app, and it answers success even while the app sits in the background. An activation performed on a background app’s window visibly does nothing. Activate on macOS brings the app forward first (sets AXFrontmost on the application element), then raises the window. A failure to activate is a surfaceable error, never a silent success.

Maximize: the zoom button is the zoom surface

Section titled “Maximize: the zoom button is the zoom surface”

macOS exposes the window’s zoom through its zoom button (AXZoomButton, the documented convenience attribute that every window with a zoom button exposes), not through a zoom-state attribute: Maximize presses it, and the state the press leaves behind is AXFullScreen, which Restore clears. Windows that expose no AXZoomButton report maximize and restore as ActionNotSupported, never a silent no-op. maximized cannot be read back on macOS. The state is reported as fullscreen instead. There is no zoom-state AX attribute: AXZoomed matches the AppKit property NSWindow.isZoomed, is not declared in the SDK headers, and live windows answer kAXErrorAttributeUnsupported for it.

  • macOS sets the AXValue attribute directly, which works for both text and numeric values.
  • Linux has a Value D-Bus interface that only supports f64, so text values require EditableText.ReplaceText. If neither interface is available, the call returns TextValueNotSupported.
  • Windows tries RangeValuePattern for numeric values and falls back to ValuePattern.SetValue for text.

Text is inserted through the accessibility API. Keyboard events are never simulated.

  • macOS sets AXSelectedText, replacing the selection or inserting at the cursor.
  • Linux calls EditableText.InsertText at the current caret offset.
  • Windows reads the current value via ValuePattern. It then splices in the new text and calls SetValue.
  • macOS sets AXFocused to false on the element.
  • Linux and Windows have no direct API equivalent, so the action is unsupported.

All platforms report bounds as logical (device-independent) screen coordinates with origin at the top-left of the primary display, and Screenshot.scale carries the physical-to-logical ratio for mapping bounds onto captured pixels. Note:

  • macOS reports in points, its native logical unit. On Retina displays, 1 point = 2 physical pixels.
  • Windows reports device-independent pixels (DIPs), and scale is the display’s DPI scaling (e.g. 1.5 at 150%). On a mixed-DPI multi-monitor desktop each monitor keeps its physical origin in the logical space and only its extent is divided by that monitor’s scale, so monitor logical rectangles are non-overlapping. A scaled monitor left of or above the primary keeps its own origin, so a gap sits between its logical rectangle and the primary’s. A point in a gap resolves via the physical query, whose answer is approximate there. A window on a 200% secondary monitor at physical x = 2000 reports logical x = 1960, not 1000.
  • Linux reports logical pixels where the HiDPI scale is reliably detectable, and falls back to physical at scale 1.0 otherwise. See Coordinates and HiDPI for exactly when.
  • Multi-monitor setups can produce negative coordinates for displays positioned left of or above the primary.

Platforms use different attributes to determine an element’s name:

  • macOS uses AXTitle, then AXDescription, then AXValue (for text elements).
  • Linux uses Accessible.Name, then Description. For StaticText with no name, the first portion of the value is used.
  • Windows uses the CurrentName property.

UI Automation has no Application accessible. Processes surface only as their top-level windows, so xa11y-windows synthesizes one Application node per process (the unified shape every platform reports), with the process’s windows as its children. The node carries no bounds (UIA has no process geometry) and no window actions or window-state flags. Its handle is a tagged synthetic identifier and backs no live UIA element, so acting on the app node itself is refused with ActionNotSupported (the core role guard rejects it before any provider is reached, on every platform); enumerate the windows and act on a window child instead. The handle is not PID-encoded: the PID is the node’s pid field, and the handle itself is a per-run counter tagged as synthetic.

The node’s raw map records how it was built:

raw key Value
uia_synthesized true. The marker for a node that was synthesized and backs no live UIA element.
uia_name_source "process" (executable stem from OpenProcess + QueryFullProcessImageNameW), or "window_title" (the representative window’s title, used when the process cannot be opened)
uia_process_name full executable path, when the process could be queried

The representative window is the process’s first top-level window in z-order.

Windows subscriptions register their UIA handlers on every current top-level window of the process, and a desktop-scoped open/close watch attaches windows opened after subscribe (and drops closed ones, so a closed window stops producing state deltas). Events from sibling windows follow the same “the process” semantics as the main window.

The tri-state checked value (Off / On / Mixed) is derived differently:

  • macOS parses the AXValue attribute, where "0" = Off, "1" = On, "2" = Mixed.
  • Linux uses the AT-SPI state bits Checked (0x10) and Mixed (0x2000).
  • Windows reads TogglePattern.CurrentToggleState, where 0 = Off, 1 = On, 2 = Indeterminate.

Per-item selection (selected on rows, cells, list items) is read differently:

  • macOS uses the per-element AXSelected attribute when present. Some bridges (Qt’s among them) provide no per-element AXSelected and expose selection only through the container’s AXSelectedChildren. For table_cell / table_row / list_item elements whose AXSelected attribute is absent, xa11y resolves selected by membership in the nearest ancestor’s AXSelectedChildren (at most two hops: cell → row → table). Derived values appear in states.selected alone, and raw never gains a synthetic AXSelected key.
  • Linux uses the AT-SPI Selected state bit.
  • Windows uses SelectionItemPattern.CurrentIsSelected.

Header exposure varies by toolkit as well as platform. Windows surfaces header cells as HeaderItem elements and per-cell header relationships via the TableItem pattern. AT-SPI exposes column header / row header roles. AppKit tables expose a header group whose sort buttons carry the column titles. One known toolkit gap: Qt tables on macOS expose no header objects at all. Qt’s Cocoa bridge synthesizes AXRow/AXColumn children only and provides no AXHeader attribute, so column titles are absent from the AX tree and xa11y cannot surface them. This is an upstream Qt limitation. Use the platform-consistent table table_cell selectors for data, and avoid depending on header names for Qt apps on macOS.

xa11y supports both X11 and Wayland Linux sessions. Backends are selected at runtime, with no compile-time feature flag.

Capability X11 Wayland
Accessibility (AT-SPI2) D-Bus, no display-server dependency D-Bus, no display-server dependency
Input simulation XTest extension (no setup) /dev/uinput virtual evdev device (requires input group)
Screen capture GetImage on the root window PNG URI from org.freedesktop.portal.Screenshot

The selection rule:

  • DISPLAY set → X11 (regardless of WAYLAND_DISPLAY).
  • Otherwise → Wayland: uinput for input, screenshot portal for capture.
  • Screenshot also returns Unsupported if neither DISPLAY nor WAYLAND_DISPLAY is set.

Why uinput instead of libei + portal RemoteDesktop?

Section titled “Why uinput instead of libei + portal RemoteDesktop?”

org.freedesktop.portal.RemoteDesktop (libei) is the architecturally “correct” Wayland input-sim path and the future of structured input injection on Linux, but today it covers a strict subset of the ecosystem: only xdg-desktop-portal-gnome and xdg-desktop-portal-kde support it, and neither has a usable headless mode without a session manager (GDM / SDDM). uinput goes through the kernel and is compositor-agnostic, so it works on every Wayland desktop (GNOME, KDE Plasma, sway, Hyprland, Cosmic, weston) and on headless Linux servers, with the same code path. It is the same mechanism xdotool --using-uinput, ydotool, wtype, Steam Input, and Wine all use.

The trade-off is that uinput requires the user to be in the input group, which grants global input read/write, comparable to macOS Input Monitoring. The portal model is finer-grained (per-app consent) but isn’t broadly available yet. xa11y may add a libei backend later as an opt-in upgrade once the portal landscape stabilises.

The Screenshot portal usually auto-approves for non-interactive callers (interactive=false, modal=false); xa11y passes both. wlroots compositors via xdg-desktop-portal-wlr and GNOME via xdg-desktop-portal-gnome both support this.

Element.bounds and input Points are logical (device-independent) coordinates, matching the cross-platform contract, and Screenshot.scale reports the physical-to-logical ratio. The scale is detected best-effort and is exact where it can be read reliably:

  • Pure X11, integer scaling. Read from Xft.dpi in the X RESOURCE_MANAGER (the same signal GTK/Qt use). AT-SPI extents and XTest are physical, so bounds are divided and input points multiplied by it.
  • Single-output Wayland, integer or fractional. The exact ratio physical_mode / logical_size from xdg-output (e.g. 1920 / 1280 = 1.5), rather than the rounded wl_output.scale.

Every other configuration fails closed to 1.0, so the data is never wrong. There, bounds stay physical and the scale is not upscaled, but capture and input round-trips remain correct because bounds and pixels stay in the same space:

  • Fractional X11. GTK’s X11 window scale is integer-only, so a fractional Xft.dpi scales fonts, not the coordinate space.
  • Multi-monitor mixed-DPI Wayland. A single scalar can’t represent per-monitor scales, so it falls back to the largest integer wl_output.scale (the Windows backend has the same limitation).
  • XWayland (both DISPLAY and WAYLAND_DISPLAY set) runs capture through the X11 backend, so the reported scale follows the X11 source and stays 1.0, consistent with that backend.

Point arguments to input simulation, after the logical→physical conversion, are mapped onto the uinput virtual coordinate range. Override the assumed screen size (default 1920×1080) with XA11Y_SCREEN_WIDTH / XA11Y_SCREEN_HEIGHT env vars if your setup differs.

GTK 4 menu-button widgets (GtkMenuButton, AdwMenuButton, AdwSplitButton) present as an outer push button accessible that advertises NActions = 0 wrapping an inner toggle button that carries the real click action. Calling press() on the outer would normally raise ActionNotSupported. When the owning application identifies itself as GTK via Application.ToolkitName == "GTK", xa11y-linux instead walks a bounded slice of the outer’s subtree (BFS, depth 3, restricted to roles that carry a click action, name must match) and invokes the single matching descendant it finds. Scoping is strict. It only runs inside GTK apps, only when the widget’s own Action interface is empty, and only when exactly one candidate matches. Ambiguous subtrees still surface the original error.

Two WebKitGTK behaviors affect <table> content (verified against WebKitGTK 2.52; both are engine behaviors, not xa11y ones):

  • <th> cells can destabilize the whole page’s tree. With a window manager present, a table containing <th> header cells can send WebKit’s accessibility tree into continuous invalidation churn. Every content accessible goes defunct moments after being exposed, and the page effectively vanishes from AT-SPI. If a WebKitGTK-based app’s tree keeps coming back empty or unknown, check for this (xa11y marks dead objects with raw["atspi_defunct"]).
  • Layout-table heuristic. A <table> without headers or a <caption> is judged decorative and dropped from the accessibility tree entirely. Cell text is exposed through the AT-SPI Text interface (surfaced as the cell’s value) rather than as the accessible name, so use table_cell[value*="..."] selectors for WebKitGTK content.

On Linux, Electron and Chromium-based apps have their AT-SPI2 bridge disabled by default for performance. xa11y will connect to the app but its tree will contain only the top-level window, so App.by_name("…").locator("button").count() returns 0 even though buttons are visible on screen.

To expose the full tree, launch the app with --force-renderer-accessibility:

Terminal window
# VS Code
code --force-renderer-accessibility
# Cursor
cursor --force-renderer-accessibility
# Google Chrome / Chromium
google-chrome --force-renderer-accessibility

Representative node counts on Ubuntu 24.04 + GNOME 46 (Wayland):

App Without flag With flag
VS Code 1 140
Cursor 1 116
Chrome 1 210

Native GTK apps (Nautilus, gnome-terminal, GNOME Calculator, gnome-text-editor) don’t need the flag, because their AT-SPI2 bridge is enabled by default.

Firefox exposes its tree when launched with MOZ_ACCESSIBILITY_ATK2=1 set in the environment.

To diagnose whether a target app has AT-SPI2 enabled at all:

Terminal window
busctl --user tree org.a11y.atspi.Registry | grep -i "<app-name>"

If the app’s subtree is missing, the problem is the app’s accessibility configuration, not xa11y.