git.lucas.co / cce-ui
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 }