git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitafb569952dcf37b147de2517ed9c85ce03737b69
parentb56b4d1d73
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 09:58
feat(a11y): the accessibility tree, in AccessKit's schema

rfc-accessibility-locale § 5 records the decision: AccessKit, not an
AT-SPI server of our own — built for toolkits that draw their own
widgets, used by egui, Bevy, Slint, GPUI and others, three crates of
Linux work saved and macOS with it; costs accepted (zbus >= 5.19 when the
adapter lands, pre-1.0 churn, its text model).

Phase 1's first piece: crate::a11y::tree_update(&UiContext, title,
scale) is AccessKit's TreeUpdate for a window — a window node carrying
the HiDPI scale, and a node per registered visible widget with its role
(WidgetHost::a11y_role, else a11y::role_for from type and focus role),
label, value (a11y_value, the widget's value_string: toggled for a check
box or switch, numeric for a slider, spin button or progress bar),
logical bounds, focus and click actions, children in reading order, and
the context's focus. Input::a11y_role is where a widget says what it is;
Owned forwards both hooks. The accesskit crate is pure data (its one
dependency is uuid) and builds for every target; no platform adapter yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                        |   4 +
 Cargo.toml                       |   3 +
 docs/rfc-accessibility-locale.md |  53 ++++++++-
 src/a11y.rs                      | 233 +++++++++++++++++++++++++++++++++++++++
 src/lib.rs                       |   1 +
 src/widget/mod.rs                |  12 ++
 src/widget/model.rs              |  15 +++
 src/widget/owned.rs              |   2 +
 8 files changed, 318 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 063c1b8..31ae15e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1705,6 +1705,10 @@ cce-system-interface) to confirm behavior, not just the test suite.
 - `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
   `Binding`, the job rules and naga's parse (see "Compute jobs run in the browser too").
   `vk::ComputeDevice` and `web::ComputeDevice` run them.
+- `a11y.rs` — the accessibility tree: `tree_update(&UiContext, title, scale)` is AccessKit's
+  `TreeUpdate` for a window's registered widgets (role, name, value, bounds, actions,
+  focus); `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself. No
+  platform adapter yet (`docs/rfc-accessibility-locale.md`, phases 1–2).
 - `ime.rs` — input-method composition shared between the editing widget and the shell:
   `Preedit`, the composition and its generation, the reported caret, the reset request
   (see "Input-method composition is one model for every shell").
diff --git a/Cargo.toml b/Cargo.toml
index db4d2c8..c95df34 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,9 @@ doc_editor = []
 # The GUI-free half: config, input, motion, units, ipc and the spec parsers,
 # re-exported at their old paths (`cce_ui::config`, …). See lib.rs.
 cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "0fb7c440d040259547aae1d711423f533ed52210" }
+# The accessibility tree's schema (rfc-accessibility-locale § 5): pure data, built for
+# every target; `crate::a11y` produces its TreeUpdate. The platform adapters are separate.
+accesskit = "0.25"
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "41ca52bd51ea3655a791c58005be9fce939e4333", optional = true }
 glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index 4d1d58b..f4ef4c0 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -79,6 +79,14 @@ One `cce_core::locale()`: `LC_ALL`, else `LC_CTYPE`, else `LANG`, turned from PO
 
 ### Phase 1 — an accessibility tree the toolkit can produce
 
+**Progress (2026-10-08):** the tree for retained widgets is `crate::a11y::tree_update`
+(AccessKit's `TreeUpdate`, § 5): a window node carrying the HiDPI scale, and a node per
+registered visible widget with its role (`WidgetHost::a11y_role`, else `a11y::role_for` from
+type and focus role), label, value (`a11y_value`, the widget's `value_string`: toggled for a
+check box or switch, numeric for a slider, spin button or progress bar), logical bounds,
+focus and click actions, children in reading order, and the context's focus. Still to come
+in this phase: the hook for immediate-mode apps, and action IDs for context-menu rows.
+
 - A node per registered widget: **role** (from `type_name` and `FocusRole` first, then an
   explicit `Input::a11y_role()` a widget can override: button, checkbox, toggle-button,
   slider, spin-button, text-input, combo-box, list, tree, table, menu), **name** (the
@@ -131,11 +139,46 @@ Phase 0 is an afternoon. Phase 1 is the real design work, and everything after i
 on it; it is additive, and the tree costs nothing for an app nobody inspects. Phases 3 and
 4 are independent of 1 and 2 and can go in parallel. Phase 5 waits on phase 1's ID rule.
 
-## 5. Open questions
-
-- AccessKit, or our own AT-SPI server? Decide by building phase 2 against AccessKit for one
-  app (cce-data-editor: plate navigation already on, a tree list, text fields, a menubar)
-  and measuring what it costs in dependencies and in frame time.
+## 5. Decision: AccessKit (2026-10-08)
+
+The tree is AccessKit's, and so are the platform adapters. AccessKit is a schema for an
+accessibility tree (the `accesskit` crate: pure data, one required dependency, `uuid`, so it
+builds for every target cce-ui does) plus adapters that publish it, each pushed to by the
+toolkit. Phase 1 produces its `TreeUpdate` directly; there is no schema of our own to
+translate.
+
+Why, against writing our own AT-SPI server:
+- **It is the problem it was built for**: "toolkits that render their own user interface
+  elements". egui, Bevy, Slint, GPUI, Masonry/Xilem, Freya, Vizia, KAS and Servo use it.
+- **Linux alone would be three crates' worth of work** (`accesskit_consumer`,
+  `accesskit_atspi_common`, `accesskit_unix`, ~115 KB compressed): the AT-SPI object
+  interfaces (accessible, component, action, value, text, editable text, selection, table),
+  their events and cache, tree diffing and text navigation.
+- **macOS comes with it** (`accesskit_macos`, onto the AppKit shell's view). Our own server
+  would cover Linux only.
+- **No cost without a screen reader**: `Adapter::update_if_active` builds nothing until an
+  assistive tool connects. Its handlers run on another thread, which `AppSender` already
+  serves.
+- MIT or Apache-2.0, like this crate.
+
+What it costs, accepted:
+- `accesskit_unix` needs zbus 5.19 or later (cce-ui has none today), so it goes in the
+  Wayland shell and behind a feature if its weight shows.
+- Pre-1.0: its crates release together with breaking minor versions every few months.
+- Editable text is exposed in its model (text runs with per-character positions), which
+  phase 4's direction-aware carets must feed.
+
+Unchanged by the choice: the browser still needs our hidden-ARIA mirror (AccessKit's web
+adapter is planned, not released), and on Wayland a window's screen position is unknown to
+any toolkit, so a screen reader's pointer features are approximate there for GTK as much as
+for us.
+
+How it is proven: phase 1's tree, then `accesskit_unix` in the Wayland shell for one app
+(cce-data-editor: plate navigation on, a tree list, text fields, a menubar), measured in
+dependencies and frame time and listened to with Orca (`at-spi2-core` is installed; a
+screen reader is not).
+
+## 6. Open questions
 - Should the compositor expose its own UI (window titles, overview, the grid) to AT-SPI?
   It draws natively, so it would need its own tree.
 - Do immediate-mode apps get the hook or get ported? The status bar and the notifier are
diff --git a/src/a11y.rs b/src/a11y.rs
new file mode 100644
index 0000000..4f76c4c
--- /dev/null
+++ b/src/a11y.rs
@@ -0,0 +1,233 @@
+//! The accessibility tree: what a window's widgets are, for screen readers and other
+//! assistive technology (`docs/rfc-accessibility-locale.md`, phase 1).
+//!
+//! The tree is AccessKit's (§ 5 of the RFC): [`tree_update`] turns a [`UiContext`] into an
+//! [`accesskit::TreeUpdate`] that a platform adapter publishes — AT-SPI on Wayland, NSAccessibility
+//! on macOS — and nothing here knows which. A node per registered, visible widget, under one
+//! window node:
+//!
+//! - **role** — the widget's explicit [`WidgetHost::a11y_role`], else [`role_for`]'s guess from
+//!   its type and its [`FocusRole`];
+//! - **name** — its label;
+//! - **value** — [`WidgetHost::a11y_value`] (a widget's `value_string`): a check box or switch
+//!   as toggled, a slider, spin button or progress bar as a number, anything else as text;
+//! - **bounds** — its rect in LOGICAL px, the window node carrying the HiDPI scale as its
+//!   transform, so no widget's bounds change with the scale;
+//! - **actions** — focus for every keyboard stop, click for every [`FocusRole::Plate`];
+//! - **focus** — the context's focused widget, else the window.
+//!
+//! Every call answers the whole tree. AccessKit's adapters compare it with the last one and
+//! raise events only for nodes that changed; sending changed nodes alone is an optimisation
+//! for later, with `backend::frame`'s damage diff as its model.
+
+use accesskit::{Action, Affine, Node, NodeId, Rect, Role, Toggled, TreeId, TreeInfo, TreeUpdate};
+
+use crate::context::UiContext;
+use crate::widget::{FocusRole, WidgetHost, WidgetId};
+
+/// The window's node, the root every widget hangs from.
+pub const WINDOW: NodeId = NodeId(0);
+
+/// A widget's node: its id, moved up one so no widget can be the window.
+pub fn node_id(id: WidgetId) -> NodeId {
+    NodeId(id.0 as u64 + 1)
+}
+
+/// What a widget is when it does not say ([`WidgetHost::a11y_role`]): by its type, else by
+/// what it is to the keyboard — a thing you press is a button, anything else a container.
+pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Role {
+    if let Some(role) = explicit {
+        return role;
+    }
+    match type_name {
+        "Button" => Role::Button,
+        "Checkbox" => Role::CheckBox,
+        "Toggle" => Role::Switch,
+        "Slider" | "RangeSlider" | "Slider2D" => Role::Slider,
+        "Spinbox" => Role::SpinButton,
+        "TextBox" | "KeybindRecorder" => Role::TextInput,
+        "Dropdown" | "FontSelector" => Role::ComboBox,
+        "ColorSelector" => Role::ColorWell,
+        "ButtonStrip" | "Paginator" => Role::TabList,
+        "Breadcrumb" => Role::Navigation,
+        "TreeList" => Role::Tree,
+        "Spreadsheet" => Role::Table,
+        "MenuBar" => Role::MenuBar,
+        "Label" | "StyledLabel" | "TextLabel" => Role::Label,
+        "ProgressBar" | "UsageBar" => Role::ProgressIndicator,
+        "ImageView" => Role::Image,
+        "Splitter" => Role::Splitter,
+        "Graph" | "Trackpad" => Role::Canvas,
+        "Group" | "ParametersBg" | "Panel" => Role::Group,
+        _ => match focus {
+            FocusRole::Plate => Role::Button,
+            FocusRole::Well | FocusRole::None => Role::GenericContainer,
+        },
+    }
+}
+
+/// One widget's node, with `children` already resolved.
+pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
+    let focus = w.focus_role();
+    let role = role_for(w.type_name(), focus, w.a11y_role());
+    let mut node = Node::new(role);
+    if let Some(label) = w.label().filter(|l| !l.is_empty()) {
+        node.set_label(label);
+    }
+    if let Some(value) = w.a11y_value() {
+        match role {
+            Role::CheckBox | Role::Switch => {
+                if let Some(on) = truth(&value) {
+                    node.set_toggled(Toggled::from(on));
+                }
+            }
+            Role::Slider | Role::SpinButton | Role::ProgressIndicator => match value.trim().parse::<f64>() {
+                Ok(n) => node.set_numeric_value(n),
+                Err(_) => node.set_value(value),
+            },
+            _ => node.set_value(value),
+        }
+    }
+    let (x, y, width, height) = w.rect();
+    node.set_bounds(Rect::new(x as f64, y as f64, (x + width) as f64, (y + height) as f64));
+    if focus != FocusRole::None {
+        node.add_action(Action::Focus);
+    }
+    if focus == FocusRole::Plate {
+        node.add_action(Action::Click);
+    }
+    if !children.is_empty() {
+        node.set_children(children);
+    }
+    node
+}
+
+/// A check box's or switch's value string as a state: what `value_string` writes for one.
+fn truth(value: &str) -> Option<bool> {
+    match value.trim().to_ascii_lowercase().as_str() {
+        "true" | "1" | "on" | "yes" => Some(true),
+        "false" | "0" | "off" | "no" => Some(false),
+        _ => None,
+    }
+}
+
+/// The whole tree of `ctx`'s registered, visible widgets under a window node named `title`,
+/// at HiDPI `scale` (see the module docs).
+pub fn tree_update(ctx: &UiContext, title: &str, scale: f64) -> TreeUpdate {
+    // The widgets, read once. Registered pointers name live widgets (the registry resolves
+    // only those: `widget::Owned`, `widget::core::Liveness`), and nothing mutates them while
+    // this borrows the context.
+    let widgets: Vec<(WidgetId, &dyn WidgetHost)> = ctx
+        .tree
+        .iter_registered()
+        .map(|(id, ptr)| (id, unsafe { &*ptr } as &dyn WidgetHost))
+        .filter(|(_, w)| w.visible())
+        .collect();
+    let shown: std::collections::HashSet<WidgetId> = widgets.iter().map(|(id, _)| *id).collect();
+
+    let mut nodes = Vec::with_capacity(widgets.len() + 1);
+    let mut top: Vec<(WidgetId, f32, f32)> = Vec::new();
+    for (id, w) in &widgets {
+        let children: Vec<NodeId> =
+            ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id).collect();
+        nodes.push((node_id(*id), widget_node(*w, children)));
+        let parented = ctx.tree.parent_id(*id).is_some_and(|p| shown.contains(&p));
+        if !parented {
+            let (x, y, _, _) = w.rect();
+            top.push((*id, y, x));
+        }
+    }
+    // Reading order, as the keyboard walk takes it: rows top to bottom, then left to right.
+    top.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)).then(a.0 .0.cmp(&b.0 .0)));
+
+    let mut window = Node::new(Role::Window);
+    if !title.is_empty() {
+        window.set_label(title);
+    }
+    window.set_transform(Affine::scale(scale));
+    window.set_children(top.iter().map(|(id, _, _)| node_id(*id)).collect::<Vec<_>>());
+    nodes.push((WINDOW, window));
+
+    let focus = ctx.focused_widget.filter(|id| shown.contains(id)).map(node_id).unwrap_or(WINDOW);
+    let mut tree = TreeInfo::new(WINDOW);
+    tree.toolkit_name = Some("cce-ui".into());
+    tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
+    TreeUpdate { nodes, tree: Some(tree), tree_id: TreeId::ROOT, focus }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{Button, Checkbox, Owned, Slider, TextBox};
+
+    fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a Node {
+        &update.nodes.iter().find(|(n, _)| *n == id).expect("node in the update").1
+    }
+
+    #[test]
+    fn a_window_of_widgets_is_a_tree_of_what_they_are() {
+        let mut ctx = UiContext::new();
+        let mut save = Owned::new(Button::new(10.0, 40.0, 80.0, 24.0).with_label("Save"));
+        let mut name = Owned::new(TextBox::new("Ada".to_string()).with_label("Name"));
+        name.set_rect(10.0, 10.0, 200.0, 24.0);
+        let mut wrap = Owned::new(Checkbox::new().with_label("Wrap lines"));
+        wrap.set_rect(10.0, 70.0, 200.0, 24.0);
+        wrap.set_value_string("true");
+        let mut zoom = Owned::new(Slider::new().with_label("Zoom"));
+        zoom.set_rect(10.0, 100.0, 200.0, 24.0);
+        ctx.register_host(&mut save);
+        ctx.register_host(&mut name);
+        ctx.register_host(&mut wrap);
+        ctx.register_host(&mut zoom);
+        ctx.set_focused(&mut *name);
+
+        let update = tree_update(&ctx, "Editor", 2.0);
+        let window = node(&update, WINDOW);
+        assert_eq!(window.role(), Role::Window);
+        assert_eq!(window.label(), Some("Editor"));
+        assert_eq!(window.transform(), Some(&Affine::scale(2.0)), "the scale is the window's");
+        let order: Vec<NodeId> = [&*name as &dyn WidgetHost, &*save, &*wrap, &*zoom]
+            .iter()
+            .map(|w| node_id(w.base().id()))
+            .collect();
+        assert_eq!(window.children(), &order[..], "reading order: top to bottom");
+        assert_eq!(update.tree.as_ref().map(|t| t.root), Some(WINDOW));
+        assert_eq!(update.focus, node_id(name.base().id()), "focus follows the context");
+
+        let b = node(&update, node_id(save.base().id()));
+        assert_eq!((b.role(), b.label()), (Role::Button, Some("Save")));
+        assert!(b.supports_action(Action::Click) && b.supports_action(Action::Focus));
+        assert_eq!(b.bounds(), Some(Rect::new(10.0, 40.0, 90.0, 64.0)), "logical px");
+
+        let t = node(&update, node_id(name.base().id()));
+        assert_eq!((t.role(), t.label(), t.value()), (Role::TextInput, Some("Name"), Some("Ada")));
+        assert!(!t.supports_action(Action::Click), "a well is not pressed");
+
+        let c = node(&update, node_id(wrap.base().id()));
+        assert_eq!((c.role(), c.toggled()), (Role::CheckBox, Some(Toggled::True)));
+
+        let s = node(&update, node_id(zoom.base().id()));
+        assert_eq!(s.role(), Role::Slider);
+        assert!(s.numeric_value().is_some(), "a slider's value is a number");
+    }
+
+    #[test]
+    fn hidden_widgets_are_not_in_the_tree_and_focus_falls_back_to_the_window() {
+        let mut ctx = UiContext::new();
+        let mut hidden = Owned::new(Button::new(0.0, 0.0, 10.0, 10.0).with_label("Ghost"));
+        hidden.set_visible(false);
+        ctx.register_host(&mut hidden);
+        ctx.set_focused(&mut *hidden);
+        let update = tree_update(&ctx, "", 1.0);
+        assert_eq!(update.nodes.len(), 1, "only the window");
+        assert_eq!(update.focus, WINDOW);
+        assert_eq!(node(&update, WINDOW).label(), None, "no title, no name");
+    }
+
+    #[test]
+    fn a_widget_that_says_what_it_is_is_believed() {
+        assert_eq!(role_for("Button", FocusRole::Plate, Some(Role::Tab)), Role::Tab);
+        assert_eq!(role_for("SomethingNew", FocusRole::Plate, None), Role::Button);
+        assert_eq!(role_for("SomethingNew", FocusRole::None, None), Role::GenericContainer);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 560947e..3065e70 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@
 // Wayland shell's, which macOS replaces with `mac` (AppKit). Everything else
 // builds for the browser too: `scripts/check-wasm` is the check, and
 // `scripts/check-mac` the macOS one.
+pub mod a11y;
 pub mod color;
 pub mod compute;
 pub mod widget;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index bc8fbe2..094436f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -575,6 +575,18 @@ pub trait WidgetHost {
         FocusRole::None
     }
 
+    /// An explicit accessibility role, overriding the guess `crate::a11y::role_for` makes
+    /// from the widget's type and focus role. Default `None`.
+    fn a11y_role(&self) -> Option<accesskit::Role> {
+        None
+    }
+
+    /// The widget's value for assistive technology: a field's text, a slider's number, a
+    /// check box's "true" / "false". Default `None`.
+    fn a11y_value(&self) -> Option<String> {
+        None
+    }
+
     fn corner_radii(&self) -> CornerRadii {
         let (r, (tl, tr, br, bl)) = self.corner_style();
         CornerRadii::new(
diff --git a/src/widget/model.rs b/src/widget/model.rs
index efd252d..590b9fc 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -476,6 +476,13 @@ pub trait Input {
         None
     }
 
+    /// What this widget is to assistive technology, when the toolkit's guess from its type
+    /// and [`focus_role`](Input::focus_role) is not it (`crate::a11y::role_for`). Default
+    /// `None`: the guess stands.
+    fn a11y_role(&self) -> Option<accesskit::Role> {
+        None
+    }
+
     /// Set the widget's value from a config string. Returns whether it parsed and changed.
     fn set_value_string(&mut self, _val: &str) -> bool {
         false
@@ -1279,6 +1286,14 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
     fn focus_role(&self) -> FocusRole {
         Input::focus_role(&self.inner)
     }
+
+    fn a11y_role(&self) -> Option<accesskit::Role> {
+        Input::a11y_role(&self.inner)
+    }
+
+    fn a11y_value(&self) -> Option<String> {
+        Input::value_string(&self.inner)
+    }
     fn solid_border(&self) -> Option<([f32; 4], f32)> {
         Paint::solid_border(&self.inner)
     }
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index 877e2d5..35fcd5b 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -154,6 +154,8 @@ impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
     fn blocks_root_plate_drag(&self) -> bool { self.widget.blocks_root_plate_drag() }
     fn corner_style(&self) -> (f32, (bool, bool, bool, bool)) { self.widget.corner_style() }
     fn focus_role(&self) -> FocusRole { self.widget.focus_role() }
+    fn a11y_role(&self) -> Option<accesskit::Role> { self.widget.a11y_role() }
+    fn a11y_value(&self) -> Option<String> { self.widget.a11y_value() }
     fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
 }