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 }