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

src/backend/a11y_unix.rs (14.5K)

  1 //! The accessibility tree, published to screen readers on Linux: AT-SPI through
  2 //! `accesskit_unix` (`docs/rfc-accessibility-locale.md`, phase 2).
  3 //!
  4 //! The Wayland shell owns one [`Publisher`] per session for an app that asks for one
  5 //! ([`wanted`]: `Application::publishes_accessibility`, or `CCE_A11Y=1` for any app). The
  6 //! adapter calls back on ITS thread — a screen reader arrived, asked for something, or left —
  7 //! and each callback only posts an [`Event`] into the runner's loop, as an `AppSender` does;
  8 //! the loop does the work on its own thread:
  9 //!
 10 //! - **arrived** ([`Event::Activated`]): publish the whole tree NOW. AccessKit requires it "no
 11 //!   later than the next display refresh, even if a frame would not normally be rendered", and
 12 //!   an idle window renders nothing, so the runner publishes from the event itself.
 13 //! - **after every frame**: publish again ([`Publisher::publish`] builds nothing while no
 14 //!   screen reader is connected — `update_if_active`).
 15 //! - **asked** ([`Event::Action`]): [`act`] — focus a widget, press or step one (by the key a
 16 //!   keyboard user would press, through the app's own key handling), or press a context-menu
 17 //!   row.
 18 //! - **the window's keyboard focus**: [`Publisher::window_focus`].
 19 //!
 20 //! Without the `a11y` feature the same API compiles to a stub whose [`Publisher::start`] is
 21 //! `None`, so the runner carries no `cfg`.
 22 
 23 use crate::widget::WidgetHostExt;
 24 use accesskit::{Action, ActionData, ActionRequest};
 25 
 26 use crate::widget::NamedKey;
 27 
 28 use crate::backend::app::Application;
 29 
 30 /// `CCE_A11Y_DEBUG=1`: log each activation, action, publish and window-focus change to stderr.
 31 pub fn debug() -> bool {
 32     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 33     *ON.get_or_init(|| std::env::var_os("CCE_A11Y_DEBUG").is_some())
 34 }
 35 
 36 /// What the adapter's thread tells the runner's loop.
 37 #[derive(Debug)]
 38 pub enum Event {
 39     /// A screen reader is connected and wants the tree.
 40     Activated,
 41     /// It left; the adapter builds nothing until it is back.
 42     Deactivated,
 43     /// It asked for something.
 44     Action(ActionRequest),
 45 }
 46 
 47 /// Whether `app` publishes its tree: it says so, or `CCE_A11Y=1` says so for any app (to
 48 /// try one that has not opted in).
 49 pub fn wanted<A: Application>(app: &A) -> bool {
 50     app.publishes_accessibility() || std::env::var("CCE_A11Y").is_ok_and(|v| v == "1")
 51 }
 52 
 53 /// What [`act`] did, and what is left for the runner to do.
 54 #[derive(Debug, Clone, PartialEq, Eq)]
 55 pub enum Acted {
 56     /// Nothing: the target is gone, or the action is not one it takes.
 57     Nothing,
 58     /// Done; redraw and republish.
 59     Changed,
 60     /// The target is focused; now press this key as a keyboard user would
 61     /// (`Driver::press_named_key`), so the widget and the app answer it as they answer one.
 62     Key(NamedKey),
 63 }
 64 
 65 /// Carry out an assistive tool's request on `app`.
 66 ///
 67 /// - **Focus** on a widget's node focuses it, through the app's `UiContext` like a Tab step.
 68 /// - **Click** on a widget focuses it and presses Space: what activates a plate from the
 69 ///   keyboard (`FocusRole::Plate`), so the app learns of it the way it learns of a key.
 70 /// - **Increment / Decrement** focus a slider or spin button and press the key that steps
 71 ///   it: Right / Left on a slider (a `RangeSlider` uses Up / Down to change ends), Up / Down
 72 ///   on a spin button.
 73 /// - **SetValue** with a number (AT-SPI's `SetCurrentValue`, how a reader adjusts a slider
 74 ///   or spin button on Linux, where AccessKit offers no Increment) sets it on the widget
 75 ///   (`WidgetHost::a11y_set_value`); with text (`SetTextContents` on a text field's
 76 ///   EditableText) it replaces the field's text (`WidgetHost::a11y_set_text`). Either is
 77 ///   marked changed for the app's `take_change` ([`set_value`]). An app
 78 ///   that drains changes in `tick` sees it this turn; one that drains them only in its
 79 ///   input handlers sees it at the next input.
 80 /// - **Click** on a widget's item (a radio button, `a11y::A11yItem`) does what a press on it
 81 ///   does (`WidgetHost::a11y_select_item`) and puts the keyboard on its widget.
 82 /// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
 83 ///   the row's action exactly as a pointer would.
 84 /// - Anything on one of the app's own nodes (`Application::accessibility`) is the app's:
 85 ///   `Application::accessibility_action`, in its own terms (`a11y::AppAction`) — a reader
 86 ///   setting a field the app draws (`AppNodes::text_field`) is `AppAction::SetText`.
 87 ///
 88 /// Anything else does nothing, as AccessKit requires of an action the app cannot perform.
 89 /// A key reaches the widget only through the app's `handle_key_input`, as every key does —
 90 /// an app that does not route keys to its focused widget answers a reader as it answers a
 91 /// keyboard.
 92 pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
 93     use crate::widget::context_menu as cm;
 94     if debug() {
 95         eprintln!("[a11y] action {:?} on {:?}", request.action, request.target_node);
 96     }
 97     if let Some(row) = crate::a11y::menu_row_of(request.target_node) {
 98         if request.action != Action::Click || !cm::is_visible() || row >= cm::options().len() {
 99             return Acted::Nothing;
100         }
101         let (x, y) = (cm::x() + cm::w() * 0.5, cm::row_y(row) + cm::ROW_H * 0.5);
102         cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
103         return Acted::Changed;
104     }
105     if let Some((id, idx)) = crate::a11y::item_of(request.target_node) {
106         // A click on a widget's item (a radio button): what a press on it does, and the
107         // keyboard goes to its widget, as after a press.
108         if request.action != Action::Click {
109             return Acted::Nothing;
110         }
111         let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
112         if !ctx.get_widget_mut(id).is_some_and(|w| w.a11y_select_item(idx)) {
113             return Acted::Nothing;
114         }
115         if ctx.focused_widget != Some(id) {
116             ctx.set_focused_id(id);
117             app.focus_stepped();
118         }
119         return Acted::Changed;
120     }
121     if let Some(n) = crate::a11y::app_node_of(request.target_node) {
122         return if app_action(request).is_some_and(|action| app.accessibility_action(n, action)) { Acted::Changed } else { Acted::Nothing };
123     }
124     let Some(id) = crate::a11y::widget_of(request.target_node) else { return Acted::Nothing };
125     let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
126     if request.action == Action::SetValue {
127         return set_value(ctx, id, request.data.as_ref());
128     }
129     let Some(w) = ctx.get_widget(id) else { return Acted::Nothing };
130     let key = match request.action {
131         Action::Focus => None,
132         action => match crate::a11y::key_for(w, action) {
133             Some(key) => Some(key),
134             None => return Acted::Nothing,
135         },
136     };
137     if ctx.focused_widget != Some(id) {
138         ctx.set_focused_id(id);
139         app.focus_stepped();
140     }
141     key.map_or(Acted::Changed, Acted::Key)
142 }
143 
144 /// A request on one of the app's own nodes, in the app's terms (`Application::accessibility_action`).
145 fn app_action(request: &ActionRequest) -> Option<crate::a11y::AppAction> {
146     use crate::a11y::AppAction;
147     Some(match (request.action, request.data.as_ref()) {
148         (Action::Focus, _) => AppAction::Focus,
149         (Action::Click, _) => AppAction::Click,
150         (Action::SetValue, Some(ActionData::Value(text))) => AppAction::SetText(text.to_string()),
151         (Action::SetValue, Some(ActionData::NumericValue(value))) => AppAction::SetNumber(*value),
152         (Action::Increment, _) => AppAction::Increment,
153         (Action::Decrement, _) => AppAction::Decrement,
154         _ => return None,
155     })
156 }
157 
158 /// A reader's `SetValue` on widget `id`, where it is, focus untouched (a reader adjusting a
159 /// value has not moved): a number for a slider or spin button (`WidgetHost::a11y_set_value`),
160 /// text for a text field (AT-SPI's `SetTextContents`, `WidgetHost::a11y_set_text`).
161 pub fn set_value(ctx: &mut crate::context::UiContext, id: crate::widget::WidgetId, data: Option<&ActionData>) -> Acted {
162     let Some(w) = ctx.get_widget_mut(id) else { return Acted::Nothing };
163     let changed = match data {
164         Some(ActionData::NumericValue(value)) => w.a11y_set_value(*value),
165         Some(ActionData::Value(text)) => w.a11y_set_text(text),
166         _ => false,
167     };
168     if changed { Acted::Changed } else { Acted::Nothing }
169 }
170 
171 #[cfg(feature = "a11y")]
172 mod imp {
173     use super::Event;
174     use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
175 
176     use crate::backend::app::Application;
177 
178     /// The session's adapter.
179     pub struct Publisher {
180         adapter: accesskit_unix::Adapter,
181     }
182 
183     struct Activation(calloop::channel::Sender<Event>);
184     impl ActivationHandler for Activation {
185         fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
186             // The tree is the loop's to build; it publishes as this event arrives.
187             let _ = self.0.send(Event::Activated);
188             None
189         }
190     }
191 
192     struct Actions(calloop::channel::Sender<Event>);
193     impl ActionHandler for Actions {
194         fn do_action(&mut self, request: ActionRequest) {
195             let _ = self.0.send(Event::Action(request));
196         }
197     }
198 
199     struct Deactivation(calloop::channel::Sender<Event>);
200     impl DeactivationHandler for Deactivation {
201         fn deactivate_accessibility(&mut self) {
202             let _ = self.0.send(Event::Deactivated);
203         }
204     }
205 
206     impl Publisher {
207         /// Register with the accessibility bus; the adapter's callbacks post to `tx`.
208         pub fn start(tx: calloop::channel::Sender<Event>) -> Option<Publisher> {
209             let adapter = accesskit_unix::Adapter::new(Activation(tx.clone()), Actions(tx.clone()), Deactivation(tx));
210             log::info!("[a11y] publishing the accessibility tree over AT-SPI");
211             Some(Publisher { adapter })
212         }
213 
214         /// Hand the adapter `app`'s whole tree, if a screen reader is connected.
215         pub fn publish<A: Application>(&mut self, app: &mut A, scale: f64) {
216             self.adapter.update_if_active(|| {
217                 let t0 = web_time::Instant::now();
218                 let tree = crate::a11y::app_tree(app, scale);
219                 if super::debug() {
220                     eprintln!("[a11y] publish: {} nodes, focus {:?}, built in {:?}", tree.nodes.len(), tree.focus, t0.elapsed());
221                 }
222                 tree
223             });
224         }
225 
226         /// The window gained or lost the keyboard.
227         pub fn window_focus(&mut self, focused: bool) {
228             if super::debug() {
229                 eprintln!("[a11y] window focus: {focused}");
230             }
231             self.adapter.update_window_focus_state(focused);
232         }
233     }
234 }
235 
236 #[cfg(not(feature = "a11y"))]
237 mod imp {
238     use super::Event;
239     use crate::backend::app::Application;
240 
241     /// Built without the `a11y` feature: nothing is published.
242     pub enum Publisher {}
243 
244     impl Publisher {
245         pub fn start(_tx: calloop::channel::Sender<Event>) -> Option<Publisher> {
246             None
247         }
248         pub fn publish<A: Application>(&mut self, _app: &mut A, _scale: f64) {
249             match *self {}
250         }
251         pub fn window_focus(&mut self, _focused: bool) {
252             match *self {}
253         }
254     }
255 }
256 
257 pub use imp::Publisher;
258 
259 #[cfg(test)]
260 mod tests {
261     use super::*;
262     use crate::context::UiContext;
263     use crate::widget::{Spinbox, TextBox};
264 
265     /// A reader's SetValue reaches a text field as text (AT-SPI's `SetTextContents`) and a
266     /// spin button as a number; each is reported as the user's change would be, and neither
267     /// takes the other's kind. The same text again, or a disabled field, changes nothing.
268     #[test]
269     fn a_reader_sets_a_field_by_text_and_a_spin_button_by_number() {
270         let mut ctx = UiContext::new();
271         let name = ctx.insert(TextBox::new("Ada".to_string()));
272         let count = ctx.insert(Spinbox::new(3, 0, 10, 1));
273         let text = |s: &str| ActionData::Value(s.into());
274 
275         assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Grace"))), Acted::Changed);
276         assert_eq!(ctx[name].text, "Grace");
277         assert!(ctx[name].take_change(), "the app hears of it as of a typed change");
278         assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Grace"))), Acted::Nothing, "unchanged");
279         assert_eq!(set_value(&mut ctx, name.id(), Some(&ActionData::NumericValue(4.0))), Acted::Nothing);
280 
281         assert_eq!(set_value(&mut ctx, count.id(), Some(&ActionData::NumericValue(7.0))), Acted::Changed);
282         assert_eq!(set_value(&mut ctx, count.id(), Some(&text("2"))), Acted::Nothing, "a spin button is set by number");
283 
284         // Being edited, the box takes it as a replacement it can undo, the caret at its end.
285         ctx.set_focused_id(name.id());
286         assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Ada Lovelace"))), Acted::Changed);
287         let t = ctx[name].a11y_text().unwrap();
288         assert_eq!((t.text.as_str(), t.selection), ("Ada Lovelace", Some((12, 12))));
289         assert!(ctx[name].context_action(crate::widget::ContextAction::Undo), "undoable");
290         assert_eq!(ctx[name].a11y_text().unwrap().text, "Grace");
291 
292         // A colour selector takes a colour as text, and nothing that is not one.
293         let accent = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]));
294         assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("#00ff00"))), Acted::Changed);
295         assert_eq!(ctx[accent].a11y_value().as_deref(), Some("#00ff00"));
296         assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("green-ish"))), Acted::Nothing);
297 
298         ctx[name].disabled = true;
299         assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Hopper"))), Acted::Nothing, "a disabled field");
300         assert_eq!(set_value(&mut ctx, crate::widget::WidgetId(usize::MAX), Some(&text("x"))), Acted::Nothing, "nothing there");
301     }
302 
303     /// What a reader asks of an app's own node reaches the app in its own terms.
304     #[test]
305     fn a_request_on_an_apps_node_is_the_apps_action() {
306         use crate::a11y::AppAction;
307         let req = |action, data| ActionRequest { action, target_tree: accesskit::TreeId::ROOT, target_node: crate::a11y::AppNodes::id(3), data };
308         assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::Value("x.org".into())))), Some(AppAction::SetText("x.org".into())));
309         assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::NumericValue(2.0)))), Some(AppAction::SetNumber(2.0)));
310         assert_eq!(app_action(&req(Action::Focus, None)), Some(AppAction::Focus));
311         assert_eq!(app_action(&req(Action::ScrollIntoView, None)), None);
312     }
313 }