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
Accessibleis 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
Vecis the widget’s primary node. Additional nodes represent internal structure. ATablewidget 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
idmatching across consecutive snapshots. areais 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.
| Group | Variants |
|---|---|
| Interactive | Button, TextInput, Checkbox, RadioButton, Slider, Tab, MenuItem |
| Containers | Window, Dialog, List, Table, Menu, Toolbar, Group |
| Content | Label, ListItem, TableRow, TableCell, TabPanel |
| Visual | ProgressBar, ScrollBar, Separator |
| Decorative | Presentation |
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 builderThe 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.
Navigating a tree
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 rootancestors 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 UIsBoundsChangedChildrenChangedLiveRegionChanged { 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 inftui-widgets. - Focus graph — the runtime’s focus manager
uses
A11yRole::is_interactive()to decide what is reachable by Tab. - Accessibility panel demo — the
accessibility_panelscreen 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
namefield; 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 alwaysPolite. - Do not include presentational children. If a container widget is
purely decorative, return
A11yRole::Presentationor simply do not implementAccessible. Screen readers will skip the subtree.
See also
- Focus graph — the focus manager that drives
focused_id - Contrast checking — WCAG utilities used by the accessibility panel
- Bidi / RTL support — the other half of “localized UIs”
- Widgets — focus · Style — color · Demo showcase overview