GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/a11y.rs (43.8K)
1 //! The accessibility tree: what a window's widgets are, for screen readers and other
2 //! assistive technology (`docs/rfc-accessibility-locale.md`, phase 1).
3 //!
4 //! The tree is AccessKit's (§ 5 of the RFC): [`tree_update`] turns a [`UiContext`] into an
5 //! [`accesskit::TreeUpdate`] that a platform adapter publishes — AT-SPI on Wayland, NSAccessibility
6 //! on macOS — and nothing here knows which. A node per registered, visible widget, under one
7 //! window node:
8 //!
9 //! - **role** — the widget's explicit [`WidgetHost::a11y_role`], else [`role_for`]'s guess from
10 //! its type and its [`FocusRole`];
11 //! - **name** — its label;
12 //! - **value** — [`WidgetHost::a11y_value`] (a widget's `value_string`): a check box or switch
13 //! as toggled, a slider, spin button or progress bar as a number, anything else as text;
14 //! a text field ([`WidgetHost::a11y_text`]) has its text as TEXT RUNS instead, one per
15 //! line, with its caret and selection — what gives it AT-SPI's Text and EditableText
16 //! interfaces, so a reader reads it by character and line and can set it;
17 //! - **bounds** — its rect in LOGICAL px, the window node carrying the HiDPI scale as its
18 //! transform, so no widget's bounds change with the scale;
19 //! - **actions** — focus for every keyboard stop, click for every [`FocusRole::Plate`];
20 //! - **focus** — the context's focused widget, else the window.
21 //!
22 //! An open context menu (`widget::context_menu`, one per thread, whatever app shows it) is a
23 //! [`Role::Menu`] under the window, its rows items: a `✓` row a checkable item, a `●` / `○`
24 //! row a radio item (toggled as marked), a slider row a slider with its number, a header a
25 //! label; `-` separators are left out. While it is open the keyboard is in it, so the focus
26 //! is its highlighted row, else the menu.
27 //!
28 //! An app that draws without a [`UiContext`] (the status bar, the terminal, the map…) says
29 //! what it shows through [`Application::accessibility`](crate::backend::app::Application::accessibility),
30 //! pushing AccessKit nodes into [`AppNodes`]; [`app_tree`] puts both halves and the menu
31 //! under one window.
32 //!
33 //! Every call answers the whole tree. AccessKit's adapters compare it with the last one and
34 //! raise events only for nodes that changed; sending changed nodes alone is an optimisation
35 //! for later, with `backend::frame`'s damage diff as its model.
36
37 use accesskit::{Action, Affine, Node, NodeId, Rect, Role, TextPosition, TextSelection, Toggled, TreeId, TreeInfo, TreeUpdate};
38
39 use crate::context::UiContext;
40 use crate::widget::{FocusRole, NamedKey, WidgetHost, WidgetId, WidgetHostExt};
41
42 /// The window's node, the root every widget hangs from.
43 pub const WINDOW: NodeId = NodeId(0);
44
45 /// The widget a node is, when it is a widget's ([`node_id`]'s inverse).
46 pub fn widget_of(node: NodeId) -> Option<WidgetId> {
47 (node.0 > 0 && node.0 < ITEM_BASE).then(|| WidgetId((node.0 - 1) as usize))
48 }
49
50 /// A part of a widget a screen reader sees as a node of its own, under the widget's node:
51 /// a radio group's radio buttons (`Input::a11y_items`). A click on it is
52 /// `Input::a11y_select_item`.
53 #[derive(Debug, Clone, PartialEq)]
54 pub struct A11yItem {
55 pub role: Role,
56 pub label: String,
57 /// Where it is, in window px.
58 pub rect: crate::scene::layout::Rect,
59 /// Its checked state, for a radio button or a check item.
60 pub toggled: Option<bool>,
61 /// Whether the keyboard is on it: the widget's focus, given to this item.
62 pub focused: bool,
63 }
64
65 /// Where widgets' items begin: `ITEM_BASE + (widget id << 16) + index`, below the apps' own
66 /// nodes and above every widget's.
67 const ITEM_BASE: u64 = 1 << 60;
68
69 /// A text field's text as a reader reads and edits it (`Input::a11y_text`).
70 #[derive(Debug, Clone, PartialEq, Default)]
71 pub struct A11yText {
72 /// What the field shows: a password field's as one bullet a character, never the secret.
73 pub text: String,
74 /// While it is being edited, the selection's anchor and its focus (the caret), as char
75 /// indices into `text`; equal for a bare caret.
76 pub selection: Option<(usize, usize)>,
77 pub multiline: bool,
78 pub password: bool,
79 /// Whether a reader may set it (a disabled field may not).
80 pub editable: bool,
81 /// What it shows while empty ("Search..."): a hint, and the field's only words when it
82 /// has no label.
83 pub placeholder: Option<String>,
84 /// What kind of field it is, in words, when "text field" says too little: a colour
85 /// selector's hex field is a "colour". Published as the node's description, not a role
86 /// description: AccessKit makes a node with one AT-SPI's `Extended` role, and such a
87 /// node never registered on the bus (2026-10-09) — the field was gone from the reader.
88 pub kind: Option<String>,
89 }
90
91 /// A text field's text runs take the item indices from here up, one per line, so they never
92 /// meet a widget's own items ([`A11yItem`]), which stay below.
93 const RUN_BASE: usize = 0x8000;
94
95 /// The node of line `line`'s text run in widget `id`'s field ([`A11yText`]).
96 pub fn text_run_id(id: WidgetId, line: usize) -> NodeId {
97 item_id(id, RUN_BASE + line.min(RUN_BASE - 1))
98 }
99
100 /// A field's text as AccessKit's text runs: one per line, each line's break at its end, the
101 /// characters as the field counts them (chars). A text ending in a break has an empty last
102 /// line, where a caret after it stands. Past `RUN_BASE` lines the rest is one run.
103 fn text_lines(text: &str) -> Vec<&str> {
104 let mut lines: Vec<&str> = text.split_inclusive('\n').collect();
105 if text.is_empty() || text.ends_with('\n') {
106 lines.push("");
107 }
108 if lines.len() > RUN_BASE {
109 let start: usize = lines[..RUN_BASE - 1].iter().map(|l| l.len()).sum();
110 lines.truncate(RUN_BASE - 1);
111 lines.push(&text[start..]);
112 }
113 lines
114 }
115
116 /// Char index `at` of a field's text as a position in its runs, named by `run_id`.
117 fn run_position(run_id: &impl Fn(usize) -> NodeId, lines: &[&str], at: usize) -> TextPosition {
118 let mut start = 0;
119 for (i, line) in lines.iter().enumerate() {
120 let len = line.chars().count();
121 if at < start + len || i + 1 == lines.len() {
122 return TextPosition { node: run_id(i), character_index: at.saturating_sub(start).min(len) };
123 }
124 start += len;
125 }
126 TextPosition { node: run_id(0), character_index: 0 }
127 }
128
129 /// A field's text runs, in order, named by `run_id` (the line's number).
130 fn run_nodes(text: &A11yText, run_id: impl Fn(usize) -> NodeId) -> Vec<(NodeId, Node)> {
131 text_lines(&text.text)
132 .into_iter()
133 .enumerate()
134 .map(|(i, line)| {
135 let mut run = Node::new(Role::TextRun);
136 run.set_value(line);
137 run.set_character_lengths(line.chars().map(|c| c.len_utf8() as u8).collect::<Vec<u8>>());
138 (run_id(i), run)
139 })
140 .collect()
141 }
142
143 /// The text run nodes of widget `id`'s field, in order.
144 pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
145 run_nodes(text, |line| text_run_id(id, line))
146 }
147
148 /// The role a field that publishes `text` has: a text input of its kind, whatever the
149 /// widget would be otherwise — only a text input has AT-SPI's EditableText.
150 fn text_field_role(text: &A11yText) -> Role {
151 if text.password {
152 Role::PasswordInput
153 } else if text.multiline {
154 Role::MultilineTextInput
155 } else {
156 Role::TextInput
157 }
158 }
159
160 /// What a field's node says of its text, beside the runs that hold it: the selection in
161 /// them, its hint and kind, and whether a reader may set it.
162 fn describe_text_field(node: &mut Node, text: &A11yText, run_id: impl Fn(usize) -> NodeId) {
163 if let Some((anchor, focus)) = text.selection {
164 let lines = text_lines(&text.text);
165 node.set_text_selection(TextSelection { anchor: run_position(&run_id, &lines, anchor), focus: run_position(&run_id, &lines, focus) });
166 }
167 if let Some(hint) = text.placeholder.as_deref().filter(|h| !h.is_empty()) {
168 node.set_placeholder(hint);
169 }
170 if let Some(kind) = text.kind.as_deref().filter(|k| !k.is_empty()) {
171 node.set_description(kind);
172 }
173 if text.editable {
174 node.add_action(Action::SetValue);
175 } else {
176 node.set_read_only();
177 }
178 }
179
180 /// The node of item `idx` of widget `id` ([`A11yItem`]).
181 pub fn item_id(id: WidgetId, idx: usize) -> NodeId {
182 NodeId(ITEM_BASE + ((id.0 as u64) << 16) + (idx as u64 & 0xffff))
183 }
184
185 /// The widget and item a node is, when it is an item's ([`item_id`]'s inverse).
186 pub fn item_of(node: NodeId) -> Option<(WidgetId, usize)> {
187 (node.0 >= ITEM_BASE && node.0 < APP_BASE)
188 .then(|| (WidgetId(((node.0 - ITEM_BASE) >> 16) as usize), ((node.0 - ITEM_BASE) & 0xffff) as usize))
189 }
190
191 /// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
192 pub fn menu_row_of(node: NodeId) -> Option<usize> {
193 (node.0 > MENU.0 && node.0 < APP_RUN_BASE).then(|| (node.0 - MENU.0 - 1) as usize)
194 }
195
196 /// The open context menu's node, and the first of its rows' (`MENU + 1 + row`): far above any
197 /// widget's id.
198 pub const MENU: NodeId = NodeId(1 << 62);
199
200 /// The node of the open context menu's row `row`.
201 pub fn menu_row_id(row: usize) -> NodeId {
202 NodeId(MENU.0 + 1 + row as u64)
203 }
204
205 /// Where an app's own nodes ([`AppNodes::id`]) begin: above every widget's, below the menu's.
206 const APP_BASE: u64 = 1 << 61;
207
208 /// Where the text runs of an app's own fields ([`AppNodes::text_field`]) begin: above the
209 /// menu's rows. `APP_RUN_BASE + (n << 15) + line` for the app's node `n`.
210 const APP_RUN_BASE: u64 = 1 << 63;
211
212 /// The node of line `line`'s text run in the app's own field `n`.
213 fn app_text_run_id(n: u64, line: usize) -> NodeId {
214 NodeId(APP_RUN_BASE + ((n & ((1 << 48) - 1)) << 15) + line.min(RUN_BASE - 1) as u64)
215 }
216
217 /// The app's own number for a node it declared ([`AppNodes::id`]'s inverse).
218 pub fn app_node_of(node: NodeId) -> Option<u64> {
219 (node.0 >= APP_BASE && node.0 < MENU.0).then(|| node.0 - APP_BASE)
220 }
221
222 /// What an assistive tool asks of one of the app's own nodes
223 /// (`Application::accessibility_action`).
224 #[derive(Debug, Clone, PartialEq)]
225 pub enum AppAction {
226 /// Put the keyboard on it.
227 Focus,
228 /// Press it.
229 Click,
230 /// Replace a field's text (AT-SPI's `SetTextContents`): as the user replacing it would.
231 SetText(String),
232 /// Set a value (AT-SPI's `SetCurrentValue`).
233 SetNumber(f64),
234 Increment,
235 Decrement,
236 }
237
238 /// The nodes an app declares itself, for what it draws without a [`UiContext`] — see
239 /// [`Application::accessibility`](crate::backend::app::Application::accessibility). Ids come
240 /// from [`AppNodes::id`], an app's own numbering; children are set on a node with
241 /// `Node::set_children`, and only the nodes pushed with [`push_top`](Self::push_top) hang
242 /// from the window.
243 #[derive(Default)]
244 pub struct AppNodes {
245 nodes: Vec<(NodeId, Node)>,
246 top: Vec<NodeId>,
247 focus: Option<NodeId>,
248 }
249
250 impl AppNodes {
251 /// The node id of the app's own node `n` (any number below 2^61).
252 pub fn id(n: u64) -> NodeId {
253 NodeId(APP_BASE + (n & (APP_BASE - 1)))
254 }
255
256 /// A node that hangs from another of the app's nodes (which lists it as a child).
257 pub fn push(&mut self, id: NodeId, node: Node) {
258 self.nodes.push((id, node));
259 }
260
261 /// A node directly under the window, in the order pushed (after the widgets').
262 pub fn push_top(&mut self, id: NodeId, node: Node) {
263 self.top.push(id);
264 self.nodes.push((id, node));
265 }
266
267 /// The node the keyboard is on, when it is one of the app's own.
268 pub fn set_focus(&mut self, id: NodeId) {
269 self.focus = Some(id);
270 }
271
272 /// A text field the app draws itself — a `LineEdit` (`LineEdit::a11y_text`) — as node
273 /// `n`: a text input named `label`, at `bounds` (window px), whose text is runs this
274 /// pushes, so a reader reads it by character, follows its caret and sets it. The
275 /// field's node is returned for the app to place (`push_top`, or `push` under one of
276 /// its nodes) at [`AppNodes::id`]`(n)`. A reader's edit arrives as
277 /// `AppAction::SetText` on `n` (`Application::accessibility_action`), and a request
278 /// for the keyboard as `AppAction::Focus`.
279 pub fn text_field(&mut self, n: u64, label: &str, text: &A11yText, bounds: Option<crate::scene::layout::Rect>) -> Node {
280 let mut node = Node::new(text_field_role(text));
281 if !label.is_empty() {
282 node.set_label(label);
283 }
284 if let Some(r) = bounds {
285 node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
286 }
287 node.add_action(Action::Focus);
288 let runs = run_nodes(text, |line| app_text_run_id(n, line));
289 node.set_children(runs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
290 for (id, run) in runs {
291 self.push(id, run);
292 }
293 describe_text_field(&mut node, text, |line| app_text_run_id(n, line));
294 node
295 }
296 }
297
298 /// A widget's node: its id, moved up one so no widget can be the window.
299 pub fn node_id(id: WidgetId) -> NodeId {
300 NodeId(id.0 as u64 + 1)
301 }
302
303 /// What a widget is when it does not say ([`WidgetHost::a11y_role`]): by its type, else by
304 /// what it is to the keyboard — a thing you press is a button, anything else a container.
305 pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Role {
306 if let Some(role) = explicit {
307 return role;
308 }
309 match type_name {
310 "Button" => Role::Button,
311 "Checkbox" => Role::CheckBox,
312 "Toggle" => Role::Switch,
313 "Slider" | "RangeSlider" | "Slider2D" => Role::Slider,
314 "Spinbox" => Role::SpinButton,
315 "TextBox" | "KeybindRecorder" => Role::TextInput,
316 "Dropdown" | "FontSelector" => Role::ComboBox,
317 "ColorSelector" => Role::ColorWell,
318 "ButtonStrip" | "Paginator" => Role::TabList,
319 "Breadcrumb" => Role::Navigation,
320 "TreeList" => Role::Tree,
321 "Spreadsheet" => Role::Table,
322 "MenuBar" => Role::MenuBar,
323 "Label" | "StyledLabel" | "TextLabel" => Role::Label,
324 "ProgressBar" | "UsageBar" => Role::ProgressIndicator,
325 "ImageView" => Role::Image,
326 "Splitter" => Role::Splitter,
327 "Graph" | "Trackpad" => Role::Canvas,
328 "Group" | "ParametersBg" => Role::Group,
329 _ => match focus {
330 FocusRole::Plate => Role::Button,
331 FocusRole::Well | FocusRole::None => Role::GenericContainer,
332 },
333 }
334 }
335
336 /// One widget's node, with `children` already resolved.
337 pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
338 let focus = w.focus_role();
339 let text = w.a11y_text();
340 let role = match &text {
341 Some(t) => text_field_role(t),
342 None => role_for(w.type_name(), focus, w.a11y_role()),
343 };
344 let mut node = Node::new(role);
345 let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
346 if let Some(label) = name.or_else(|| w.label().filter(|l| !l.is_empty())) {
347 node.set_label(label);
348 }
349 if let Some(t) = &text {
350 // The text is the runs (`text_run_nodes`, the node's first children).
351 let id = w.base().id();
352 describe_text_field(&mut node, t, |line| text_run_id(id, line));
353 } else if let Some(value) = w.a11y_value() {
354 match role {
355 Role::CheckBox | Role::Switch => {
356 if let Some(on) = truth(&value) {
357 node.set_toggled(Toggled::from(on));
358 }
359 }
360 Role::Slider | Role::SpinButton | Role::ProgressIndicator => match value.trim().parse::<f64>() {
361 Ok(n) => node.set_numeric_value(n),
362 Err(_) => node.set_value(value),
363 },
364 _ => node.set_value(value),
365 }
366 }
367 let (x, y, width, height) = w.rect();
368 node.set_bounds(Rect::new(x as f64, y as f64, (x + width) as f64, (y + height) as f64));
369 if focus != FocusRole::None {
370 node.add_action(Action::Focus);
371 }
372 if let Some((min, max, step)) = w.a11y_range() {
373 node.set_min_numeric_value(min);
374 node.set_max_numeric_value(max);
375 if step > 0.0 {
376 node.set_numeric_value_step(step);
377 }
378 node.add_action(Action::SetValue);
379 }
380 for action in [Action::Click, Action::Increment, Action::Decrement] {
381 if key_for(w, action).is_some() {
382 node.add_action(action);
383 }
384 }
385 if !children.is_empty() {
386 node.set_children(children);
387 }
388 node
389 }
390
391 /// The key a keyboard user presses on the focused widget to do `action`, which is how the
392 /// Linux adapter carries it out (`backend::a11y_unix::act`): Space clicks a plate (what
393 /// arms on `FocusIn`); Right / Left step a slider or a range's focused end, Up / Down a
394 /// spin button. `None`: the widget does not take it. The node advertises exactly the
395 /// actions this answers, so the tree never offers one the runner cannot perform.
396 pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
397 let role = role_for(w.type_name(), w.focus_role(), w.a11y_role());
398 match (action, role) {
399 // A radio group is clicked through its radio buttons (`A11yItem`), not as a whole.
400 (Action::Click, Role::RadioGroup) => None,
401 (Action::Click, _) if w.focus_role() == FocusRole::Plate => Some(NamedKey::Space),
402 (Action::Increment, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowRight),
403 (Action::Decrement, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowLeft),
404 (Action::Increment, Role::SpinButton) => Some(NamedKey::ArrowUp),
405 (Action::Decrement, Role::SpinButton) => Some(NamedKey::ArrowDown),
406 _ => None,
407 }
408 }
409
410 /// Whether a widget is on screen: visible, with a size, and not parked far off the window
411 /// (x or y below -9000). A widget an app is not showing should be HIDDEN
412 /// (`WidgetHost::set_visible(false)`), which takes it out of the Tab walk too; the sentinel
413 /// is a backstop for one only parked. cce-data-editor parked its per-type value editors at
414 /// -1000, above the sentinel, so all of them were in the tree until it hid them (2026-10-08).
415 fn shown_on_screen(w: &dyn WidgetHost) -> bool {
416 let (x, y, width, height) = w.rect();
417 w.visible() && width > 0.0 && height > 0.0 && x > -9000.0 && y > -9000.0
418 }
419
420 /// A check box's or switch's value string as a state: what `value_string` writes for one.
421 fn truth(value: &str) -> Option<bool> {
422 match value.trim().to_ascii_lowercase().as_str() {
423 "true" | "1" | "on" | "yes" => Some(true),
424 "false" | "0" | "off" | "no" => Some(false),
425 _ => None,
426 }
427 }
428
429 /// The whole tree of `ctx`'s registered, visible widgets under a window node named `title`,
430 /// at HiDPI `scale` (see the module docs).
431 pub fn tree_update(ctx: &UiContext, title: &str, scale: f64) -> TreeUpdate {
432 window_tree(Some(ctx), AppNodes::default(), title, scale)
433 }
434
435 /// An app's whole tree: its [`UiContext`]'s widgets if it has one, the nodes its
436 /// [`Application::accessibility`](crate::backend::app::Application::accessibility) declares,
437 /// and an open context menu, under a window named by its settings' title.
438 pub fn app_tree<A: crate::backend::app::Application>(app: &mut A, scale: f64) -> TreeUpdate {
439 let mut own = AppNodes::default();
440 app.accessibility(&mut own);
441 let title = app.settings().title;
442 window_tree(app.ui_context(), own, &title, scale)
443 }
444
445 /// The tree of `ctx`'s widgets (if any) and `app`'s own nodes under one window. Focus, most
446 /// specific first: an open menu's, else the app's own, else the context's, else the window.
447 pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f64) -> TreeUpdate {
448 let empty;
449 let ctx = match ctx {
450 Some(ctx) => ctx,
451 None => {
452 empty = UiContext::new();
453 &empty
454 }
455 };
456 // The widgets, read once: the context owns them, and nothing mutates them while this
457 // borrows it.
458 let widgets: Vec<(WidgetId, &dyn WidgetHost)> = ctx
459 .widgets()
460 .map(|(id, w)| (id, w as &dyn WidgetHost))
461 .filter(|(_, w)| shown_on_screen(*w))
462 .collect();
463 let shown: std::collections::HashSet<WidgetId> = widgets.iter().map(|(id, _)| *id).collect();
464
465 let mut nodes = Vec::with_capacity(widgets.len() + 1);
466 let mut top: Vec<(WidgetId, f32, f32)> = Vec::new();
467 // The keyboard's node: a focused widget's, or its focused item's.
468 let mut item_focus: Option<NodeId> = None;
469 for (id, w) in &widgets {
470 // A text field's runs come first, then the widget's own items, then its linked
471 // children.
472 let mut children: Vec<NodeId> = Vec::new();
473 if let Some(text) = w.a11y_text() {
474 for (nid, run) in text_run_nodes(*id, &text) {
475 nodes.push((nid, run));
476 children.push(nid);
477 }
478 }
479 for (i, item) in w.a11y_items().into_iter().enumerate() {
480 let nid = item_id(*id, i);
481 let mut node = Node::new(item.role);
482 node.set_label(item.label);
483 let r = item.rect;
484 node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
485 if let Some(on) = item.toggled {
486 node.set_toggled(Toggled::from(on));
487 }
488 node.add_action(Action::Click);
489 if item.focused && ctx.focused_widget == Some(*id) {
490 item_focus = Some(nid);
491 }
492 nodes.push((nid, node));
493 children.push(nid);
494 }
495 children.extend(ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id));
496 let mut node = widget_node(*w, children);
497 // The open modal is said to be one: a reader keeps to it.
498 if ctx.modal_owner() == Some(*id) {
499 node.set_modal();
500 }
501 nodes.push((node_id(*id), node));
502 let parented = ctx.tree.parent_id(*id).is_some_and(|p| shown.contains(&p));
503 if !parented {
504 let (x, y, _, _) = w.rect();
505 top.push((*id, y, x));
506 }
507 }
508 // Reading order, as the keyboard walk takes it: rows top to bottom, then left to right.
509 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)));
510
511 let mut window = Node::new(Role::Window);
512 if !title.is_empty() {
513 window.set_label(title);
514 }
515 window.set_transform(Affine::scale(scale));
516 let mut children: Vec<NodeId> = top.iter().map(|(id, _, _)| node_id(*id)).collect();
517 children.extend(app.top.iter().copied());
518 nodes.extend(app.nodes);
519 if crate::widget::context_menu::is_visible() {
520 children.push(MENU);
521 }
522 window.set_children(children);
523 nodes.push((WINDOW, window));
524
525 let mut focus = app
526 .focus
527 .or(item_focus)
528 .or_else(|| ctx.focused_widget.filter(|id| shown.contains(id)).map(node_id))
529 .unwrap_or(WINDOW);
530 if let Some(menu_focus) = push_context_menu(&mut nodes) {
531 focus = menu_focus;
532 }
533 let mut tree = TreeInfo::new(WINDOW);
534 tree.toolkit_name = Some("cce-ui".into());
535 tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
536 TreeUpdate { nodes, tree: Some(tree), tree_id: TreeId::ROOT, focus }
537 }
538
539 /// The open context menu and its rows, pushed onto `nodes`; the node the keyboard is on
540 /// while it is open, or `None` when no menu is.
541 fn push_context_menu(nodes: &mut Vec<(NodeId, Node)>) -> Option<NodeId> {
542 use crate::widget::context_menu as cm;
543 if !cm::is_visible() {
544 return None;
545 }
546 let (mx, my, mw, mh) = (cm::x() as f64, cm::y() as f64, cm::w() as f64, cm::h() as f64);
547 let headers = cm::header_count();
548 let mut menu = Node::new(Role::Menu);
549 menu.set_bounds(Rect::new(mx, my, mx + mw, my + mh));
550 let mut rows = Vec::new();
551 for (i, label) in cm::options().iter().enumerate() {
552 if label.trim() == "-" {
553 continue;
554 }
555 // `split_mark` answers the glyph a mark is drawn as: "check" for `✓`, "circle" /
556 // "circle-outline" for a radio row on / off.
557 let (mark, text) = cm::split_mark(label);
558 let role = if i < headers {
559 Role::Label
560 } else if cm::slider(i).is_some() {
561 Role::Slider
562 } else {
563 match mark {
564 Some("check") => Role::MenuItemCheckBox,
565 Some("circle") | Some("circle-outline") => Role::MenuItemRadio,
566 _ => Role::MenuItem,
567 }
568 };
569 let mut row = Node::new(role);
570 row.set_label(text.trim());
571 let y = cm::row_y(i) as f64;
572 row.set_bounds(Rect::new(mx, y, mx + mw, y + cm::ROW_H as f64));
573 match (role, mark) {
574 (Role::MenuItemCheckBox, _) => row.set_toggled(Toggled::True),
575 (Role::MenuItemRadio, Some(glyph)) => row.set_toggled(Toggled::from(glyph == "circle")),
576 _ => {}
577 }
578 if let Some(slider) = cm::slider(i) {
579 row.set_numeric_value(slider.value as f64);
580 row.set_min_numeric_value(slider.min as f64);
581 row.set_max_numeric_value(slider.max as f64);
582 row.set_numeric_value_step(slider.step as f64);
583 }
584 if i >= headers {
585 row.add_action(Action::Click);
586 }
587 rows.push(menu_row_id(i));
588 nodes.push((menu_row_id(i), row));
589 }
590 menu.set_children(rows);
591 nodes.push((MENU, menu));
592 Some(cm::hovered_item().filter(|&i| i >= headers).map(menu_row_id).unwrap_or(MENU))
593 }
594
595 #[cfg(test)]
596 mod tests {
597 use super::*;
598 use crate::widget::{Button, Checkbox, RangeSlider, Slider, Spinbox, TextBox};
599
600 /// An open dialog is a modal `Dialog` node holding its members; a radio group in it is a
601 /// `RadioGroup` of `RadioButton` items, the chosen one checked and, with the group
602 /// focused, the keyboard's node.
603 #[test]
604 fn a_dialog_is_modal_and_a_radio_group_is_its_radio_buttons() {
605 use crate::widget::{Dialog, RadioGroup};
606 let mut ctx = UiContext::new();
607 let size = ctx.insert(RadioGroup::new(["Small", "Medium", "Large"]).with_selected(1));
608 ctx[size].set_rect(120.0, 120.0, 200.0, 100.0);
609 let ok = ctx.insert(Button::new(120.0, 240.0, 80.0, 24.0).with_label("OK"));
610 let dialog = ctx.insert(Dialog::new().with_label("Size"));
611 ctx.lend_h(dialog, |d, ctx| d.open(ctx, vec![size.id(), ok.id()]));
612
613 let update = tree_update(&ctx, "App", 1.0);
614 let d = node(&update, node_id(dialog.id()));
615 assert_eq!((d.role(), d.label(), d.is_modal()), (Role::Dialog, Some("Size"), true));
616 assert_eq!(d.children(), &[node_id(size.id()), node_id(ok.id())]);
617 assert_eq!(node(&update, WINDOW).children(), &[node_id(dialog.id())], "the members hang from it");
618
619 let g = node(&update, node_id(size.id()));
620 assert_eq!(g.role(), Role::RadioGroup);
621 assert!(!g.supports_action(Action::Click), "clicked through its buttons");
622 let buttons: Vec<(Option<&str>, Option<Toggled>)> = g
623 .children()
624 .iter()
625 .map(|c| node(&update, *c))
626 .inspect(|b| assert!(b.role() == Role::RadioButton && b.supports_action(Action::Click)))
627 .map(|b| (b.label(), b.toggled()))
628 .collect();
629 assert_eq!(
630 buttons,
631 [(Some("Small"), Some(Toggled::False)), (Some("Medium"), Some(Toggled::True)), (Some("Large"), Some(Toggled::False))]
632 );
633 assert_eq!(update.focus, item_id(size.id(), 1), "the dialog's first stop, on its chosen button");
634 assert_eq!(item_of(item_id(size.id(), 2)), Some((size.id(), 2)));
635 assert_eq!(widget_of(item_id(size.id(), 2)), None, "an item is not a widget");
636 }
637
638 /// A node offers the actions the Linux adapter can carry out, and each by the key a
639 /// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
640 /// Up / Down on a spin button, nothing on a text box.
641 /// A control named without a label of its own (a value editor in a table row) is called
642 /// by its accessible name, which wins over a label and adds no label strip.
643 #[test]
644 fn an_accessible_name_names_a_widget_and_draws_nothing() {
645 let mut text = TextBox::new(String::new());
646 let strip = text.label_strip();
647 text.set_accessible_name(Some("Value of font_size"));
648 assert_eq!(widget_node(&text, Vec::new()).label(), Some("Value of font_size"));
649 assert_eq!(text.label_strip(), strip, "no label strip for a name nobody draws");
650 let mut save = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
651 save.set_accessible_name(Some("Save the file"));
652 assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save the file"));
653 save.set_accessible_name(None);
654 assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save"), "back to the label");
655 }
656
657 #[test]
658 fn a_node_offers_the_actions_its_keys_carry_out() {
659 let button = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
660 let slider = Slider::new().with_label("Zoom");
661 let range = RangeSlider::new();
662 let spin = Spinbox::new(0, 0, 10, 1);
663 let text = TextBox::new(String::new());
664 let check = Checkbox::new();
665 let offers = |w: &dyn WidgetHost| {
666 let node = widget_node(w, Vec::new());
667 [Action::Click, Action::Increment, Action::Decrement]
668 .into_iter()
669 .filter(|a| node.supports_action(*a))
670 .map(|a| (a, key_for(w, a).expect("an offered action has its key")))
671 .collect::<Vec<_>>()
672 };
673 assert_eq!(offers(&button), [(Action::Click, NamedKey::Space)]);
674 assert_eq!(offers(&check), [(Action::Click, NamedKey::Space)]);
675 assert_eq!(offers(&slider), [(Action::Increment, NamedKey::ArrowRight), (Action::Decrement, NamedKey::ArrowLeft)]);
676 assert_eq!(offers(&range), [(Action::Increment, NamedKey::ArrowRight), (Action::Decrement, NamedKey::ArrowLeft)]);
677 assert_eq!(offers(&spin), [(Action::Increment, NamedKey::ArrowUp), (Action::Decrement, NamedKey::ArrowDown)]);
678 assert_eq!(offers(&text), [], "a well is typed into, not pressed or stepped");
679
680 let spin_node = widget_node(&spin, Vec::new());
681 assert!(spin_node.supports_action(Action::SetValue), "a reader sets a spin button");
682 assert_eq!((spin_node.min_numeric_value(), spin_node.max_numeric_value()), (Some(0.0), Some(10.0)));
683 assert_eq!(spin_node.numeric_value_step(), Some(1.0));
684 assert!(!widget_node(&button, Vec::new()).supports_action(Action::SetValue));
685 assert_eq!(key_for(&text, Action::Click), None);
686 }
687
688 fn node(update: &TreeUpdate, id: NodeId) -> &Node {
689 &update.nodes.iter().find(|(n, _)| *n == id).expect("node in the update").1
690 }
691
692 #[test]
693 fn a_window_of_widgets_is_a_tree_of_what_they_are() {
694 let mut ctx = UiContext::new();
695 let save = ctx.insert(Button::new(10.0, 40.0, 80.0, 24.0).with_label("Save"));
696 let name = ctx.insert(TextBox::new("Ada".to_string()).with_label("Name"));
697 ctx[name].set_rect(10.0, 10.0, 200.0, 24.0);
698 let wrap = ctx.insert(Checkbox::new().with_label("Wrap lines"));
699 ctx[wrap].set_rect(10.0, 70.0, 200.0, 24.0);
700 ctx[wrap].set_value_string("true");
701 let zoom = ctx.insert(Slider::new().with_label("Zoom"));
702 ctx[zoom].set_rect(10.0, 100.0, 200.0, 24.0);
703 ctx.set_focused_id(name.id());
704
705 let update = tree_update(&ctx, "Editor", 2.0);
706 let window = node(&update, WINDOW);
707 assert_eq!(window.role(), Role::Window);
708 assert_eq!(window.label(), Some("Editor"));
709 assert_eq!(window.transform(), Some(&Affine::scale(2.0)), "the scale is the window's");
710 let order: Vec<NodeId> = [&ctx[name] as &dyn WidgetHost, &ctx[save], &ctx[wrap], &ctx[zoom]]
711 .iter()
712 .map(|w| node_id(w.base().id()))
713 .collect();
714 assert_eq!(window.children(), &order[..], "reading order: top to bottom");
715 assert_eq!(update.tree.as_ref().map(|t| t.root), Some(WINDOW));
716 assert_eq!(update.focus, node_id(name.id()), "focus follows the context");
717
718 let b = node(&update, node_id(save.id()));
719 assert_eq!((b.role(), b.label()), (Role::Button, Some("Save")));
720 assert!(b.supports_action(Action::Click) && b.supports_action(Action::Focus));
721 assert_eq!(b.bounds(), Some(Rect::new(10.0, 40.0, 90.0, 64.0)), "logical px");
722
723 let t = node(&update, node_id(name.id()));
724 assert_eq!((t.role(), t.label(), t.value()), (Role::TextInput, Some("Name"), None), "a field's text is its runs");
725 assert_eq!(node(&update, t.children()[0]).value(), Some("Ada"));
726 assert!(!t.supports_action(Action::Click), "a well is not pressed");
727
728 let c = node(&update, node_id(wrap.id()));
729 assert_eq!((c.role(), c.toggled()), (Role::CheckBox, Some(Toggled::True)));
730
731 let s = node(&update, node_id(zoom.id()));
732 assert_eq!(s.role(), Role::Slider);
733 assert!(s.numeric_value().is_some(), "a slider's value is a number");
734 }
735
736 /// A text field is its text runs, a line each with the line's break at its end, every
737 /// character's byte length beside it; while it is edited its selection is in the runs. A
738 /// reader may set it (AT-SPI's EditableText) unless it is disabled, and a password field's
739 /// runs are bullets: the secret is nowhere in the tree.
740 #[test]
741 fn a_text_field_is_its_text_runs() {
742 let mut ctx = UiContext::new();
743 let notes = ctx.insert(TextBox::new("héllo\nwo".to_string()).with_multiline(true).with_label("Notes"));
744 ctx[notes].set_rect(10.0, 10.0, 200.0, 80.0);
745 let pass = ctx.insert(TextBox::new("hunter2".to_string()).with_label("Password"));
746 ctx[pass].set_placeholder("Passphrase");
747 ctx[pass].is_password = true;
748 ctx[pass].set_rect(10.0, 100.0, 200.0, 24.0);
749 let fixed = ctx.insert(TextBox::new("fixed".to_string()));
750 ctx[fixed].disabled = true;
751 ctx[fixed].set_rect(10.0, 130.0, 200.0, 24.0);
752 // Focused, a box opens with all of it selected: anchor at the start, caret at the end.
753 ctx.set_focused_id(notes.id());
754
755 let update = tree_update(&ctx, "", 1.0);
756 let t = node(&update, node_id(notes.id()));
757 assert_eq!(t.role(), Role::MultilineTextInput);
758 assert!(t.supports_action(Action::SetValue), "a reader may set it");
759 let runs: Vec<&Node> = t.children().iter().map(|&c| node(&update, c)).collect();
760 assert!(runs.iter().all(|r| r.role() == Role::TextRun));
761 assert_eq!(runs.iter().map(|r| r.value().unwrap()).collect::<Vec<_>>(), ["héllo\n", "wo"]);
762 assert_eq!(runs[0].character_lengths(), &[1, 2, 1, 1, 1, 1], "é is two bytes, the break one character");
763 let sel = t.text_selection().expect("being edited, it has a selection");
764 assert_eq!(sel.anchor, TextPosition { node: t.children()[0], character_index: 0 });
765 assert_eq!(sel.focus, TextPosition { node: t.children()[1], character_index: 2 }, "the caret at the end of the last line");
766
767 let p = node(&update, node_id(pass.id()));
768 assert_eq!(p.role(), Role::PasswordInput);
769 assert_eq!(p.placeholder(), Some("Passphrase"), "its hint");
770 assert_eq!(node(&update, p.children()[0]).value(), Some("\u{2022}".repeat(7).as_str()));
771 assert!(
772 update.nodes.iter().all(|(_, n)| !n.value().unwrap_or("").contains("hunter2") && !n.label().unwrap_or("").contains("hunter2")),
773 "the secret is in no node",
774 );
775
776 let f = node(&update, node_id(fixed.id()));
777 assert!(f.is_read_only() && !f.supports_action(Action::SetValue), "a disabled field is read-only");
778 assert_eq!(f.text_selection(), None, "not being edited, it has no caret");
779
780 // A text that ends in a line break has an empty last line, where the caret after it is.
781 ctx[notes].a11y_set_text("a\n");
782 let update = tree_update(&ctx, "", 1.0);
783 let t = node(&update, node_id(notes.id()));
784 assert_eq!(t.children().len(), 2);
785 assert_eq!(node(&update, t.children()[1]).value(), Some(""));
786 assert_eq!(t.text_selection().unwrap().focus, TextPosition { node: t.children()[1], character_index: 0 });
787 }
788
789 /// A colour selector's hex field is a text field too, said to be a colour.
790 #[test]
791 fn a_colour_selectors_hex_is_a_colour_field() {
792 let mut ctx = UiContext::new();
793 let c = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]).with_label("Accent"));
794 ctx[c].set_rect(10.0, 10.0, 200.0, 24.0);
795 let update = tree_update(&ctx, "", 1.0);
796 let n = node(&update, node_id(c.id()));
797 assert_eq!((n.role(), n.label(), n.description()), (Role::TextInput, Some("Accent"), Some("colour")));
798 assert_eq!(n.role_description(), None, "a role description would make it AT-SPI's Extended role, which never registers");
799 assert!(n.supports_action(Action::SetValue));
800 assert_eq!(node(&update, n.children()[0]).value(), Some("#4080ff"));
801 }
802
803 /// A field an app draws itself (a `LineEdit`) is published as one of its own nodes: a
804 /// text input whose runs and caret are where a widget's would be, its secret bulleted,
805 /// its runs' ids clear of every other kind of node.
806 #[test]
807 fn an_app_drawn_field_is_a_text_field_of_the_apps() {
808 use crate::widget::LineEdit;
809 let mut url = LineEdit::with_text("héllo.org");
810 url.cursor = 3; // after the é, a two-byte char: the caret is char 2
811 let mut pass = LineEdit::masked();
812 pass.a11y_set_text("hunter2");
813 let mut app = AppNodes::default();
814 let mut t = url.a11y_text(true);
815 t.placeholder = Some("Search or enter address".into());
816 let field = app.text_field(7, "Address", &t, Some(crate::scene::layout::Rect { x: 0.0, y: 0.0, width: 300.0, height: 30.0 }));
817 app.push_top(AppNodes::id(7), field);
818 let secret = app.text_field(8, "Password", &pass.a11y_text(false), None);
819 app.push_top(AppNodes::id(8), secret);
820 app.set_focus(AppNodes::id(7));
821 let update = window_tree(None, app, "", 1.0);
822
823 let f = node(&update, AppNodes::id(7));
824 assert_eq!((f.role(), f.label(), f.placeholder()), (Role::TextInput, Some("Address"), Some("Search or enter address")));
825 assert!(f.supports_action(Action::SetValue) && f.supports_action(Action::Focus));
826 let run = f.children()[0];
827 assert_eq!(node(&update, run).value(), Some("héllo.org"));
828 assert_eq!(f.text_selection().unwrap().focus, TextPosition { node: run, character_index: 2 });
829 assert_eq!(app_node_of(AppNodes::id(7)), Some(7));
830 assert_eq!((app_node_of(run), menu_row_of(run), widget_of(run), item_of(run)), (None, None, None, None), "a run is nobody's node");
831
832 let p = node(&update, AppNodes::id(8));
833 assert_eq!(p.role(), Role::PasswordInput);
834 assert_eq!(node(&update, p.children()[0]).value(), Some("\u{2022}".repeat(7).as_str()));
835 assert_eq!(p.text_selection(), None, "without the keyboard, no caret");
836 }
837
838 #[test]
839 fn hidden_widgets_are_not_in_the_tree_and_focus_falls_back_to_the_window() {
840 let mut ctx = UiContext::new();
841 let hidden = ctx.insert(Button::new(0.0, 0.0, 10.0, 10.0).with_label("Ghost"));
842 ctx[hidden].set_visible(false);
843 ctx.set_focused_id(hidden.id());
844 let update = tree_update(&ctx, "", 1.0);
845 assert_eq!(update.nodes.len(), 1, "only the window");
846 assert_eq!(update.focus, WINDOW);
847 assert_eq!(node(&update, WINDOW).label(), None, "no title, no name");
848 }
849
850 #[test]
851 fn an_open_context_menu_is_a_menu_and_holds_the_keyboard() {
852 use crate::widget::context_menu as cm;
853 let ctx = UiContext::new();
854 let rows = vec![
855 "[TextBox]: Name".to_string(),
856 "✓ Wrap lines".to_string(),
857 "● Follow editor".to_string(),
858 "-".to_string(),
859 "Opacity".to_string(),
860 "Copy".to_string(),
861 ];
862 cm::show(40.0, 50.0, rows, 1, WidgetId(7));
863 cm::set_row_slider(4, cm::MenuSlider { value: 50.0, min: 0.0, max: 100.0, step: 5.0, decimals: 0, suffix: "%" });
864 let update = tree_update(&ctx, "", 1.0);
865 assert!(node(&update, WINDOW).children().contains(&MENU), "the menu hangs from the window");
866 let menu = node(&update, MENU);
867 assert_eq!(menu.role(), Role::Menu);
868 assert_eq!(menu.children(), &[menu_row_id(0), menu_row_id(1), menu_row_id(2), menu_row_id(4), menu_row_id(5)][..], "no separator");
869 let row = |i| node(&update, menu_row_id(i));
870 assert_eq!((row(0).role(), row(0).label()), (Role::Label, Some("[TextBox]: Name")));
871 assert_eq!((row(1).role(), row(1).label(), row(1).toggled()), (Role::MenuItemCheckBox, Some("Wrap lines"), Some(Toggled::True)));
872 assert_eq!((row(2).role(), row(2).toggled()), (Role::MenuItemRadio, Some(Toggled::True)));
873 assert_eq!((row(4).role(), row(4).numeric_value(), row(4).max_numeric_value()), (Role::Slider, Some(50.0), Some(100.0)));
874 assert_eq!(row(5).role(), Role::MenuItem);
875 assert!(row(5).supports_action(Action::Click) && !row(0).supports_action(Action::Click));
876 assert_eq!(update.focus, MENU, "nothing highlighted: the menu has the keyboard");
877 cm::set_hovered_item(Some(5));
878 assert_eq!(tree_update(&ctx, "", 1.0).focus, menu_row_id(5), "the highlight is the focus");
879 cm::hide();
880 let closed = tree_update(&ctx, "", 1.0);
881 assert!(!node(&closed, WINDOW).children().contains(&MENU) && closed.focus == WINDOW);
882 }
883
884 #[test]
885 fn an_app_without_widgets_declares_its_own_nodes() {
886 // A status-bar module: no UiContext, a clock it draws itself.
887 let mut own = AppNodes::default();
888 let (bar, clock, button) = (AppNodes::id(1), AppNodes::id(2), AppNodes::id(3));
889 let mut group = Node::new(Role::Group);
890 group.set_label("Status");
891 group.set_children(vec![clock, button]);
892 own.push_top(bar, group);
893 let mut label = Node::new(Role::Label);
894 label.set_value("12:30");
895 own.push(clock, label);
896 let mut b = Node::new(Role::Button);
897 b.set_label("Volume");
898 b.add_action(Action::Click);
899 own.push(button, b);
900 own.set_focus(button);
901
902 let update = window_tree(None, own, "Status bar", 1.0);
903 assert_eq!(node(&update, WINDOW).children(), &[bar][..], "only what was pushed to the top");
904 assert_eq!(node(&update, bar).children(), &[clock, button][..]);
905 assert_eq!(node(&update, clock).value(), Some("12:30"));
906 assert_eq!(update.focus, button, "the app's focus");
907 assert!(bar != WINDOW && bar != MENU && bar.0 > node_id(WidgetId(usize::MAX >> 4)).0, "its own id range");
908 }
909
910 #[test]
911 fn parked_and_sizeless_widgets_are_not_on_screen() {
912 let mut ctx = UiContext::new();
913 ctx.insert(Button::new(-10_000.0, 0.0, 80.0, 24.0).with_label("Parked"));
914 ctx.insert(Button::new(10.0, 10.0, 0.0, 0.0).with_label("Empty"));
915 let shown = ctx.insert(Button::new(10.0, 10.0, 80.0, 24.0).with_label("Shown"));
916 let update = tree_update(&ctx, "", 1.0);
917 assert_eq!(node(&update, WINDOW).children(), &[node_id(shown.id())][..]);
918 }
919
920 #[test]
921 fn a_node_id_names_back_what_it_was_made_from() {
922 for id in [WidgetId(0), WidgetId(1), WidgetId(123_456)] {
923 assert_eq!(widget_of(node_id(id)), Some(id));
924 }
925 for row in [0, 1, 40] {
926 assert_eq!(menu_row_of(menu_row_id(row)), Some(row));
927 assert_eq!(widget_of(menu_row_id(row)), None, "a menu row is no widget");
928 }
929 assert_eq!(widget_of(WINDOW), None);
930 assert_eq!((widget_of(MENU), menu_row_of(MENU)), (None, None));
931 assert_eq!((widget_of(AppNodes::id(5)), menu_row_of(AppNodes::id(5))), (None, None), "an app's own node is neither");
932 }
933
934 #[test]
935 fn a_widget_that_says_what_it_is_is_believed() {
936 assert_eq!(role_for("Button", FocusRole::Plate, Some(Role::Tab)), Role::Tab);
937 assert_eq!(role_for("SomethingNew", FocusRole::Plate, None), Role::Button);
938 assert_eq!(role_for("SomethingNew", FocusRole::None, None), Role::GenericContainer);
939 }
940 }