Accessibility Overrides Reference
Teksilo widgets declare their own a11y info via Widget::accessibility(&self, builder: &mut AccessNodeBuilder): Button emits Role::Button + label, Slider emits Role::Slider + numeric range, a Panel that is a11y_presentational publishes a bare Role::GenericContainer the adapters drop, etc. Use builder-level overrides for icon-only labels, composite reading order, live regions, and custom actions.
The override layer is a one-method-per-concern surface (.access_label, .access_role, .access_merge_subtree, …) on WidgetBuilder and WidgetWithHandlers, analogous to SwiftUI's .accessibility* modifiers and Flutter's Semantics(...). App authors annotate widgets from the outside without touching widget internals.
#![allow(unused)] fn main() { Button::new(tr!(save_icon())) .icon(IconWidget::from_svg_icon(&save_icon), IconLocation::IconOnly) .access_label(tr!(save())) // icon-only button needs a name for AT .access_shortcut_id("app.save") // tracks user rebinds via ShortcutRegistry .access_action(Action::ShowContextMenu, |ctx| ctx.send_intent(AppIntent::Menu)); }
Mental model in one line:
HandlerSet (carries) → WidgetNode (owns) → tree walker (applies) → accesskit::Node
No shipped example exercises this surface end to end; the in-crate tests (see Testing patterns) are the executable reference.
Where overrides live
The override surface piggy-backs on the existing handler-extraction plumbing, the same path that already mirrors cursor, clips_children, focus_within_signal from HandlerSet onto WidgetNode.
- Builder chain,
Widget::new(...).access_label(...).access_role(...)each return aWidgetWithHandlers<W>whoseHandlerSetcarries anOption<Box<AccessibilityOverrides>>. The firstaccess_*call lazily allocates the box; subsequent calls extend it. (The subtree mode is the one exception:access_subtree/access_exclude_subtree/access_merge_subtreeset a separateHandlerSet::access_subtreefield, mirrored ontoWidgetNode::access_subtree.) - Insertion,
WidgetTree::add(...)callstake_handler_set()on the wrapper andapply_handler_set(crates/teksilo-core/src/arena.rs) mirrors the box onto the persistentWidgetNode::access_overridesfield, merging it into any block the node already carries (later scalars win, lists append, twocustomizeclosures chain) rather than replacing it. After this point, the wrapper has no override state, the source of truth is on the node. - AT tree build, when the framework calls
WidgetTree::sync_accessibility(), the walker (build_accessibility_recursivein crates/teksilo-core/src/widget_tree/accessibility_emit_impl.rs) runsnode.widget.accessibility(builder)first (so the inner widget emits its defaults), then callsnode.access_overrides.apply(builder)to layer the overrides on top.
Subsequent sync_accessibility() calls re-run the walker if the AT cache is dirty, which also dirties on ShortcutRegistry::version() bumps so access_shortcut_id tracks rebinds (see Shortcuts below), and on a locale switch so tr!(...) overrides re-resolve.
Method reference
Naming: .access_* prefix throughout. Three tiers by frequency of use.
Tier 1: labeling and state
| Method | Sets | Notes |
|---|---|---|
.access_label(s) | Node::label | What screen readers announce. Replaces widget-emitted name. |
.access_label(lit!(s)) | same | Explicitly untranslated string, lit! is the grep marker (convention; see Internationalization). |
.access_description(s) | Node::description | Long-form context. |
.access_description(lit!(s)) | same | Explicitly untranslated string, lit! is the grep marker (convention; see Internationalization). |
.access_hint(s) | Node::description | Alias for access_description (SwiftUI parity, AccessKit has no separate hint slot). |
.access_hint(lit!(s)) | same | Explicitly untranslated string, lit! is the grep marker (convention; see Internationalization). |
.access_value(s) | Node::value | Current value (sliders, spin boxes, text input). |
.access_value(lit!(s)) | same | Explicitly untranslated string, lit! is the grep marker (convention; see Internationalization). |
.access_role(role) | Node::role | Replace widget-emitted role. |
.access_hidden(b) | Node::hidden flag | impl Into<Prop<bool>>: a bool or a Signal<bool> (bound at AccessibilityOnly, so the node appears / disappears reactively). true hides the node and its whole subtree from AT (to drop only the node's own role and keep its content, use access_role(Role::GenericContainer)); false un-hides (clears even widget-emitted hidden on this node, but cannot reveal a node under a hidden ancestor). |
.access_disabled(bool) | Node::disabled flag | true marks disabled, false clears even arena-driven disabled. A plain bool, unlike access_hidden, not reactive. |
Tier 2: relationships, live regions, identity
| Method | Sets | Notes |
|---|---|---|
.access_identifier(s) | Node::author_id | Stable test/debug id (like data-testid). Not user-visible. |
.access_controls(target_id) | Node::controls | Append. ARIA aria-controls. |
.access_described_by(target_id) | Node::described_by, and Node::description | Append. ARIA aria-describedby. No AccessKit 0.25 adapter reads the relation, so the tree also writes the target's text into the node's description, which every platform does read; see A description from described_by. |
.access_labelled_by(target_id) | Node::labelled_by | Append. |
.access_live(mode) | Node::live | Politeness for status regions (Polite, Assertive). Inherited by every descendant that sets none, and the adapters announce each named one; Off stops it (see Hidden is for decoration, never for a wrapper). |
.access_current(c) | Node::aria_current | Mark this as the current item in its container (aria-current). |
.access_has_popup(kind) | Node::has_popup | Disclosure flag, Menu, Listbox, Dialog, … |
.access_orientation(o) | Node::orientation | Sliders, scrollbars, separators. |
Naming a container from its visible title
A container that copies its title into its own name says the same string
twice: once as the container's name, once as the label under it. Point at
the label instead, accesskit_consumer builds the container's name by
concatenating its labelled_by targets' values, so the title is announced
once and stays a label a reader can still find, review by character, and
route a braille cell to.
#![allow(unused)] fn main() { let title = ctx.add(TextWidget::new(tr!(unsaved_changes()))); let panel = ctx.add(VStack::new().child(title).child(body)); ctx.access_labelled_by(panel, title); // BuildContext's post-mount form of `.access_labelled_by(..)` }
Two rules the framework enforces, and one it cannot:
- A container named through the relation must not also set a name. The consumer prefers a node's own label, so setting both silently drops the relation and announces a copy that no longer tracks the title.
- A relation target absent from the emitted tree, a dormant tab panel, a pruned stack, used to panic the consumer's relation walk. The walker now strips such a target; the relation simply goes quiet.
- Live regions are the exception.
Banner,ToastandMessageBoxkeep their ownset_nameand hide their title label instead. The adapters do followlabelled_byfor a live node's name, so such a region is announced by its title as it appears; but they check a node's name only when that node's own data changed, so a new title on the label it points at announces nothing.
A composite whose content carries the title implements
Widget::accessible_title_node
and the shell wires the relation for it, that is how Dialog and
InputDialog are named. It supersedes accessible_title_hint, which
returns a copy of the string; the hint remains for content that has no
label node to point at.
A composite that publishes itself through the field that holds focus
Some composites keep everything a screen reader reads on one inner widget.
A SpinBox's editing field holds the focus, the caret, the text and the
value, so the field is the node focus lands on, and the one an assistive
technology announces. It therefore carries the spin box's role, name and
numeric range, and the composite's own node is structure
(Role::GenericContainer), which the presentational pass collapses.
An application, though, only ever has the composite's id: FormLayout
names it with labelled_by, an app calls .access_label(..),
.access_described_by(..) or .tooltip(..) on it. A composite in this
shape returns the inner widget from
Widget::accessibility_proxy,
and the walker then:
- applies every override attached to the composite to the proxy's node, after the proxy's own, so what the application attached still wins; a chain of composites applies innermost first, so the outermost wins;
- keeps none of them on the composite's own node;
- gives the proxy the tooltip the composite owns, which would otherwise fall to the chrome it is anchored on;
- points every
controls/described_by/labelled_bytarget naming the composite at the proxy; - offers the context menu the composite owns on the proxy's node.
The text surfaces are the other shape: RichTextEditor, CodeEditor,
PlainTextEditor and LogView take focus and the keys on the composite
itself, and keep their text on a body that is not focusable. A screen reader
reads the node an update names as focus when it arrives, and the Linux
adapter reports caret and selection moves only for that node, so while a
composite with a proxy holds keyboard focus, the update names the proxy as
the focus. An assistive Focus on a proxy that is not focusable itself puts
the keyboard on the composite, where its focus handlers run.
accessibility_node, find_by_label and find_by_role answer the same
way the walk emits. Only a live, strict descendant counts, and only while
the walk reaches it: the composite and every widget between the two walked
normally (Inherit). Otherwise the overrides stay where they were attached.
Tier 3: subtree modes, numeric, actions, escape hatch
| Method | Effect |
|---|---|
.access_exclude_subtree() | Prune all descendants from the AT tree. Parent still emitted. |
.access_merge_subtree() | Lift descendant labels / values / actions into parent, prune descendants. |
.access_subtree(mode) | Set explicit AccessSubtreeMode::{Inherit, Exclude, Merge}. |
.access_numeric_value(v) | Node::numeric_value. |
.access_numeric_range(min, max) | Node::min_numeric_value + max_numeric_value. |
.access_numeric_step(s) | Node::numeric_value_step. |
.access_action(action, handler) | Advertise an AT action AND register a callback. |
.access_remove_action(action) | Suppress an action the widget emitted. |
.access_custom_action(label, handler) | SwiftUI accessibilityAction(named:), appears in VoiceOver's Actions rotor. Also advertises Action::CustomAction, without which adapters don't expose the list. |
.access_custom_action(lit!(label), handler) | Explicitly untranslated label (lit! grep marker). |
.access_shortcut_literal(s) | Pre-formatted chord string ("Ctrl+S"). |
.access_shortcut_id(id) | Bind to a registered Shortcut id; tracks user rebinds. |
.access_customize(|builder| ...) | Final escape hatch, runs last, full &mut AccessNodeBuilder access. |
Subtree modes
By default the AT tree mirrors the widget tree one-to-one, every widget emits one AT node, descendants are visible to AT. access_subtree controls how the walker handles descendants of the annotated node.
Inherit (default)
Normal walk. Descendants emit their own nodes. Used implicitly everywhere.
Exclude: access_exclude_subtree()
Keep the parent in the AT tree, prune all descendants. Equivalent to Flutter's excludeSemantics: true.
#![allow(unused)] fn main() { HStack::new() .child(IconWidget::from_svg_icon(logo_icon)) .child(TextWidget::new(lit!("Teksilo"))) .child(TextWidget::new(lit!("Pure-Rust GUI"))) .access_label(lit!("Teksilo logo")) .access_exclude_subtree(); }
Without access_exclude_subtree, a screen reader would walk all three children individually: "graphic", "Teksilo", "Pure-Rust GUI". With it, AT sees one node named "Teksilo logo".
Use for purely decorative composites: animated logos, icon clusters, splash content.
Merge: access_merge_subtree()
Keep the parent, but lift descendants' a11y info into the parent before pruning. The whole composite reads as one AT element. Equivalent to Flutter's mergeAllDescendants: true and SwiftUI's .accessibilityElement(children: .combine).
#![allow(unused)] fn main() { Card::new() .header(TextWidget::new(lit!("New message"))) .content( VStack::new() .child(TextWidget::new(lit!("From Alice"))) .child(TextWidget::new(lit!("Hey, are we still on for…"))), ) .access_merge_subtree(); }
VoiceOver announces the card as one element: "New message From Alice Hey, are we still on for…" (names are joined with a single space). Screen-reader navigation stops collapse, so an AT user moves card-by-card instead of line-by-line within the card. Keyboard Tab order is not affected, merge prunes the AT tree only, so a focusable descendant is still a Tab stop, and the AT focus resolves to the merged parent while it holds focus.
Merge accumulator rules:
| Source | Merged into parent | Rule |
|---|---|---|
descendant name | parent name | Append with single space; existing parent name kept first if any. |
descendant value | parent value | First non-empty wins, and only if the parent has no value of its own. |
| descendant supported actions | parent action set | Union, deduplicated. |
descendant role | , | Discarded. Parent's role wins. |
descendant numeric_value / range / step | , | Discarded. |
descendant hidden / disabled | , | Discarded, except that a hidden descendant (access_hidden(true) or widget-emitted set_hidden()) contributes nothing at all to the merge. Parent's state governs the merged element. |
descendant description / controls / described_by / labelled_by | , | Currently dropped (no AccessNodeBuilder getters); use access_customize on the parent if you need them. |
A hidden descendant keeps its whole subtree out of the merge, not only its own name: the adapters treat everything under a hidden node as hidden, so text merged from there would be read aloud on the merged node after the widget hid it.
Nested subtree modes:
MergecontainingExcludesomewhere, Exclude wins for that subtree (descendants of the excluded node contribute nothing to the merge).MergecontainingMerge, the inner merge runs first into a temp builder, the outer merge then absorbs the inner's already-merged label as one element.Excludecontaining anything, outer Exclude prunes everything; inner modes never run.
What merge can't reach. Widgets that don't expose their internals to the arena (e.g. a hand-rolled paint()-only widget that draws its own icon + label without inserting child WidgetNodes) have no descendants for the merge walker to find. Those widgets' authors should set accessibility() correctly internally; consumers can still use .access_label(...) to override the parent. This is an inherent property of the arena-based tree, not a deferred feature.
Automatic presentational collapse (no opt-in)
Layout primitives (HStack, VStack, ZStack, Center, Grid, Wrap, Padding, Expand, FixedSize, …) emit empty Role::GenericContainer / Role::Unknown AT nodes purely to carry visual structure. VoiceOver announces a bare GenericContainer as "group", so a composing control whose chrome is built from these primitives (a Button, a Checkbox, …) would otherwise read as "Save, button, group". To prevent that, the walker runs a final pass that collapses semantically-empty container nodes and promotes their children to the parent, the same "ignored / presentational node" pruning browsers do. Chains of nested empty containers collapse in one pass, so a Button → Padding → Center → HStack → (hidden label) subtree becomes a single childless Role::Button leaf.
This is automatic and requires no annotation. A node is collapsed only when, after the framework's structural additions (children, bounds, arena-driven disabled) are set aside, it is a bare node of its role, i.e. role is GenericContainer or Unknown and it carries no name, value, description, live region, popup, relationship, identifier, action, or any other author/widget property. The moment a container gains semantic content it is kept:
- An
HStackwith.access_label(lit!("Toolbar"))→Role::GenericContainerwith a name → kept in the update. The platform adapters still drop aGenericContainerfrom the tree they expose, name or no name (accesskit_consumer'scommon_filteranswers the role withExcludeNode), so the name is not heard; addaccess_role(Role::Group)for a named group. - A
Panel(Role::Group) orGroupBox→ non-presentational role → kept. - The Window root, the currently-focused node, and any node referenced by another node's
controls/described_by/labelled_by→ always kept.
So you rarely need Exclude just to silence layout scaffolding, that happens for free. Reach for Exclude / Merge only when you want to prune or combine descendants that do carry semantics (decorative icon clusters with labels, multi-line cards, …).
Action callbacks
AT-invoked actions arrive as WidgetEvent::AccessAction { action, target, target_node, data } on the widget's on_access_action handler. The override system layers an additional callback path on top of any user-installed handler.
Standard actions: access_action
#![allow(unused)] fn main() { use teksilo::core::accesskit::Action; let widget = my_widget .access_action(Action::ShowContextMenu, |ctx| { ctx.send_intent(AppIntent::OpenMenu); }) .access_action(Action::Increment, |ctx| { ctx.send_intent(AppIntent::StepUp); }); }
Both calls advertise the action on the AT node AND register the callback. Multiple access_action calls register separate callbacks for distinct actions; the dispatcher routes each invoked action to the matching callback.
Layering with on_access_action. If the developer also calls .on_access_action(|action, ctx| …) (or .on_access_action_request(…)) directly, both fire for the same dispatched event, the user-installed handlers first, then the override-registered callback; the event counts as handled if either handles it. Builder ordering doesn't matter; the dispatcher (pointer_router.rs) reads node.access_overrides.actions directly.
Action suppression: access_remove_action
A widget like Button emits Action::Click and Action::Focus unconditionally (and the walker adds Focus to any focusable node and ShowContextMenu to any node with a context menu, before overrides apply). To neutralize one (e.g. a Button used purely as a layout shim that shouldn't appear clickable to AT):
#![allow(unused)] fn main() { my_button.access_remove_action(Action::Click); }
Applied after the widget's accessibility() runs but before override-advertised actions are added, so a subsequent .access_action(Action::Click, …) call re-advertises Click with the override's callback.
Custom-named actions: access_custom_action
SwiftUI's accessibilityAction(named:) parity. The label is exposed verbatim by AT software (e.g. VoiceOver's Actions rotor reads "Reply to message").
#![allow(unused)] fn main() { my_message .access_custom_action(tr!(reply_now()), |ctx| { ctx.send_intent(AppIntent::Reply); }) .access_custom_action(tr!(delete()), |ctx| { ctx.send_intent(AppIntent::Delete); }); }
Each entry is assigned a stable i32 id in declaration order. AT triggers a custom action via WidgetEvent::AccessAction { action: Action::CustomAction, data: Some(ActionData::CustomAction(idx)), .. } and the dispatcher routes by idx into access_overrides.custom_actions.
The entries are appended to whatever custom actions the widget publishes itself, so a Slider given .access_custom_action(..) still offers its own "Reset to default". The dispatcher hands one CustomAction(id) to the widget's own handler and to the override entry at that index alike, which is why a widget that publishes custom actions numbers them clear of the low ids the overrides take (Slider uses 1000).
Shortcuts
Two variants for announcing a chord on the AT node, pick by where the binding lives.
.access_shortcut_id("app.save"): the production path
Bind to a Shortcut registered in ShortcutRegistry. The walker resolves the current effective primary keystroke at AT-build time and writes it via KeyStroke::Display ("Ctrl+S"; "Cmd+S" on macOS, where a declared Ctrl resolves to ⌘). On a user rebind via ShortcutSettings, the registry's version() signal bumps and sync_accessibility dirties the AT cache automatically, the announcement updates without any explicit signaling from the settings UI.
#![allow(unused)] fn main() { // Somewhere in your root widget's build(), register the Shortcut. ctx.register_shortcut_global( Shortcut::new("app.save").name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ); ctx.register_action(Action::new("app.save").on_invoke(|_, ctx| save(ctx))); // On the Save button, bind the AT announcement to the same id. Button::new(tr!(save())) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)) .access_shortcut_id("app.save"); }
If the registry has no entry for id yet (registration hasn't happened, or the app spelled the id wrong), the announcement is silently omitted, same fallback as MenuItem::for_shortcut(...) and TooltipContent::for_shortcut(...).
.access_shortcut_literal("Ctrl+S"): the explicit-string path
Frozen pre-formatted string. Use for chords NOT going through the Shortcut system: platform-native keys (Tab, Esc), app-internal hotkeys not exposed to user rebinding, or stand-alone demos.
#![allow(unused)] fn main() { my_button.access_shortcut_literal("Ctrl+Shift+P"); }
Does NOT track rebinds, that's the literal variant's tradeoff. For chords routed through Shortcut, prefer access_shortcut_id or the announcement and the actual binding will drift.
See shortcut-intent-action.md for the full Shortcut/Intent/Action pipeline.
Internationalization
User-visible string methods (access_label, access_description, access_hint, access_value, access_custom_action) accept impl Into<Prop<String>>. With the i18n feature enabled, teksilo_i18n::LocalizedString (the type produced by tr!(...)) implements From<LocalizedString> for Prop<String>, so:
#![allow(unused)] fn main() { button .access_label(tr!(save())) // Fluent-translated .access_description(tr!(save_explanation())) .access_custom_action(tr!(publish_now()), |ctx| ctx.send_intent(AppIntent::Publish)); }
flows through unchanged. The user-visible string overrides (access_label, access_description, access_hint, access_value, access_custom_action) take impl Into<Prop<String>> and store a Prop<String>, so tr!(...) stays locale-reactive: the accessibility tree re-walks on a locale change and re-resolves the announced value, no composite rebuild required. (AccessibilityOverrides lives in teksilo-core, which can't name LocalizedString; the bridge is From<LocalizedString> for Prop<String>.)
For explicitly-untranslated AT strings, wrap with lit!(...): access_label(lit!("Debug")). A bare &str still compiles, because impl From<&str> for Prop<String> exists so that call sites written against the older impl Into<String> setters kept working when the setters widened. lit! is therefore a convention, not a compiler-enforced requirement. Use it anyway: it is what makes an untranslated AT string greppable in a one-pass audit, and it records the omission as a decision rather than an oversight. The #[doc(hidden)] _literal twins (access_label_literal, etc.) remain only as the literal path reachable from inside teksilo-core itself (where lit! isn't available); application code uses lit!.
State clearing
AccessNodeBuilder::set_hidden() and set_disabled() flip flags on. Some widgets call those setters unconditionally (e.g. an ImageWidget marked a11y_hidden() calls set_hidden()). To un-set widget-emitted state, the override system exposes:
.access_hidden(false)clears even widget-emittedset_hidden()on this node. Full clear (no framework re-application of hidden). It reaches this node's own flag only: a node under a hidden ancestor stays hidden, because the adapters inherit the flag from every ancestor..access_disabled(false)clears widget-emitted disabled AND arena-driven disabled. The framework's gate ataccessibility_emit_impl.rsrespects the override, so even a.disabled(true)set on the widget's enabled-state can be overridden for AT purposes.
Real use cases:
- App author uses an image marked decorative in a place where a screen reader user does need it, and gives it
.access_hidden(false)with an.access_label(...). - App author force-disables a Button visually pending a save, but wants AT to keep announcing it as enabled because the disabled state is transient.
- Test scaffolding asserts a widget is exposed regardless of internal decorative plumbing.
Hidden is for decoration, never for a wrapper
set_hidden() hides the node and everything under it. accesskit_consumer, which the AT-SPI, UIA and macOS adapters all read the tree through, treats a node as hidden when any ancestor is and filters a hidden node out with its whole subtree (ExcludeSubtree). Only the focused node is let back through, alone: a reader lands on it by Tab and finds nothing around it, and its parent does not list it. Use it for what really is decoration: an icon, a separator, a scroll bar, a painter, a label that repeats a name its parent already carries.
A widget that is only chrome around content a reader needs (a panel, a frame a style draws, a layout shell, a row wrapper) publishes a bare Role::GenericContainer instead. The walker prunes it (see Automatic presentational collapse) and, if something keeps it in the update, the adapters still drop it and promote its children (ExcludeNode). Every style protocol that wraps content (DialogStyle::make_panel, and make_body on CardStyle, PanelStyle, SnackbarStyle, ToastStyle, PopoverStyle for its Menu variant and RadioTileStyle) says so, and accessibility::audit::focusable_nodes_hidden flags a control that takes focus inside a hidden subtree, which is how the mistake shows.
Under a live region the wrapper has one more thing to say. A node's politeness is inherited by every descendant that sets none (accesskit_consumer node.rs:906-910), and the AT-SPI, UIA and macOS adapters all announce each named node that enters the filtered tree, or is renamed there, with a politeness other than off. So a region meant to be announced by its own name (a toast, a snackbar, a banner, a message box, the calendar grid, whose name is the month) is announced once for its name and once more for every named node its content holds, the moment that content can be reached. Orca speaks each announcement with interrupt set, so what is left at the end is whichever came last in the consumer's hash order. A hidden wrapper used to stop this by hiding the content; the wrapper that replaces it gives its content Live::Off instead: a GenericContainer with Live::Off is kept in the update, where its descendants inherit the Off, and the adapters still drop it and promote its children. SnackbarStyle::make_body and ToastStyle::make_body say so. A region whose content is meant to be heard as it changes, a StatusBar with announce_changes(true), keeps the inheritance.
Synthetic children: access_customize
Widgets like RichTextEditor emit synthetic AT children (paragraphs, text-runs) via push_paragraph_child / push_text_run_child, these live inside the parent's emitted Node, not as separate WidgetNodes in the arena. The override system can't reach them through .access_* modifiers (which target whole widgets, not sub-nodes).
The supported path is access_customize, which runs last in the apply pipeline with full &mut AccessNodeBuilder access:
#![allow(unused)] fn main() { my_widget.access_customize(|builder| { // builder.inner_mut() exposes the underlying accesskit::Node, any // AccessKit field the typed surface doesn't cover is reachable. builder.inner_mut().set_class_name("custom-widget"); builder.inner_mut().set_role_description("special panel"); }); }
Same escape-hatch model AccessKit itself uses internally. The closure runs every time the AT tree is built, so it has consistent re-render semantics with the rest of the override layer.
Apply order: the full pipeline
For each widget the AT walker visits, the sequence is:
node.widget.accessibility(&mut builder), inner widget emits role, name, value, actions, hidden/disabled, etc. The walker then addsShowContextMenu(node has a context-menu factory) andFocus(node is focusable) if not already present.overrides.apply(&mut builder)in this order:- Scalars:
label,description,value,role(replace ifSome). - State flags:
hidden/disabledset or clear based onSome(true)/Some(false). - Identity:
identifier→set_author_id. - Relationships:
controls,described_by,labelled_by(append). - Live region /
aria_current/keyboard_shortcutliteral /has_popup/orientation. - Numeric:
numeric_value,min,max,step. - Action suppression:
removed_actions(callremove_action). - Action advertisement:
actions(calladd_action). - Custom actions: append
Vec<accesskit::CustomAction>with sequential ids to the widget's own, and advertiseAction::CustomAction. customizeclosure runs last with fullinner_mut()access.
- Scalars:
- Walker post-processing:
- Resolve
access_shortcut_idagainstShortcutRegistry(this needs tree access, so it lives outsideapply()). - For a node that stands for composites (
accessibility_proxy), apply their overrides next, innermost composite first. A composite that names a proxy skips step 2 and step 3.1 on its own node. - Subtree dispatch: for
Merge, walk descendants and absorb into the current builder; forExclude, prune.
- Resolve
- Framework finalization:
- Push child NodeIds (skipped for Exclude / Merge).
- Inject layout bounds (plus a transform at a content-transform boundary such as
SceneView). - Re-apply
set_disabled()from the arena's enabled-flag UNLESS the override hasdisabled: Some(false). - Tooltip →
push_described_bywhen the tooltip is shown (its text then comes back as the description in the tree-wide pass below), otherwise its text as a staticdescription. builder.build(id)→ produces(NodeId, accesskit::Node, synthetic_children, synthetic_local_bounds).
After every widget has been visited, tree-wide passes run over the assembled node list: a relation target naming a composite with a proxy is pointed at the proxy; a nameless TreeItem / ListBoxOption row takes its name from its first named descendant; semantically-empty GenericContainer / Unknown nodes are dropped and their children promoted to the parent, exempting the root, the focused node, and relationship targets (see Automatic presentational collapse); children and relation targets (controls / described_by / labelled_by) naming a node absent from the update are stripped; and, last, every node's described_by targets are written into its description (below).
A description from described_by
aria-describedby works in a browser because the browser computes the
description from the referenced nodes. AccessKit 0.25 does not:
accesskit_consumer gives a node a description from its own property only,
and no adapter exports the relation (the AT-SPI relation set carries
ControllerFor alone, UIA answers ControllerFor and never DescribedBy,
macOS links controls). A field described only by the relation is described
to nobody. So the last tree-wide pass does the browser's part: each target's
text (its name, or failing that the text of what it contains, without its
hidden parts) is appended to the node's own description, and that is what
Orca, NVDA and VoiceOver read (AT-SPI Description, UIA FullDescription,
AXHelp). The relation is kept; Orca, which follows it, reads it only when
the description is empty, so nothing is read twice.
A description is what a reader says as focus arrives. A message
appearing is the business of a live region: the target itself, as a
ValidationStrip is, or an announcement. Two facts about the readers shape
what the pass writes around focus, both read from their sources:
- Orca speaks a change to the description of the node it holds as focus, and does not check that the text is new. The adapters send an update's node changes before its focus move, so while Orca hears them it still holds the node focus is leaving.
- A focus move cuts an announcement made in the same update on Orca, which stops speaking to read the new focus, and not on NVDA, which reads the new focus after it.
So, on the node focus is on and every node it is inside:
- while focus stays, and in the update focus leaves, the description gains nothing new; a message that went away is dropped at once, so the description is never stale. A node focus has left takes what it held back in the next update, which the tree asks for;
- on Windows, a text that a live region announces in the update focus arrives in is left out of that arrival, so a field focused in the very update its error is announced says the error once, through NVDA's announcement. On Linux the arrival keeps it, since Orca cuts the announcement and the arrival is the one voice left; macOS does the same, since VoiceOver is unverified and a text left out of both would reach nobody;
- the framework's own announcer (
ctx.announce) never speaks in the update focus moves in: it says its message in the next update, after the new focus, so every reader hears it whole, and the arrival leaves its text out on every platform.
What is held back is read the next time focus arrives. Only Orca speaks a
change while focus stays: NVDA maps no event to a FullDescription change,
and macOS posts no notification for one. So a message that must be heard the
moment it appears needs a live region on every platform, and one without it
is heard on the next arrival, the same on all three.
Testing patterns
All headless. Assertions go through WidgetTree::accessibility_node(id) (synthetic snapshot) or WidgetTree::sync_accessibility() (full TreeUpdate, useful when checking pruning, custom_actions, controls relationships, etc.). sync_accessibility() always returns the complete tree, never a delta, so a test that wants to know whether something changed compares WidgetTree::at_version() (bumps only when the content differs) or the node itself between two syncs; the update it returns is not what an adapter is fed, which is deliver_accessibility.
What a screen reader is told, as opposed to what the tree holds, is read with WidgetTree::announcements_since(seq): every announcement the platform adapters would have spoken from the updates sync_accessibility() has returned, a ctx.announce(…) and a live region's change alike. Take the last seq before the action, act, sync once, and read what came after:
#![allow(unused)] fn main() { let seq = tree.announcements_since(0).last().map_or(0, |a| a.seq); tree.press_key(Key::Enter, Modifiers::NONE); // a handler calls ctx.announce("Saved") tree.sync_accessibility(); let said: Vec<String> = tree.announcements_since(seq).into_iter().map(|a| a.text).collect(); assert_eq!(said, ["Saved"]); }
The sync is what records them, so nothing is there before it. A tree built with WidgetTree::new() records from its first update; the tree of a window teksilo-app opens records only when the automation bridge is installed.
A test about what a screen reader is told as a node leaves the tree and comes back reads the update the platform adapter is handed, WidgetTree::deliver_accessibility(window_focused), through accesskit_consumer. sync_accessibility() keeps every node's own id; a node that returns is handed a new one on its way to the adapter, because the AT-SPI adapter announced the old one defunct as it left.
#![allow(unused)] fn main() { use teksilo::core::accesskit::{Action, Role, HasPopup}; // Scalar override let mut tree = WidgetTree::new(); let id = tree.add(MyWidget.access_label(lit!("Publish"))); tree.layout(SizeProposal::exact(100.0, 40.0)); assert_eq!(tree.accessibility_node(id).name(), Some("Publish")); // Action callback let flag = Signal::new(false); let cb = flag.clone(); let id = tree.add(MyWidget.access_action(Action::ShowContextMenu, move |_| cb.set(true))); tree.layout(...); tree.dispatch_event(WidgetEvent::AccessAction { action: Action::ShowContextMenu, target: Some(id), target_node: widget_id_to_node_id(id), data: None, }); assert!(flag.get()); // Subtree merge let title = tree.add(FillWidget::new().label("Title")); let body = tree.add(FillWidget::new().label("Body")); let card = tree.add(StackWidget::new().child(title).child(body).access_merge_subtree()); tree.layout(...); let update = tree.sync_accessibility(); assert_eq!(tree.text_content(card), Some("Title Body".to_string())); // Children pruned from output: assert!(find_node(&update, title).is_none()); assert!(find_node(&update, body).is_none()); // Shortcut id auto-tracks rebinds tree.shortcut_registry_mut().register( Shortcut::new("app.save").name("Save").primary(KeyStroke::ctrl(Key::S)).build(), ); let id = tree.add(MyButton.access_shortcut_id("app.save")); let node = find_node(&tree.sync_accessibility(), id).unwrap(); assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S")); // "Cmd+S" on macOS tree.shortcut_registry_mut().rebind_primary("app.save", Some(KeyStroke::ctrl(Key::Q))); let node = find_node(&tree.sync_accessibility(), id).unwrap(); assert_eq!(node.keyboard_shortcut(), Some("Ctrl+Q")); }
The in-crate tests at crates/teksilo-core/src/widget_tree/accessibility_impl.rs (the access_* ones) cover every method in this reference, including all subtree-mode edge cases (nested Exclude-in-Merge, Merge-in-Merge), action-callback layering with on_access_action, custom-action dispatch by index, state-clearing for both hidden and disabled, and the i18n Into<Prop<String>> conversion path (with locale-reactive re-walk covered by an integration test in teksilo-app).
End-to-end demo
No shipped example demonstrates the whole override surface on widgets (the scene_* demos use the parallel access_* builders on scene items). To verify a11y output of any demo against a real assistive tech stack:
cargo run -p widget-catalog
# In another terminal:
accerciser # Linux AT-SPI inspector
Navigate to the control you annotated and confirm the announced name, the keyboard shortcut field and any has_popup value match the overrides you set.
Styling never touches accessibility
The Tier-3 styling system (see styling-system.md) lets an app swap a widget's entire chrome, Button::style(MyGlassButton), theme.style_slots.toggle = Some(...), an image-backed theme, but style trait impls do not participate in the accessibility tree. A *Style::make_body return is decoration only; the widget owns its accessibility(builder) output and all .access_* overrides regardless of which style is installed. A glassmorphism button and the default RecipeButtonStyle button announce identically. This keeps AT identity stable across theme swaps and reskins, switching themes at runtime never disturbs a screen-reader's cursor or the AccessKit node ids.
Related references
- styling-system.md, the four-tier styling ladder; style traits decorate, they do not annotate.
- shortcut-intent-action.md, the
Shortcut/Intent/Actionpipeline that.access_shortcut_idbinds to. - events-and-gestures.md,
on_access_actionandon_access_action_requestevent handlers (what.access_actionlayers on top of). - reactive-theme.md, how locale and theme changes propagate without a composite rebuild (on the AT side, a locale switch dirties the cache and the re-walk keeps
.access_label(tr!(...))translations current). - teksu-macro-reference.md,
teksu!DSL syntax forname: valuebody items (howaccess_*overrides are written insideteksu!). - crates/teksilo-core/src/widget_builder.rs,
AccessibilityOverridesstruct,AccessSubtreeModeenum, everyaccess_*method definition. - crates/teksilo-core/src/widget_tree/accessibility_emit_impl.rs, walker integration, the tree-wide passes,
merge_descendants_intohelper. - crates/teksilo-core/src/widget_tree/accessibility_impl.rs,
sync_accessibility, the test accessors, and the unit tests. - crates/teksilo-core/src/widget_tree/accessibility_description_impl.rs: the description written from
described_by, and the platform sources its rules rest on. - automation-mcp.md, the in-process AT tree + AT-action channel exposed as a Model Context Protocol server, so an agent can observe and drive the same accessibility surface these overrides shape.