Skip to Content
Accessibility & i18nA11y tree

Accessibility tree

ftui-a11y is the crate that lets FrankenTUI widgets describe themselves to screen readers and other assistive technology (AT) without coupling the widgets to any particular platform accessibility API. The data model is a small, ARIA-like tree of nodes that is rebuilt each render pass and diffed against the previous frame.

Phase 1 scope. ftui-a11y currently ships the core data types, the trait contract, and tree construction + diffing. The platform bridges that push the diffs into AccessKit / OS accessibility APIs are future work. The crate is usable today for widget authors who want to declare the accessibility story up front.

The shape of the tree

Widget ──impl Accessible──> Vec<A11yNodeInfo> A11yTreeBuilder::add_node() A11yTreeBuilder::build() A11yTree (immutable snapshot) A11yTree::diff(&prev) A11yTreeDiff (future: platform bridge)

The render pass collects nodes by calling each widget’s accessibility_nodes(area), pushes them into an A11yTreeBuilder, and seals the builder into an immutable A11yTree. Consecutive snapshots diff to an A11yTreeDiff that describes exactly what changed — adds, removes, property changes, and focus transitions. That is what a platform bridge will consume.

The Accessible trait

pub trait Accessible { fn accessibility_nodes(&self, area: Rect) -> Vec<A11yNodeInfo>; }
  • Opt-in. A widget that does not implement Accessible is invisible to AT (treated as presentational). This is the right default: most decorative widgets have nothing useful to say, and saying nothing is better than saying something misleading.
  • The first node in the returned Vec is the widget’s primary node. Additional nodes represent internal structure. A Table widget returns the table node, then the row nodes, then the cell nodes in one call.
  • Node IDs must be unique per render pass and stable across frames. Use a deterministic hash of widget identity, not a random ID. Diffing relies on id matching across consecutive snapshots.
  • area is the bounding rectangle the widget was laid out into. Include it on the primary node so AT can position overlays correctly.

Minimal example

use ftui_core::geometry::Rect; use ftui_a11y::{Accessible, node::{A11yNodeInfo, A11yRole}}; struct Button { label: String, id: u64 } impl Accessible for Button { fn accessibility_nodes(&self, area: Rect) -> Vec<A11yNodeInfo> { vec![ A11yNodeInfo::new(self.id, A11yRole::Button, area) .with_name(&self.label), ] } }

Roles

A11yRole is a small, terminal-oriented ARIA-like taxonomy — 23 variants in total. Each variant maps to a common TUI widget archetype; screen readers use the role to decide announcement strategy and keyboard affordances.

GroupVariants
InteractiveButton, TextInput, Checkbox, RadioButton, Slider, Tab, MenuItem
ContainersWindow, Dialog, List, Table, Menu, Toolbar, Group
ContentLabel, ListItem, TableRow, TableCell, TabPanel
VisualProgressBar, ScrollBar, Separator
DecorativePresentation

A11yRole::is_interactive() returns true for the first group — the roles the focus manager treats as keyboard-focusable by default.

State flags

pub struct A11yState { pub focused: bool, pub disabled: bool, pub checked: Option<bool>, pub expanded: Option<bool>, pub selected: bool, pub readonly: bool, pub required: bool, pub busy: bool, pub value_now: Option<f64>, pub value_min: Option<f64>, pub value_max: Option<f64>, pub value_text: Option<String>, }

Boolean flags default to false. Optional fields distinguish “not applicable” (None) from “explicitly false” (Some(false)) — a button has no sensible checked value, so it stays None; a checkbox that is currently off is Some(false). The diff engine treats them differently.

value_* fields are for widgets with a numeric range (sliders, progress bars). value_text is the human-readable summary a screen reader announces (“50 %”, “Medium”) when the raw number is not meaningful.

Live regions

pub enum LiveRegion { Polite, // announce at the next graceful opportunity Assertive, // announce immediately, interrupting current speech }

Attach a LiveRegion to any node whose text changes dynamically and should be announced even when it is not focused — toast notifications, progress updates, validation errors. Polite is the sane default; Assertive is reserved for genuinely urgent content, because it interrupts the user.

Changes to LiveRegion::Assertive nodes show up in the diff and a platform bridge forwards them as an interruption event. This is the mechanism that makes “the build failed” announce itself without the user navigating to the status line.

Node info: the full record

pub struct A11yNodeInfo { pub id: u64, pub role: A11yRole, pub name: Option<String>, // ~= aria-label pub description: Option<String>, // ~= aria-description pub bounds: Rect, pub children: Vec<u64>, pub parent: Option<u64>, pub shortcut: Option<String>, // e.g. "Ctrl+S" pub state: A11yState, pub live_region: Option<LiveRegion>, }

Construct with A11yNodeInfo::new(id, role, bounds), then chain builder-style setters: .with_name, .with_description, .with_shortcut, .with_live_region, .with_children, .with_parent, .with_state.

Building a tree

Allocate a builder

let mut b = A11yTreeBuilder::new(); // or with_capacity(n)

Push nodes in any order

b.add_node(root); b.add_node(child_a); b.add_node(child_b);

Designate root and focus

b.set_root(root_id); b.set_focused(Some(child_a_id));

Freeze into an immutable tree

let tree = b.build(); // A11yTree, consumed builder

The tree is a flat AHashMap<u64, A11yNodeInfo>: O(1) lookup by ID, O(n) diff. Parent/child relationships are encoded inside each node, not in a separate adjacency list.

tree.node(id) // Option<&A11yNodeInfo> tree.root() // the root node if set tree.focused() // the focused node if set tree.nodes() // iterator over all nodes tree.node_count() tree.children_of(id) // Vec<&A11yNodeInfo> tree.ancestors(id) // Vec<u64> from self up to root

ancestors is cycle-safe — it caps at 1000 nodes to avoid infinite walks on a malformed parent chain. A malformed chain is almost certainly a bug in a widget’s ID scheme; the 1000 cap is a seatbelt, not a feature.

Diffing

let diff: A11yTreeDiff = current.diff(&previous);
pub struct A11yTreeDiff { pub added: Vec<u64>, pub removed: Vec<u64>, pub changed: Vec<(u64, Vec<A11yChange>)>, pub focus_changed: Option<(Option<u64>, Option<u64>)>, }

The diff engine walks both maps once and emits per-node A11yChange records:

  • NameChanged { old, new }
  • RoleChanged { old, new } — rare, but possible during dynamic UIs
  • BoundsChanged
  • ChildrenChanged
  • LiveRegionChanged { old, new }
  • DescriptionChanged { old, new }
  • ShortcutChanged { old, new }
  • ParentChanged { old, new }
  • StateChanged { field, description } — one per changed state field

A11yTreeDiff::is_empty() lets a bridge skip the trip to the OS when nothing changed.

Where this plugs in

  • Widgets opt into Accessible. Most of the built-in widgets have declarations in ftui-widgets.
  • Focus graph — the runtime’s focus manager uses A11yRole::is_interactive() to decide what is reachable by Tab.
  • Accessibility panel demo — the accessibility_panel screen exercises high-contrast, reduced-motion, and large-text toggles; the contrast checker on the same screen computes WCAG ratios against the active theme.
  • Bidi / RTL — accessible names for RTL widgets travel through the same name field; the renderer handles visual reordering separately.

Pitfalls

  • Do not allocate fresh IDs per frame. The diff engine matches nodes by ID across frames. Random IDs mean every node looks “added” and every previous node “removed” — catastrophic for announcement latency.
  • Do not omit bounds. AT overlays position themselves against the bounds rectangle. Zero-sized bounds make them un-hittable.
  • Do not mark everything Assertive. Interrupting the user is the nuclear option. Keep it for failures, security prompts, and genuine emergencies. Toast notifications are almost always Polite.
  • Do not include presentational children. If a container widget is purely decorative, return A11yRole::Presentation or simply do not implement Accessible. Screen readers will skip the subtree.

See also