GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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() }
}