Skip to content

Extend Accessibility Features#6295

Open
natefinch wants to merge 16 commits into
fyne-io:developfrom
natefinch:nf/accessibility
Open

Extend Accessibility Features#6295
natefinch wants to merge 16 commits into
fyne-io:developfrom
natefinch:nf/accessibility

Conversation

@natefinch

@natefinch natefinch commented May 6, 2026

Copy link
Copy Markdown

Description

This PR adds a complete, opt-in accessibility implementation for Fyne (issue #1285), exposed under the accessibility build tag. It builds on the scaffolding already on develop and turns it into a usable surface for assistive technologies (VoiceOver, NVDA, Narrator, TalkBack, VoiceOver iOS) and for tooling that drives apps via the OS accessibility APIs.

The change is organised into ten focused commits so each layer can be reviewed independently. The work was driven by the need to let computer-use / RPA-style tooling read and manipulate the UI through real platform AT APIs rather than synthetic input — so the macOS bridge in particular implements the full action/value/state surface end-to-end, while Windows / iOS / Android currently get the role mapping with action wiring marked as follow-up.

Why these specific design choices

  • One opt-in interface per concern rather than a single fat interface, so widgets only pay for what they expose. A widget that just needs a label and a role only implements Accessible; richer ones layer on AccessibleValue, AccessibleActions, AccessibleStates, etc.
  • Cross-platform walker shared in internal/driver/common so all four platform drivers see the same tree, and a renderer-aware AccessibleChildren so collection widgets can expose only their visible items without forcing every container to implement the interface.
  • macOS cgo.Handle lifetime owned by Obj-C -dealloc, with a destroy-callback handed to the C bridge. This is the only safe way to let AppKit retain elements past a Go-side rebuild without dangling handles.
  • No synthetic AXWindow wrapper — AppKit's NSWindow already is the AX window; inserting another would have broken focus/Z-order semantics.
  • Tab role uses radio-button + tab subrole and Heading uses static-text + role-description "heading", matching the NSAccessibility role catalogue; there is no native heading role.
  • Slider snaps to step in AccessibilitySetValue so VoiceOver value adjustments behave the same as keyboard interaction.

Fixes #1285.


What changed, commit by commit

All commit links target this PR's commit view.

1. Add accessibility roles, actions, states and optional interfaces

Defines the public API in accessibility.go:

2. Walk into AccessibleChildren and stop dropping non-accessible subtrees

Adds common.AccessibilityChildren(): the single place that decides "what are this object's accessible children?" — preferring AccessibleChildren when implemented, falling back to *fyne.Container.Objects, otherwise returning nil. All four platform walkers (accessibility_darwin.go, accessibility_windows.go, accessibility_ios.go, accessibility_android.go) now go through this helper so they cannot drift apart, and so a non-Accessible wrapper (e.g. a styled rectangle) no longer hides the accessible widgets nested inside it.

3. Expose accessibility on tabbed and split containers

Adds Accessibility* methods to AppTabs, DocTabs, Split and the internal Scroll. Tabs report role TabList with each header reporting role Tab plus the Selected state; Split reports Container with both panes as accessible children.

4. Expose accessibility on form and input widgets

Wires up the full action/value/state surface for Button, Hyperlink, Check, RadioGroup / radioItem, CheckGroup, Slider, Entry, Select, ProgressBar, ProgressBarInfinite, and Form. Highlights:

5. Expose accessibility on collection widgets

List, GridWrap, Tree, and Table each gain an AccessibilityChildren that returns only their currently visible items, by reaching through the cached renderer (cache.CachedRenderer) to find the live listItem / gridWrapItem / treeNode / table cell widgets. Per-item AX is implemented on those internal types — for example:

This avoids materialising AX nodes for thousands of off-screen rows in a long list while still letting AT clients tab through every visible item.

6. Expose accessibility on remaining standard widgets

Smaller widgets get straightforward implementations: Card (Container, label = title or subtitle), Toolbar (Container, children = each ToolbarObject() excluding spacers), Accordion (List, children = headers + open Detail), Icon / FileIcon (Image), Separator, Activity (ProgressBar, value="active"/"idle"), RichText (Text, label = String()), TextGrid, Menu, menuItem (Button + Disabled/Checked, Press → Tapped), PopUp.

7. Wire accessibility actions, states, and roles on macOS

This is the biggest commit and the one that makes the rest visible to assistive tech on macOS. Touches the three accessibility_darwin.{go,h,m} files plus a new test.

  • All 19 roles mapped in -accessibilityRole, with Tab subrole and Heading role description implemented separately to match NSAccessibility conventions.
  • All 6 actions wired through accessibilityPerformPress / Increment / Decrement / ShowMenu, all returning BOOL based on the Go callback's int result.
  • -setAccessibilityValue: at accessibility_darwin.m#L216 only mutates state when the Go side accepts the new value, then posts NSAccessibilityValueChangedNotification.
  • State + action bitmasks: Go computes them in stateMaskFor / actionMaskFor, Obj-C consumes them in AccessibilityElementSetStates / AccessibilityElementSetSupportedActions. This avoids 13 separate setters and keeps the C surface tiny.
  • cgo.Handle lifetime owned by Obj-C -dealloc: Go creates a handle for each widget, passes it through a void* context, and the C destroyTrampoline calls back into Go to delete the handle when the Obj-C element is finally released. This is essential because AppKit can keep elements retained past a Go-side rebuild.
  • Live notifications: NSAccessibilityValueChangedNotification on value updates, NSAccessibilitySelectedChildrenChangedNotification on selection state changes, NSAccessibilityRowExpandedNotification on expand/collapse, NSAccessibilityLayoutChangedNotification on child add/remove, and NSAccessibilityFocusedUIElementChangedNotification on focus.
  • -isAccessibilityElement keeps the existing Group-with-children behaviour but deliberately does not hide List/Tree/Table — those need to remain elements so AT can navigate into them.
  • Test coverage in the new accessibility_darwin_test.go: role-distinctness, mask round-trips, state/action bitmask uniqueness, and actionFromC round-trips.

Also fixes a pre-existing nil-icon panic in Button.AccessibilityLabel that surfaced once the bridge started walking every Accessible widget.

8. Expand Windows UIA role coverage to all accessibility roles

Maps all 19 Fyne roles to the closest UIA control type in roleToUIA — CheckBox, RadioButton, Slider, ProgressBar, Edit, List/ListItem, Tree/TreeItem, DataGrid, Tab/TabItem, Image, Header, Separator, etc. NVDA / Narrator now report the right control type for each widget. Action and value patterns (IInvokeProvider, IValueProvider, …) are deliberately deferred to a follow-up PR.

9. Expand iOS and Android accessibility role coverage

iOS gets the full role enum forwarded to Obj-C, with UIAccessibilityTraits mapped for Button/Checkbox/Radio/Tab (button), Heading (header), Image, Link, ProgressBar (updatesFrequently), Slider (adjustable), and StaticText. Android forwards the full role integer to the Java accessibility delegate so the Kotlin side can attach the appropriate setClassName mapping. Action/value pattern wiring on these two platforms is deferred.

10. Document accessibility support in CHANGELOG

Adds a 2.8.0 — Unreleased entry summarising the new public surface and per-platform coverage.


How to try it

go build -tags accessibility ./...
go test  -tags accessibility ./...

On macOS, run any sample app under VoiceOver or use the macOS Accessibility Inspector — buttons, tabs, sliders, lists, trees and form inputs all surface with their semantics, can be activated, and announce value changes live.


Tests

  • New tests:
    • accessibility_test.go (public-API round-trips)
    • internal/driver/common/accessibility_test.go (walker semantics)
    • widget/accessibility_test.go (~20+ widget AX tests covering Button / Hyperlink / Check / Entry / Select / Slider / ProgressBar* / RadioGroup / CheckGroup / Form / List / GridWrap / Tree / Table / Card / Toolbar / Accordion / Icon / Separator / Activity / RichText / TextGrid)
    • widget/radio_item_test.go (radio item Accessibility)
    • internal/driver/glfw/accessibility_darwin_test.go (role/state/action bitmask mapping)
    • container tests touched in apptabs_internal_test.go, doctabs_internal_test.go, split_test.go, internal/widget/scroller_test.go.
  • All accessibility tests pass with go test -tags accessibility ./....

Checklist

  • Tests included.
  • Lint and formatter run with no errors (goimports, go vet).
  • Tests all pass.

Where applicable

  • Public APIs match existing style and have Since: 2.8 line.
  • Any breaking changes have a deprecation path or have been discussed. No breaking changes — everything is additive and gated behind the accessibility build tag for the native bridges.
  • Check for binary size increases when importing new modules. No new imports outside runtime/cgo (already used in the toolchain) and internal/cache for collection widgets.

Follow-ups (not in this PR)

  • Implement UIA pattern providers (IInvokeProvider, IValueProvider, IRangeValueProvider, IToggleProvider, ISelectionItemProvider, IExpandCollapseProvider) on Windows.
  • Implement custom actions and adjustable-value setter on iOS.
  • Implement virtual click delegation and custom actions in the Android AccessibilityNodeProvider.

Copilot AI and others added 10 commits May 6, 2026 16:09
Expand the accessibility public API to support the breadth of widgets
the Fyne toolkit ships:

- Add 15 new AccessibleRole constants (Checkbox, TextField, Tab, TabList,
  List, ListItem, Tree, TreeItem, Slider, ProgressBar, Image, Heading,
  Separator, Radio, Table) alongside the existing Button/Container/
  Link/Text.
- Introduce typed AccessibleAction (press, increment, decrement, select,
  showMenu, setValue) and AccessibleState (checked, disabled, expanded,
  focused, invalid, required, selected) string constants.
- Add five optional interfaces (AccessibleChildren, AccessibleValue,
  AccessibleValueSetter, AccessibleActions, AccessibleStates) so widgets
  can opt into richer behaviour without breaking existing implementors
  of Accessible.

All new symbols are tagged Since: 2.8 to match the existing scaffold.
The accompanying tests pin the constant values and verify a stub
implementor satisfies every interface.
The accessibility walker on every platform only recurses through
*fyne.Container.Objects today, which means widgets that compose their
children outside of a Container (tabbed containers, trees, lists,
forms) are invisible to assistive technologies even when their
children would otherwise be reachable.

Introduce a shared common.AccessibilityChildren helper that returns
the canvas objects to descend into, preferring fyne.AccessibleChildren
when the widget implements it and falling back to *fyne.Container
otherwise. The four platform walkers (darwin/windows/ios/android) all
delegate child enumeration to this helper.

While here, fix a Darwin walker bug that pruned the entire subtree
when the current object did not itself implement fyne.Accessible:
non-accessible wrapper objects (decorative containers, custom
layouts) are now traversed transparently.
AppTabs and DocTabs now implement AccessibleChildren by returning their
renderer's objects so the tab bar (and through it each tab button) and
the active tab's content are reachable from the accessibility walker.
The internal tabButton widget reports the AccessibleRoleTab role,
labels itself with its tab text (or icon name when only an icon is set),
exposes Disabled and Selected states, and handles AccessibleActionPress
and AccessibleActionSelect to switch tabs from assistive technologies.

Split exposes itself as an AccessibleRoleContainer with the leading and
trailing children, skipping nil siblings.

Scroll exposes itself as an AccessibleRoleContainer with its wrapped
Content as the sole child so the walker can keep descending into the
scrollable subtree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Button gains AccessibilityStates (Disabled), AccessibilityActions
(Press), and AccessibilityPerformAction so assistive technologies can
describe a button's enabled state and trigger its tap handler. Hyperlink
gains the same press action so links can be followed without a pointer.

Check, the radio item used inside RadioGroup, and Slider all expose
their role, label, supported actions, and per-state flags
(Checked/Selected/Disabled/Focused/Required/Invalid where applicable)
and execute Press/Select/Increment/Decrement/SetValue actions through
their existing handlers. Slider additionally implements
AccessibleValueSetter so AT clients can write a numeric value directly.

Entry exposes its placeholder as the accessibility label, its text as
the accessibility value, and accepts AccessibilitySetValue to drive
typing from AT. State flags reflect Disabled/Focused as well as
Required (when a Validator is set) and Invalid (when validation has
failed).

Select reports as a button whose value is the currently selected
option, with Press and ShowMenu actions both opening the option list.
ProgressBar reports its percentage (or TextFormatter result) as the
value, while ProgressBarInfinite reports "indeterminate".

RadioGroup, CheckGroup, and Form expose AccessibleChildren so the AX
walker can descend into their rendered items and form rows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
List, GridWrap, Tree, and Table each implement AccessibleChildren so
the accessibility walker can descend into the rows or cells that are
currently rendered, rather than treating these virtualized widgets as
opaque leaves. Each container reports its appropriate role
(List/List/Tree/Table) and an empty default label that callers can
override on the embedding BaseWidget.

Visible list and grid items expose role ListItem, defer their label to
the wrapped child when it implements fyne.Accessible, report the
Selected state, and accept Press and Select actions through their
existing onTapped handler. Tree nodes expose role TreeItem with the
Expanded state for open branches and the Selected state for the
current selection; Press toggles a branch and selects a leaf, while
Select always selects the node.

Table exposes the visible cell content (including header rows and
columns) as accessible children so screen readers can read across the
rendered viewport.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Card, Toolbar, Accordion, Menu, and PopUp implement AccessibleChildren
so the accessibility walker can descend through their content. Card
labels itself with its title (or subtitle if no title is set). Toolbar
filters out the layout spacer items but exposes actions and separators.
Accordion exposes section header buttons and, when a section is open,
its detail content. Menu items expose role Button, the disabled and
checked states, and a press action that triggers their existing tap
handler.

Icon and FileIcon report role Image with a label derived from the
resource or URI name respectively. Separator reports role Separator
with no label. Activity reports role ProgressBar with an
"active"/"idle" value reflecting its animation state. RichText and
TextGrid report role Text with the plain-text content as their label
so screen readers can announce them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Expand the macOS NSAccessibility bridge to cover the full Fyne
accessibility surface introduced in earlier commits: 19 roles, six
actions (press/increment/decrement/showMenu/select/setValue), and
the seven accessibility states.

Cgo Handles are used to round-trip widgets through Obj-C; the handle is
freed in -dealloc via a small destroy callback so AppKit can outlive a
Go-side rebuild without dangling. Notifications (value changed,
selected children changed, layout changed, focused element changed)
are posted on dynamic updates so AT clients pick up changes live.

Tab uses the radio-button role with a tab subrole; Heading is exposed
as static text with a 'heading' role description, matching the
NSAccessibility role catalogue. List/Tree/Table remain accessibility
elements so screen readers can navigate into them — only Group/Container
nodes with children continue to delegate to their descendants.

Also fixes a nil-icon panic in widget.Button.AccessibilityLabel that
surfaced once the bridge started exercising every walked widget.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Map all 19 Fyne AccessibleRole values to the closest UIA control type
(CheckBox, RadioButton, Slider, ProgressBar, Edit, List/ListItem,
Tree/TreeItem, DataGrid, Tab/TabItem, Image, Header, Separator) so
NVDA and other UIA clients see meaningful semantics. Action and value
patterns remain to be wired up in a follow-up commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Map the full Fyne AccessibleRole set through to the iOS and Android
bridges. iOS surfaces traits for Button/Checkbox/Radio/Tab (button),
Heading (header), Image, Link, Slider (adjustable), ProgressBar
(updatesFrequently), and StaticText. Android forwards the full role
integer to the Java accessibility delegate so the Kotlin side can
attach the appropriate setClassName mapping. Action and value
patterns remain to be wired up in a follow-up commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@natefinch natefinch changed the base branch from master to develop May 6, 2026 21:16
@natefinch

natefinch commented May 6, 2026

Copy link
Copy Markdown
Author

oy, I think this got munged.. will try to repair the diff later.
Ahh, ok, it was just that it was made against main, now that it's against develop it looks correct.

natefinch and others added 3 commits May 6, 2026 22:08
macOS bridge — fix reference-counting and cleanup of AccessibleElement:

- Remove the extra [elem retain] in AccessibilityElementCreate; the
  +1 from alloc/init is already the create-ownership reference.
  Previously every element was over-retained by one.

- In AccessibilityElementAddChild, release the child after adding it
  to the parent children array (which retains it). This transfers
  create-ownership to the parent so the child will dealloc when the
  parent releases it.

- In AccessibilityElementRemoveChild, nil the parentElement pointer
  before removing from the children array so the child does not
  reference a stale parent during its potential dealloc.

- In AccessibilityElementDestroy, remove the element from
  globalAccessibilityElements so stale roots do not remain visible to
  VoiceOver after a rebuild. If the element is still parented under
  another AccessibleElement, detach it from that parent (which
  triggers the final release via the children array).

- Autorelease the NSArray returned by -accessibilityChildren and
  customAccessibilityChildren; [copy] returns a +1 object that was
  previously leaked on every query.

- Use [[NSMutableArray alloc] init] instead of [NSMutableArray array]
  for globalAccessibilityElements so the global array is properly
  owned (not autoreleased out from under us).

Android bridge — sync Java role constants with Go enum:

The Go side (accessibility_android.go) sends role integers 0–18
matching the androidRole* iota, but the Java side only understood the
old four-value set (Button=1, Text=2, Link=3, Container=4). This
caused most roles to be misreported to TalkBack — e.g. Checkbox (2)
was announced as "Text", Heading (3) as "Link".

Update GoNativeActivity.java to:
- Define all 19 role constants matching the Go iota (0=Container
  through 18=TreeItem).
- Map each role to the closest Android widget className and
  appropriate AccessibilityNodeInfo properties (checkable, clickable,
  heading, etc.).

Doc fix — clarify AccessibleChildren fallback behaviour:

The AccessibleChildren doc comment previously said "the walker will
fall back to recursing through any Container.Objects" when nil is
returned. In reality the fallback to Container.Objects only applies
to objects that do not implement AccessibleChildren at all. Reword
to match the actual behaviour of common.AccessibilityChildren().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The macOS accessibility bridge had a critical bug that made Fyne windows
invisible to System Events (AppleScript) and other external accessibility
clients. The root cause was that AccessibilityAttachToWindow used
method_setImplementation to override accessibilityChildren on the
GLFWContentView class, but this actually modified the method on the parent
class (NSView/NSResponder), breaking accessibilityChildren for ALL views —
including NSWindow — which caused the window to disappear from the
accessibility hierarchy entirely.

Fixes in accessibility_darwin.m:

- Replace method_setImplementation with class_addMethod for the content
  view's accessibilityChildren override, ensuring only GLFWContentView is
  affected without modifying parent classes

- Add class_addMethod overrides on GLFWContentView to fix its default
  AXUnknown role (now AXGroup), isAccessibilityElement (now YES), and
  accessibilityIsIgnored (now NO)

- Add class_addMethod override on GLFWWindow for accessibilityChildren so
  the window exposes its content view to the accessibility tree

- Add accessibilityWindow and accessibilityTopLevelUIElement to
  AccessibleElement so assistive tech can navigate back to the window

- Add legacy API support (accessibilityIsIgnored,
  accessibilityAttributeNames, accessibilityAttributeValue:) to
  AccessibleElement, since external AX clients like System Events and
  VoiceOver query via the deprecated legacy path rather than the modern
  NSAccessibility protocol

New documentation:

- ACCESSIBILITY.md: Comprehensive guide covering the public API, widget
  implementation patterns, platform bridge architecture, macOS debugging
  techniques, known pitfalls, and file reference

- .agents/skills/macos-accessibility/: AI agent skill for diagnosing and
  fixing macOS accessibility issues in GLFW/Fyne apps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document that Xcode's Accessibility Inspector requires a .app bundle
and provide a minimal bundle creation example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@natefinch natefinch force-pushed the nf/accessibility branch from 823345b to ab6963e Compare May 7, 2026 20:06
@coveralls

coveralls commented May 7, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 60.45% (+0.3%) from 60.171% — natefinch:nf/accessibility into fyne-io:develop

Comment thread internal/driver/common/accessibility_test.go
Comment thread internal/driver/glfw/accessibility_darwin_test.go
Co-authored-by: Nate Finch <natefinch@github.com>
@natefinch

Copy link
Copy Markdown
Author

My main impetus for this was to enable computer use with LLMs. In working on this it eventually became clear that there are some interesting requirements for at least some computer use systems on MacOS - the binary has to be bundled into a .app file, or the computer use code can't access it.

I added the LLM's accessibility research as a document in th PR, because that can help future devs continuing the work.

Most of the additions are pretty simple, especially for non-Mac OSes, since I didn't have a way to test those (though now that I think of it, I can grab a Linux machine to test on).

For MacOS, the changes are more extensive, since I can test thoroughly there. I was able to test not only via copilot-cli's computer-use mcp but also using XCode's accessibility testing tool (both require building into a .app to function). The accessibility tool shows the correct name and type etc.. I'll get some screenshots tonight.

@andydotxyz

Copy link
Copy Markdown
Member

The accessibility tool shows the correct name and type etc.. I'll get some screenshots tonight.

Thanks for all the hard work here, were you still planning to share the screenshots?

Also, please remove the CHANGELOG updates - we create that during the release process.

@andydotxyz

Copy link
Copy Markdown
Member

@natefinch are you here to help us get this over the line? There are some CI failures and a couple of small requests. Should not take long but we can't land it as-is.

@natefinch

Copy link
Copy Markdown
Author

Yep sorry... I'll get screenshots hopefully tonight. I don't want this to rot.

natefinch and others added 2 commits June 10, 2026 14:17
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@natefinch

natefinch commented Jun 12, 2026

Copy link
Copy Markdown
Author

Went to get screenshots last night and my app kept hard-crashing when the accessibility inspector examined it, which is obviously not a good sign. This wasn't happening the last time I was working on this. I'll try to figure out what's going on and fix it.

@andydotxyz

Copy link
Copy Markdown
Member

Went to get screenshots last night and my app kept hard-crashing when the accessibility inspector examined it, which is obviously not a good sign. This wasn't happening the last time I was working on this. I'll try to figure out what's going on and fix it.

Any luck? We're a day or so away from code freeze and this is at risk of missing :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Supporting accessibility technologies

4 participants