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

commitd704c8ba87b36e6c2773dc4835020e5e4bef3f15
parent0633ea44fc
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 10:36
feat(a11y): publish the accessibility tree over AT-SPI (phase 2, Linux)

backend::a11y_unix runs accesskit_unix in the Wayland shell behind the new
`a11y` feature, for an app that returns true from
Application::publishes_accessibility (or any app under CCE_A11Y=1). The
adapter's callbacks only post into the calloop loop, which publishes the
whole tree when a reader connects and after every frame (nothing is built
while none is connected), answers Focus on a widget and Click on a context
menu row, and forwards the keyboard's enter/leave as window focus.
Widgets parked off-screen or without a size are left out of the tree.
CCE_A11Y_DEBUG=1 logs connections, actions, publishes and focus changes.

Proven on cce-data-editor in a shadow session through Atspi: roles,
values, grab_focus moving the app's focus and reading back FOCUSED, Tab
steps republished; a 21-node tree builds in 50-100 us (debug build).

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

 CLAUDE.md                        |  18 +++--
 Cargo.toml                       |   6 ++
 docs/rfc-accessibility-locale.md |  24 ++++++
 src/a11y.rs                      |  48 ++++++++++-
 src/backend/a11y_unix.rs         | 171 +++++++++++++++++++++++++++++++++++++++
 src/backend/app.rs               |   7 ++
 src/backend/mod.rs               |   2 +
 src/backend/window_runner.rs     |  50 +++++++++++-
 8 files changed, 319 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 000efd1..964c7e6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1564,10 +1564,12 @@ the ladder should be supplying, and the audit counts them.
 
 ## Accessibility and locale are on the roadmap (read `docs/rfc-accessibility-locale.md`)
 
-There is no accessibility support yet (no AT-SPI, AccessKit, NSAccessibility or ARIA), no
-message catalogue, and the editors assume left-to-right text (`TextBox`
-assumes monospace too). The RFC has the measured state and a phased plan. Until it lands,
-two rules keep the retrofit cheap:
+Accessibility reaches Linux screen readers only, and only for an app that opts in: the
+widget tree is published over AT-SPI through AccessKit (`backend::a11y_unix`, the `a11y`
+feature, `Application::publishes_accessibility` or `CCE_A11Y=1`; phase 2, proven on
+cce-data-editor). There is nothing yet on macOS or in the browser, no message catalogue,
+and the editors assume left-to-right text (`TextBox` assumes monospace too). The RFC has
+the measured state and a phased plan. Until it lands, two rules keep the retrofit cheap:
 
 - **A new widget declares what it is**: its `focus_role`, and a label that names it to a
   person (not to a host).
@@ -1711,7 +1713,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
   window's `TreeUpdate` — its registered widgets (role, name, value, bounds, actions, focus;
   `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself), the nodes an
   app without widgets declares (`Application::accessibility`, `AppNodes`), and an open context
-  menu. No platform adapter yet (`docs/rfc-accessibility-locale.md`, phase 2).
+  menu. `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
+  `docs/rfc-accessibility-locale.md`, phase 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").
@@ -2192,6 +2195,11 @@ All opt-in, all read once, all quiet when unset — set one and run any client.
   its grouping window (correctly — the carve's shading is baked into the plate's earlier
   draw).
 - `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
+- `CCE_A11Y=1` — publish the accessibility tree over AT-SPI for an app that has not opted in
+  (`Application::publishes_accessibility`; needs the `a11y` feature). `CCE_A11Y_DEBUG=1` logs
+  each reader connection, action, publish (node count, focus, build time) and window-focus
+  change. A shadow window holds no keyboard until `ctl focus-window <app_id>`, and nothing
+  reads FOCUSED until it does. See `docs/rfc-accessibility-locale.md`, phase 2.
 - `CCE_UI_MENU_POPUP=0` — keep the context menu in the window instead of its popup
   surface (see "The context menu draws in its own popup surface").
 - `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
diff --git a/Cargo.toml b/Cargo.toml
index c95df34..9dd2fbf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,11 @@ markdown = ["dep:cce-vault"]
 # `widget::doc_editor` (DocEditor): the line-buffer document editor with
 # Markdown live preview (cce-notes' editing mode). No extra dependencies.
 doc_editor = []
+# Publish each window's accessibility tree to screen readers through AT-SPI
+# (accesskit_unix, in the Wayland shell; rfc-accessibility-locale phase 2). An
+# app must still opt in at run time (`Application::publishes_accessibility`, or
+# CCE_A11Y=1), since a workspace build turns a feature on for every app at once.
+a11y = ["dep:accesskit_unix"]
 
 [dependencies]
 # The GUI-free half: config, input, motion, units, ipc and the spec parsers,
@@ -78,6 +83,7 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
 wayland-backend = "0.3"
 wayland-scanner = "0.31"
 xkeysym = "0.2"
+accesskit_unix = { version = "0.24", optional = true }
 
 # The AppKit shell (`src/mac`): the window, its CAMetalLayer, the run loop.
 [target.'cfg(target_os = "macos")'.dependencies]
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index ab76d79..d67d609 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -117,6 +117,30 @@ focus and click actions, children in reading order, and the context's focus.
 
 ### Phase 2 — speak to the platform
 
+**Progress (2026-10-08): Linux works, on one app.** `backend::a11y_unix` runs `accesskit_unix`
+in the Wayland shell behind the `a11y` feature (23 crates, zbus among them; nothing without
+it), for an app that returns true from `Application::publishes_accessibility` or any app run
+with `CCE_A11Y=1`. The adapter's callbacks only post into the runner's calloop loop; the
+loop publishes the whole tree when a reader connects (an idle window renders nothing, and
+AccessKit wants it by the next refresh), after every frame (`update_if_active`: nothing is
+built while no reader is connected), and answers `Focus` on a widget (`UiContext::
+set_focused_id`) and `Click` on a context-menu row (a press where it is drawn). The window's
+keyboard enter / leave is AccessKit's window focus, so a node reads FOCUSED only while the
+window has the keyboard. Widgets parked off-screen or without a size are left out.
+`CCE_A11Y_DEBUG=1` logs each connection, action, publish (with its build time) and focus change.
+
+Proven on cce-data-editor in a shadow session through `pyatspi`'s `Atspi` (no screen reader
+is installed), with the session's `org.a11y.Status IsEnabled` set for the run: the app is
+listed with toolkit `cce-ui`; its frame holds buttons, combo boxes, entries, spin buttons
+with values, a check box, the menu bar and the tree with their roles; `grab_focus` on a
+button focuses it in the window (its ring drawn) and reads back FOCUSED; Tab steps are
+republished. A tree of 21 nodes builds in 50–100 µs in a debug build.
+
+What it showed is the apps' to fix, not the adapter's: most of the data editor's controls
+have no label (an unlabelled field is an empty name to a reader), and its inactive editors
+are registered and visible though not drawn. Next: a widget `Click` (activate), the macOS
+adapter onto the AppKit view, and listening with Orca.
+
 - **Wayland / Linux:** AT-SPI over D-Bus. AccessKit's Unix adapter is the likely carrier
   (evaluate it first; the alternative is a small AT-SPI server of our own). The compositor
   needs nothing new: AT-SPI is a session-bus protocol between the app and the reader.
diff --git a/src/a11y.rs b/src/a11y.rs
index e1975f7..cb1e3ca 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -39,6 +39,16 @@ use crate::widget::{FocusRole, WidgetHost, WidgetId};
 /// The window's node, the root every widget hangs from.
 pub const WINDOW: NodeId = NodeId(0);
 
+/// The widget a node is, when it is a widget's ([`node_id`]'s inverse).
+pub fn widget_of(node: NodeId) -> Option<WidgetId> {
+    (node.0 > 0 && node.0 < APP_BASE).then(|| WidgetId((node.0 - 1) as usize))
+}
+
+/// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
+pub fn menu_row_of(node: NodeId) -> Option<usize> {
+    (node.0 > MENU.0).then(|| (node.0 - MENU.0 - 1) as usize)
+}
+
 /// The open context menu's node, and the first of its rows' (`MENU + 1 + row`): far above any
 /// widget's id.
 pub const MENU: NodeId = NodeId(1 << 62);
@@ -160,6 +170,15 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
     node
 }
 
+/// Whether a widget is on screen: visible, with a size, and not parked. Apps park a widget
+/// they are not showing far off the window (x or y below -9000, the toolkit's sentinel —
+/// cce-data-editor's per-type value editors) instead of hiding it; a reader must not see
+/// those either.
+fn shown_on_screen(w: &dyn WidgetHost) -> bool {
+    let (x, y, width, height) = w.rect();
+    w.visible() && width > 0.0 && height > 0.0 && x > -9000.0 && y > -9000.0
+}
+
 /// 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() {
@@ -203,7 +222,7 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
         .tree
         .iter_registered()
         .map(|(id, ptr)| (id, unsafe { &*ptr } as &dyn WidgetHost))
-        .filter(|(_, w)| w.visible())
+        .filter(|(_, w)| shown_on_screen(*w))
         .collect();
     let shown: std::collections::HashSet<WidgetId> = widgets.iter().map(|(id, _)| *id).collect();
 
@@ -434,6 +453,33 @@ mod tests {
         assert!(bar != WINDOW && bar != MENU && bar.0 > node_id(WidgetId(usize::MAX >> 4)).0, "its own id range");
     }
 
+    #[test]
+    fn parked_and_sizeless_widgets_are_not_on_screen() {
+        let mut ctx = UiContext::new();
+        let mut parked = Owned::new(Button::new(-10_000.0, 0.0, 80.0, 24.0).with_label("Parked"));
+        let mut empty = Owned::new(Button::new(10.0, 10.0, 0.0, 0.0).with_label("Empty"));
+        let mut shown = Owned::new(Button::new(10.0, 10.0, 80.0, 24.0).with_label("Shown"));
+        ctx.register_host(&mut parked);
+        ctx.register_host(&mut empty);
+        ctx.register_host(&mut shown);
+        let update = tree_update(&ctx, "", 1.0);
+        assert_eq!(node(&update, WINDOW).children(), &[node_id(shown.base().id())][..]);
+    }
+
+    #[test]
+    fn a_node_id_names_back_what_it_was_made_from() {
+        for id in [WidgetId(0), WidgetId(1), WidgetId(123_456)] {
+            assert_eq!(widget_of(node_id(id)), Some(id));
+        }
+        for row in [0, 1, 40] {
+            assert_eq!(menu_row_of(menu_row_id(row)), Some(row));
+            assert_eq!(widget_of(menu_row_id(row)), None, "a menu row is no widget");
+        }
+        assert_eq!(widget_of(WINDOW), None);
+        assert_eq!((widget_of(MENU), menu_row_of(MENU)), (None, None));
+        assert_eq!((widget_of(AppNodes::id(5)), menu_row_of(AppNodes::id(5))), (None, None), "an app's own node is neither");
+    }
+
     #[test]
     fn a_widget_that_says_what_it_is_is_believed() {
         assert_eq!(role_for("Button", FocusRole::Plate, Some(Role::Tab)), Role::Tab);
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
new file mode 100644
index 0000000..bff4428
--- /dev/null
+++ b/src/backend/a11y_unix.rs
@@ -0,0 +1,171 @@
+//! The accessibility tree, published to screen readers on Linux: AT-SPI through
+//! `accesskit_unix` (`docs/rfc-accessibility-locale.md`, phase 2).
+//!
+//! The Wayland shell owns one [`Publisher`] per session for an app that asks for one
+//! ([`wanted`]: `Application::publishes_accessibility`, or `CCE_A11Y=1` for any app). The
+//! adapter calls back on ITS thread — a screen reader arrived, asked for something, or left —
+//! and each callback only posts an [`Event`] into the runner's loop, as an `AppSender` does;
+//! the loop does the work on its own thread:
+//!
+//! - **arrived** ([`Event::Activated`]): publish the whole tree NOW. AccessKit requires it "no
+//!   later than the next display refresh, even if a frame would not normally be rendered", and
+//!   an idle window renders nothing, so the runner publishes from the event itself.
+//! - **after every frame**: publish again ([`Publisher::publish`] builds nothing while no
+//!   screen reader is connected — `update_if_active`).
+//! - **asked** ([`Event::Action`]): [`act`] — focus a widget, or press a context-menu row.
+//! - **the window's keyboard focus**: [`Publisher::window_focus`].
+//!
+//! Without the `a11y` feature the same API compiles to a stub whose [`Publisher::start`] is
+//! `None`, so the runner carries no `cfg`.
+
+use accesskit::{Action, ActionRequest};
+
+use crate::backend::app::Application;
+
+/// `CCE_A11Y_DEBUG=1`: log each activation, action, publish and window-focus change to stderr.
+pub fn debug() -> bool {
+    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *ON.get_or_init(|| std::env::var_os("CCE_A11Y_DEBUG").is_some())
+}
+
+/// What the adapter's thread tells the runner's loop.
+#[derive(Debug)]
+pub enum Event {
+    /// A screen reader is connected and wants the tree.
+    Activated,
+    /// It left; the adapter builds nothing until it is back.
+    Deactivated,
+    /// It asked for something.
+    Action(ActionRequest),
+}
+
+/// Whether `app` publishes its tree: it says so, or `CCE_A11Y=1` says so for any app (to
+/// try one that has not opted in).
+pub fn wanted<A: Application>(app: &A) -> bool {
+    app.publishes_accessibility() || std::env::var("CCE_A11Y").is_ok_and(|v| v == "1")
+}
+
+/// Carry out an assistive tool's request on `app`; true when something changed (redraw).
+///
+/// - **Focus** on a widget's node focuses it, through the app's `UiContext` like a Tab step.
+/// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
+///   the row's action exactly as a pointer would.
+///
+/// Anything else is not supported yet and does nothing, as AccessKit requires of an action
+/// the app cannot perform (a click on a widget is the next step).
+pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> bool {
+    use crate::widget::context_menu as cm;
+    if debug() {
+        eprintln!("[a11y] action {:?} on {:?}", request.action, request.target_node);
+    }
+    match request.action {
+        Action::Focus => {
+            let (Some(id), Some(ctx)) = (crate::a11y::widget_of(request.target_node), app.ui_context_mut()) else {
+                return false;
+            };
+            if ctx.tree.get_ptr(id).is_none() {
+                return false;
+            }
+            ctx.set_focused_id(id);
+            true
+        }
+        Action::Click => {
+            let Some(row) = crate::a11y::menu_row_of(request.target_node) else { return false };
+            if !cm::is_visible() || row >= cm::options().len() {
+                return false;
+            }
+            let (x, y) = (cm::x() + cm::w() * 0.5, cm::row_y(row) + cm::ROW_H * 0.5);
+            cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
+            true
+        }
+        _ => false,
+    }
+}
+
+#[cfg(feature = "a11y")]
+mod imp {
+    use super::Event;
+    use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
+
+    use crate::backend::app::Application;
+
+    /// The session's adapter.
+    pub struct Publisher {
+        adapter: accesskit_unix::Adapter,
+    }
+
+    struct Activation(calloop::channel::Sender<Event>);
+    impl ActivationHandler for Activation {
+        fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
+            // The tree is the loop's to build; it publishes as this event arrives.
+            let _ = self.0.send(Event::Activated);
+            None
+        }
+    }
+
+    struct Actions(calloop::channel::Sender<Event>);
+    impl ActionHandler for Actions {
+        fn do_action(&mut self, request: ActionRequest) {
+            let _ = self.0.send(Event::Action(request));
+        }
+    }
+
+    struct Deactivation(calloop::channel::Sender<Event>);
+    impl DeactivationHandler for Deactivation {
+        fn deactivate_accessibility(&mut self) {
+            let _ = self.0.send(Event::Deactivated);
+        }
+    }
+
+    impl Publisher {
+        /// Register with the accessibility bus; the adapter's callbacks post to `tx`.
+        pub fn start(tx: calloop::channel::Sender<Event>) -> Option<Publisher> {
+            let adapter = accesskit_unix::Adapter::new(Activation(tx.clone()), Actions(tx.clone()), Deactivation(tx));
+            log::info!("[a11y] publishing the accessibility tree over AT-SPI");
+            Some(Publisher { adapter })
+        }
+
+        /// Hand the adapter `app`'s whole tree, if a screen reader is connected.
+        pub fn publish<A: Application>(&mut self, app: &mut A, scale: f64) {
+            self.adapter.update_if_active(|| {
+                let t0 = web_time::Instant::now();
+                let tree = crate::a11y::app_tree(app, scale);
+                if super::debug() {
+                    eprintln!("[a11y] publish: {} nodes, focus {:?}, built in {:?}", tree.nodes.len(), tree.focus, t0.elapsed());
+                }
+                tree
+            });
+        }
+
+        /// The window gained or lost the keyboard.
+        pub fn window_focus(&mut self, focused: bool) {
+            if super::debug() {
+                eprintln!("[a11y] window focus: {focused}");
+            }
+            self.adapter.update_window_focus_state(focused);
+        }
+    }
+}
+
+#[cfg(not(feature = "a11y"))]
+mod imp {
+    use super::Event;
+    use crate::backend::app::Application;
+
+    /// Built without the `a11y` feature: nothing is published.
+    pub enum Publisher {}
+
+    impl Publisher {
+        pub fn start(_tx: calloop::channel::Sender<Event>) -> Option<Publisher> {
+            None
+        }
+        pub fn publish<A: Application>(&mut self, _app: &mut A, _scale: f64) {
+            match *self {}
+        }
+        pub fn window_focus(&mut self, _focused: bool) {
+            match *self {}
+        }
+    }
+}
+
+pub use imp::Publisher;
diff --git a/src/backend/app.rs b/src/backend/app.rs
index cf5ff20..4916dd8 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -334,6 +334,13 @@ pub trait Application: Sized + 'static {
         None
     }
     
+    /// Publish this app's accessibility tree to screen readers (AT-SPI on Wayland), with
+    /// cce-ui built with its `a11y` feature. Default false while the adapter is proven
+    /// (`docs/rfc-accessibility-locale.md`, phase 2); `CCE_A11Y=1` turns it on for any app.
+    fn publishes_accessibility(&self) -> bool {
+        false
+    }
+
     /// What the app shows that its [`ui_context`](Self::ui_context) does not: the nodes of
     /// an accessibility tree, for an app that draws without widgets (a status bar module, a
     /// terminal, a map) or draws parts of its window itself. Pushed as AccessKit nodes into
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 9462179..d56ac90 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -9,6 +9,8 @@ pub mod text;
 pub mod touch;
 // The Wayland shell: native, but for macOS.
 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+pub mod a11y_unix;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 pub mod dnd;
 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 pub mod menu_popup;
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index b6df778..693787c 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -63,6 +63,9 @@ fn debug_clock_ms() -> u128 {
 }
 
 pub struct EngineState<A: Application> {
+    /// The session's accessibility publisher, for an app that publishes its tree
+    /// (`backend::a11y_unix`).
+    pub a11y: Option<crate::backend::a11y_unix::Publisher>,
     pub registry_state: RegistryState,
     pub compositor_state: CompositorState,
     pub xdg_shell_state: XdgShell,
@@ -556,6 +559,11 @@ impl<A: Application> EngineState<A> {
             self.damage_owed = false;
         }
         self.sync_text_input();
+        // The tree after the frame that may have changed it; nothing is built while no
+        // screen reader is connected.
+        if let (Some(publisher), Some(app)) = (self.a11y.as_mut(), self.inner.as_mut()) {
+            publisher.publish(app, crate::scale::scale_factor() as f64);
+        }
     }
 
     /// Bring the text input in step with the frame just built: enabled at
@@ -1324,6 +1332,9 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         _raw_modifiers: &[u32],
         _keysyms: &[xkeysym::Keysym],
     ) {
+        if let Some(publisher) = self.a11y.as_mut() {
+            publisher.window_focus(true);
+        }
         let (driver, t) = self.turn();
         driver.keyboard_focus(t, true);
     }
@@ -1336,6 +1347,9 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         _surface: &wl_surface::WlSurface,
         _serial: u32,
     ) {
+        if let Some(publisher) = self.a11y.as_mut() {
+            publisher.window_focus(false);
+        }
         let (driver, t) = self.turn();
         driver.keyboard_focus(t, false);
     }
@@ -1892,6 +1906,9 @@ pub fn run<A: Application>() {
     // loop outlives a reconnect while the EngineState does not.
     let (drop_tx, drop_rx) =
         calloop::channel::channel::<crate::backend::dnd::DroppedData>();
+    // The accessibility adapter's callbacks (`backend::a11y_unix`), from its own thread;
+    // registered once, as the loop outlives a reconnect.
+    let (a11y_tx, a11y_rx) = calloop::channel::channel::<crate::backend::a11y_unix::Event>();
     let mut event_loop = match EventLoop::try_new() {
         Ok(l) => l,
         Err(e) => {
@@ -1932,6 +1949,32 @@ pub fn run<A: Application>() {
         })
         .unwrap();
 
+    event_loop
+        .handle()
+        .insert_source(a11y_rx, |event, _metadata, app_state: &mut EngineState<A>| {
+            use crate::backend::a11y_unix::{act, Event};
+            let calloop::channel::Event::Msg(event) = event else { return };
+            let (Some(publisher), Some(app)) = (app_state.a11y.as_mut(), app_state.inner.as_mut()) else { return };
+            match event {
+                // Published from here, not at the next frame: an idle window renders none,
+                // and AccessKit wants the tree by the next refresh.
+                Event::Activated => {
+                    if crate::backend::a11y_unix::debug() {
+                        eprintln!("[a11y] a screen reader connected");
+                    }
+                    publisher.publish(app, crate::scale::scale_factor() as f64)
+                }
+                Event::Deactivated => {}
+                Event::Action(request) => {
+                    if act(app, &request) {
+                        app_state.redraw = true;
+                        publisher.publish(app, crate::scale::scale_factor() as f64);
+                    }
+                }
+            }
+        })
+        .unwrap();
+
     let mut app: Option<A> = None;
     let mut sources_registered = false;
     let mut attempt: u32 = 0;
@@ -1939,7 +1982,7 @@ pub fn run<A: Application>() {
     loop {
         let started = std::time::Instant::now();
         let (returned_app, end) =
-            run_session(&mut event_loop, sender.clone(), drop_tx.clone(), app.take(), !sources_registered);
+            run_session(&mut event_loop, sender.clone(), drop_tx.clone(), a11y_tx.clone(), app.take(), !sources_registered);
         app = returned_app;
         sources_registered = true;
 
@@ -1990,6 +2033,7 @@ fn run_session<'l, A: Application>(
     event_loop: &mut EventLoop<'l, EngineState<A>>,
     sender: calloop::channel::Sender<A::Message>,
     drop_tx: calloop::channel::Sender<crate::backend::dnd::DroppedData>,
+    a11y_tx: calloop::channel::Sender<crate::backend::a11y_unix::Event>,
     existing_app: Option<A>,
     register_app_sources: bool,
 ) -> (Option<A>, SessionEnd) {
@@ -2020,6 +2064,7 @@ fn run_session<'l, A: Application>(
     let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind(&qh, 1..=1, ()).ok();
 
     let mut engine_state = EngineState {
+        a11y: None,
         data_device_manager: DataDeviceManagerState::bind(&globals, &qh).ok(),
         data_devices: Vec::new(),
         drag_mime: None,
@@ -2107,6 +2152,9 @@ fn run_session<'l, A: Application>(
         Some(app) => app,
         None => A::create(AppSender::from(engine_state.sender.clone())),
     };
+    if crate::backend::a11y_unix::wanted(&inner) {
+        engine_state.a11y = crate::backend::a11y_unix::Publisher::start(a11y_tx);
+    }
     let settings = inner.settings();
     crate::scale::set_app_id(settings.app_id.clone());
     engine_state.is_status_bar = settings.app_id.starts_with("cce-status");