1use std::collections::HashMap;
84
85use crate::a11y::{A11yChecked, A11yInfo, A11yLive, A11yRole};
86use crate::event::Rect;
87use crate::focus::FocusManager;
88use crate::frame::{OwnedContent, OwnedFrame};
89use crate::primitives::list::ListView;
90use crate::primitives::toolbar::{Toolbar, ToolbarButton};
91use crate::types::{StyledText, WidgetId};
92
93const DEFAULT_ROOT_LABEL: &str = "quadraui";
95
96const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
97const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
98
99const fn fnv1a(bytes: &[u8], seed: u64) -> u64 {
101 let mut hash = seed;
102 let mut i = 0;
103 while i < bytes.len() {
104 hash ^= bytes[i] as u64;
105 hash = hash.wrapping_mul(FNV_PRIME);
106 i += 1;
107 }
108 hash
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
117pub struct A11yNodeId(u64);
118
119impl A11yNodeId {
120 pub const ROOT: A11yNodeId = A11yNodeId(fnv1a(b"quadraui.a11y.root", FNV_OFFSET));
122
123 pub fn for_widget(id: &WidgetId) -> Self {
125 Self::for_key(id.as_str())
126 }
127
128 pub fn for_key(key: &str) -> Self {
131 Self(fnv1a(
132 key.as_bytes(),
133 fnv1a(b"quadraui.a11y.node.", FNV_OFFSET),
134 ))
135 }
136
137 pub fn child(self, key: &str) -> Self {
140 Self(fnv1a(
141 key.as_bytes(),
142 fnv1a(&self.0.to_le_bytes(), FNV_OFFSET),
143 ))
144 }
145
146 pub fn as_u64(self) -> u64 {
148 self.0
149 }
150
151 #[cfg(feature = "a11y")]
153 #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
154 pub fn to_accesskit(self) -> accesskit::NodeId {
155 accesskit::NodeId(self.0)
156 }
157}
158
159#[derive(Debug, Clone, PartialEq)]
167pub struct A11yChild {
168 pub key: String,
171 pub info: A11yInfo,
173 pub bounds: Option<Rect>,
176 pub children: Vec<A11yChild>,
178}
179
180impl A11yChild {
181 pub fn new(key: impl Into<String>, info: A11yInfo) -> Self {
183 Self {
184 key: key.into(),
185 info,
186 bounds: None,
187 children: Vec::new(),
188 }
189 }
190
191 pub fn with_bounds(mut self, bounds: Rect) -> Self {
193 self.bounds = Some(bounds);
194 self
195 }
196
197 pub fn with_children(mut self, children: Vec<A11yChild>) -> Self {
199 self.children = children;
200 self
201 }
202}
203
204pub trait A11yPrimitive {
211 fn a11y_info(&self) -> A11yInfo;
213
214 fn a11y_children(&self) -> Vec<A11yChild> {
217 Vec::new()
218 }
219
220 fn a11y_focus_key(&self) -> Option<String> {
225 None
226 }
227}
228
229#[derive(Debug, Clone, PartialEq)]
231pub struct A11yTreeNode {
232 pub id: A11yNodeId,
234 pub info: A11yInfo,
236 pub bounds: Option<Rect>,
239 pub children: Vec<A11yNodeId>,
241}
242
243impl A11yTreeNode {
244 pub fn role(&self) -> A11yRole {
247 self.info.a11y_role.unwrap_or(A11yRole::Group)
248 }
249
250 pub fn label(&self) -> Option<&str> {
252 self.info.a11y_label.as_deref()
253 }
254
255 #[cfg(feature = "a11y")]
260 #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
261 pub fn to_accesskit(&self) -> accesskit::Node {
262 let mut node = accesskit::Node::new(self.role().into());
263 if let Some(label) = self.label() {
264 node.set_label(label.to_string());
265 }
266 if let Some(rect) = self.bounds {
267 node.set_bounds(accesskit::Rect::new(
268 rect.x as f64,
269 rect.y as f64,
270 (rect.x + rect.width) as f64,
271 (rect.y + rect.height) as f64,
272 ));
273 }
274 if let Some(selected) = self.info.selected {
275 node.set_selected(selected);
276 }
277 if let Some(expanded) = self.info.expanded {
278 node.set_expanded(expanded);
279 }
280 if let Some(checked) = self.info.checked {
281 node.set_toggled(checked.into());
282 }
283 if self.info.disabled == Some(true) {
284 node.set_disabled();
285 }
286 if self.info.read_only == Some(true) {
287 node.set_read_only();
288 }
289 if let Some(value) = &self.info.value {
290 node.set_value(value.clone());
291 }
292 if let Some(range) = self.info.value_range {
293 node.set_numeric_value(range.current);
294 node.set_min_numeric_value(range.min);
295 node.set_max_numeric_value(range.max);
296 }
297 if let Some(level) = self.info.level {
298 node.set_level(level.saturating_sub(1) as usize);
299 }
300 if let Some(pos) = self.info.position_in_set {
301 node.set_position_in_set(pos.saturating_sub(1) as usize);
302 }
303 if let Some(size) = self.info.set_size {
304 node.set_size_of_set(size as usize);
305 }
306 if let Some(live) = self.info.live {
307 node.set_live(live.into());
308 }
309 if !self.children.is_empty() {
310 node.set_children(
311 self.children
312 .iter()
313 .map(|id| id.to_accesskit())
314 .collect::<Vec<_>>(),
315 );
316 }
317 node
318 }
319}
320
321#[derive(Debug, Clone, PartialEq)]
324pub struct A11yTree {
325 root: A11yNodeId,
326 focus: A11yNodeId,
327 nodes: Vec<A11yTreeNode>,
328}
329
330impl A11yTree {
331 pub fn root(&self) -> A11yNodeId {
333 self.root
334 }
335
336 pub fn focus(&self) -> A11yNodeId {
339 self.focus
340 }
341
342 pub fn nodes(&self) -> &[A11yTreeNode] {
344 &self.nodes
345 }
346
347 pub fn node(&self, id: A11yNodeId) -> Option<&A11yTreeNode> {
349 self.nodes.iter().find(|node| node.id == id)
350 }
351
352 pub fn find(&self, label: &str) -> Option<&A11yTreeNode> {
355 self.nodes.iter().find(|node| node.label() == Some(label))
356 }
357
358 pub fn children_of(&self, id: A11yNodeId) -> Vec<&A11yTreeNode> {
361 match self.node(id) {
362 Some(parent) => parent
363 .children
364 .iter()
365 .filter_map(|child| self.node(*child))
366 .collect(),
367 None => Vec::new(),
368 }
369 }
370
371 pub fn focused(&self) -> Option<&A11yTreeNode> {
373 self.node(self.focus)
374 }
375
376 pub fn to_snapshot(&self) -> String {
383 let mut out = String::new();
384 self.write_snapshot(self.root, 0, &mut out);
385 out
386 }
387
388 fn write_snapshot(&self, id: A11yNodeId, depth: usize, out: &mut String) {
389 let Some(node) = self.node(id) else {
390 return;
391 };
392 for _ in 0..depth {
393 out.push_str(" ");
394 }
395 out.push_str(role_name(node.role()));
396 if let Some(label) = node.label() {
397 out.push_str(" \"");
398 out.push_str(label);
399 out.push('"');
400 }
401 let states = state_tokens(node, self.focus == node.id);
402 if !states.is_empty() {
403 out.push_str(" [");
404 out.push_str(&states.join(" "));
405 out.push(']');
406 }
407 out.push('\n');
408 for child in &node.children {
409 self.write_snapshot(*child, depth + 1, out);
410 }
411 }
412}
413
414#[derive(Debug, Clone, PartialEq)]
417pub struct A11yUpdate {
418 pub nodes: Vec<A11yTreeNode>,
422 pub root: Option<A11yNodeId>,
425 pub focus: A11yNodeId,
428 pub full: bool,
430}
431
432#[cfg(feature = "a11y")]
433#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
434impl A11yUpdate {
435 pub fn to_tree_update(&self) -> accesskit::TreeUpdate {
438 accesskit::TreeUpdate {
439 nodes: self
440 .nodes
441 .iter()
442 .map(|node| (node.id.to_accesskit(), node.to_accesskit()))
443 .collect(),
444 tree: self
445 .root
446 .map(|root| accesskit::TreeInfo::new(root.to_accesskit())),
447 tree_id: accesskit::TreeId::ROOT,
448 focus: self.focus.to_accesskit(),
449 }
450 }
451}
452
453#[derive(Debug, Clone, Default)]
460pub struct A11yTreeBuilder {
461 prev: HashMap<A11yNodeId, A11yTreeNode>,
462 sent_tree: bool,
463 root_label: Option<String>,
464}
465
466impl A11yTreeBuilder {
467 pub fn new() -> Self {
469 Self::default()
470 }
471
472 pub fn with_root_label(mut self, label: impl Into<String>) -> Self {
475 self.root_label = Some(label.into());
476 self
477 }
478
479 pub fn tree(&self, frame: &OwnedFrame, focus: &FocusManager) -> A11yTree {
482 build_tree(
483 frame,
484 focus,
485 self.root_label.as_deref().unwrap_or(DEFAULT_ROOT_LABEL),
486 )
487 }
488
489 pub fn update(&mut self, frame: &OwnedFrame, focus: &FocusManager) -> A11yUpdate {
492 let tree = self.tree(frame, focus);
493 let full = !self.sent_tree;
494 let nodes: Vec<A11yTreeNode> = if full {
495 tree.nodes().to_vec()
496 } else {
497 tree.nodes()
498 .iter()
499 .filter(|node| self.prev.get(&node.id) != Some(*node))
500 .cloned()
501 .collect()
502 };
503 self.prev = tree
504 .nodes()
505 .iter()
506 .map(|node| (node.id, node.clone()))
507 .collect();
508 self.sent_tree = true;
509 A11yUpdate {
510 nodes,
511 root: if full { Some(tree.root()) } else { None },
512 focus: tree.focus(),
513 full,
514 }
515 }
516
517 pub fn reset(&mut self) {
520 self.prev.clear();
521 self.sent_tree = false;
522 }
523
524 #[cfg(feature = "a11y")]
526 #[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
527 pub fn tree_update(
528 &mut self,
529 frame: &OwnedFrame,
530 focus: &FocusManager,
531 ) -> accesskit::TreeUpdate {
532 self.update(frame, focus).to_tree_update()
533 }
534}
535
536pub fn frame_to_tree(frame: &OwnedFrame, focus: &FocusManager) -> A11yTree {
539 build_tree(frame, focus, DEFAULT_ROOT_LABEL)
540}
541
542fn build_tree(frame: &OwnedFrame, focus: &FocusManager, root_label: &str) -> A11yTree {
543 let mut nodes: Vec<A11yTreeNode> = Vec::with_capacity(frame.nodes().len() + 1);
544 let mut root_children: Vec<A11yNodeId> = Vec::new();
545 let mut root_bounds: Option<Rect> = None;
546 let mut focus_id = A11yNodeId::ROOT;
547
548 nodes.push(A11yTreeNode {
551 id: A11yNodeId::ROOT,
552 info: A11yInfo::new()
553 .with_role(A11yRole::Window)
554 .with_label(root_label),
555 bounds: None,
556 children: Vec::new(),
557 });
558
559 for owned in frame.nodes() {
560 let id = match &owned.widget_id {
561 Some(widget) => A11yNodeId::for_widget(widget),
562 None => A11yNodeId::for_key(&format!("surface.{}", owned.idx)),
563 };
564 let described = describe(&owned.content);
565 let info = match &owned.a11y {
566 Some(override_info) => merge(described.info, override_info),
567 None => described.info,
568 };
569 let child_ids: Vec<A11yNodeId> = described
570 .children
571 .iter()
572 .map(|child| id.child(&child.key))
573 .collect();
574
575 if focus.focused() == owned.widget_id.as_ref() && owned.widget_id.is_some() {
576 focus_id = match &described.focus_key {
577 Some(key) if child_ids.contains(&id.child(key)) => id.child(key),
578 _ => id,
579 };
580 }
581
582 root_children.push(id);
583 root_bounds = Some(match root_bounds {
584 Some(acc) => union(acc, owned.rect),
585 None => owned.rect,
586 });
587 nodes.push(A11yTreeNode {
588 id,
589 info,
590 bounds: Some(owned.rect),
591 children: child_ids,
592 });
593 for child in &described.children {
594 push_child(&mut nodes, id, child);
595 }
596 }
597
598 nodes[0].children = root_children;
599 nodes[0].bounds = root_bounds;
600
601 A11yTree {
602 root: A11yNodeId::ROOT,
603 focus: focus_id,
604 nodes,
605 }
606}
607
608fn push_child(nodes: &mut Vec<A11yTreeNode>, parent: A11yNodeId, child: &A11yChild) {
609 let id = parent.child(&child.key);
610 let grandchildren: Vec<A11yNodeId> = child
611 .children
612 .iter()
613 .map(|grandchild| id.child(&grandchild.key))
614 .collect();
615 nodes.push(A11yTreeNode {
616 id,
617 info: child.info.clone(),
618 bounds: child.bounds,
619 children: grandchildren,
620 });
621 for grandchild in &child.children {
622 push_child(nodes, id, grandchild);
623 }
624}
625
626fn union(a: Rect, b: Rect) -> Rect {
627 let x = a.x.min(b.x);
628 let y = a.y.min(b.y);
629 let right = (a.x + a.width).max(b.x + b.width);
630 let bottom = (a.y + a.height).max(b.y + b.height);
631 Rect::new(x, y, right - x, bottom - y)
632}
633
634struct Described {
636 info: A11yInfo,
637 children: Vec<A11yChild>,
638 focus_key: Option<String>,
639}
640
641impl Described {
642 fn from_primitive(primitive: &dyn A11yPrimitive) -> Self {
643 Self {
644 info: primitive.a11y_info(),
645 children: primitive.a11y_children(),
646 focus_key: primitive.a11y_focus_key(),
647 }
648 }
649
650 fn fallback(content: &OwnedContent) -> Self {
653 Self {
654 info: A11yInfo::new()
655 .with_role(A11yRole::Group)
656 .with_label(content_kind(content)),
657 children: Vec::new(),
658 focus_key: None,
659 }
660 }
661}
662
663fn describe(content: &OwnedContent) -> Described {
664 match content {
665 OwnedContent::List(list) => Described::from_primitive(list),
666 OwnedContent::Toolbar(bar) => Described::from_primitive(bar),
667 other => Described::fallback(other),
668 }
669}
670
671fn merge(base: A11yInfo, over: &A11yInfo) -> A11yInfo {
675 let mut merged = base;
676 merged.a11y_role = over.a11y_role.or(merged.a11y_role);
677 merged.a11y_label = over.a11y_label.clone().or(merged.a11y_label);
678 merged.selected = over.selected.or(merged.selected);
679 merged.expanded = over.expanded.or(merged.expanded);
680 merged.checked = over.checked.or(merged.checked);
681 merged.disabled = over.disabled.or(merged.disabled);
682 merged.read_only = over.read_only.or(merged.read_only);
683 merged.value = over.value.clone().or(merged.value);
684 merged.value_range = over.value_range.or(merged.value_range);
685 merged.level = over.level.or(merged.level);
686 merged.position_in_set = over.position_in_set.or(merged.position_in_set);
687 merged.set_size = over.set_size.or(merged.set_size);
688 merged.live = over.live.or(merged.live);
689 merged
690}
691
692fn content_kind(content: &OwnedContent) -> &'static str {
694 match content {
695 OwnedContent::Editor(_) => "Editor",
696 OwnedContent::TabBar(_) => "Tab bar",
697 OwnedContent::StatusBar(_) => "Status bar",
698 OwnedContent::ActivityBar(_) => "Activity bar",
699 OwnedContent::CommandLine(_) => "Command line",
700 OwnedContent::Terminal(_) => "Terminal",
701 OwnedContent::TextDisplay(_) => "Text",
702 OwnedContent::MultiSectionView(_) => "Multi-section view",
703 OwnedContent::Tree(_) => "Tree",
704 OwnedContent::List(_) => "List",
705 OwnedContent::Form(_) => "Form",
706 OwnedContent::MenuBar(_) => "Menu bar",
707 OwnedContent::Split(_) => "Split",
708 OwnedContent::Panel(_) => "Panel",
709 OwnedContent::Scrollbar(_) => "Scrollbar",
710 OwnedContent::Palette(_) => "Palette",
711 OwnedContent::Tooltip(_) => "Tooltip",
712 OwnedContent::ContextMenu(_) => "Context menu",
713 OwnedContent::Dialog(_) => "Dialog",
714 OwnedContent::Float(_) => "Floating panel",
715 OwnedContent::Completions(_) => "Completions",
716 OwnedContent::RichTextPopup(_) => "Rich text popup",
717 OwnedContent::Toast(_) => "Notifications",
718 OwnedContent::DataTable(_) => "Table",
719 OwnedContent::Chart(_) => "Chart",
720 OwnedContent::Canvas(_) => "Canvas",
721 OwnedContent::Board(_) => "Board",
722 OwnedContent::CommandCenter(_) => "Command center",
723 OwnedContent::DiffView(_) => "Diff view",
724 OwnedContent::Image(_) => "Image",
725 OwnedContent::MessageList(_) => "Message list",
726 OwnedContent::Minimap(_) => "Minimap",
727 OwnedContent::PipelineView(_) => "Pipeline view",
728 OwnedContent::Progress(_) => "Progress",
729 OwnedContent::SidebarPanel(_) => "Sidebar panel",
730 OwnedContent::Spinner(_) => "Spinner",
731 OwnedContent::SplitTree(_) => "Split tree",
732 OwnedContent::TextInput(_) => "Text input",
733 OwnedContent::Toolbar(_) => "Toolbar",
734 OwnedContent::DropOverlay => "Drop overlay",
735 OwnedContent::FindReplace => "Find and replace",
736 OwnedContent::Opaque => "Content",
737 }
738}
739
740fn role_name(role: A11yRole) -> &'static str {
742 match role {
743 A11yRole::Window => "window",
744 A11yRole::Toolbar => "toolbar",
745 A11yRole::Button => "button",
746 A11yRole::Tab => "tab",
747 A11yRole::TabList => "tab_list",
748 A11yRole::Menu => "menu",
749 A11yRole::MenuItem => "menu_item",
750 A11yRole::Tree => "tree",
751 A11yRole::TreeItem => "tree_item",
752 A11yRole::List => "list",
753 A11yRole::ListItem => "list_item",
754 A11yRole::Table => "table",
755 A11yRole::Row => "row",
756 A11yRole::Cell => "cell",
757 A11yRole::TextInput => "text_input",
758 A11yRole::Document => "document",
759 A11yRole::Editor => "editor",
760 A11yRole::Terminal => "terminal",
761 A11yRole::Dialog => "dialog",
762 A11yRole::Status => "status",
763 A11yRole::Progress => "progress",
764 A11yRole::Image => "image",
765 A11yRole::Group => "group",
766 A11yRole::Presentation => "presentation",
767 }
768}
769
770fn state_tokens(node: &A11yTreeNode, focused: bool) -> Vec<String> {
773 let info = &node.info;
774 let mut tokens = Vec::new();
775 if focused {
776 tokens.push("focused".to_string());
777 }
778 if info.selected == Some(true) {
779 tokens.push("selected".to_string());
780 }
781 match info.expanded {
782 Some(true) => tokens.push("expanded".to_string()),
783 Some(false) => tokens.push("collapsed".to_string()),
784 None => {}
785 }
786 match info.checked {
787 Some(A11yChecked::True) => tokens.push("checked".to_string()),
788 Some(A11yChecked::False) => tokens.push("unchecked".to_string()),
789 Some(A11yChecked::Mixed) => tokens.push("mixed".to_string()),
790 None => {}
791 }
792 if info.disabled == Some(true) {
793 tokens.push("disabled".to_string());
794 }
795 if info.read_only == Some(true) {
796 tokens.push("readonly".to_string());
797 }
798 if let Some(value) = &info.value {
799 tokens.push(format!("value=\"{value}\""));
800 }
801 if let Some(range) = info.value_range {
802 tokens.push(format!(
803 "range={}/{}-{}",
804 range.current, range.min, range.max
805 ));
806 }
807 if let Some(level) = info.level {
808 tokens.push(format!("level={level}"));
809 }
810 match (info.position_in_set, info.set_size) {
811 (Some(pos), Some(size)) => tokens.push(format!("pos={pos}/{size}")),
812 (Some(pos), None) => tokens.push(format!("pos={pos}")),
813 (None, Some(size)) => tokens.push(format!("size={size}")),
814 (None, None) => {}
815 }
816 match info.live {
817 Some(A11yLive::Polite) => tokens.push("live=polite".to_string()),
818 Some(A11yLive::Assertive) => tokens.push("live=assertive".to_string()),
819 Some(A11yLive::Off) | None => {}
820 }
821 tokens
822}
823
824fn plain_text(text: &StyledText) -> String {
826 text.spans.iter().map(|span| span.text.as_str()).collect()
827}
828
829fn non_empty(text: String) -> Option<String> {
830 if text.trim().is_empty() {
831 None
832 } else {
833 Some(text)
834 }
835}
836
837impl A11yPrimitive for ListView {
838 fn a11y_info(&self) -> A11yInfo {
839 let mut info = A11yInfo::new()
840 .with_role(A11yRole::List)
841 .with_set_size(self.items.len() as u32);
842 if let Some(label) = self.title.as_ref().and_then(|t| non_empty(plain_text(t))) {
843 info = info.with_label(label);
844 }
845 info
846 }
847
848 fn a11y_children(&self) -> Vec<A11yChild> {
849 let set_size = self.items.len() as u32;
850 self.items
851 .iter()
852 .enumerate()
853 .map(|(idx, item)| {
854 let mut info = A11yInfo::new()
855 .with_role(A11yRole::ListItem)
856 .with_selected(idx == self.selected_idx)
857 .with_position_in_set(idx as u32 + 1)
858 .with_set_size(set_size);
859 if let Some(label) = non_empty(plain_text(&item.text)) {
860 info = info.with_label(label);
861 }
862 if let Some(detail) = item.detail.as_ref().and_then(|d| non_empty(plain_text(d))) {
863 info = info.with_value(detail);
864 }
865 A11yChild::new(format!("item.{idx}"), info)
866 })
867 .collect()
868 }
869
870 fn a11y_focus_key(&self) -> Option<String> {
871 if self.selected_idx < self.items.len() {
872 Some(format!("item.{}", self.selected_idx))
873 } else {
874 None
875 }
876 }
877}
878
879impl A11yPrimitive for Toolbar {
880 fn a11y_info(&self) -> A11yInfo {
881 A11yInfo::new().with_role(A11yRole::Toolbar)
882 }
883
884 fn a11y_children(&self) -> Vec<A11yChild> {
885 self.buttons
886 .iter()
887 .enumerate()
888 .map(|(idx, button)| toolbar_child(idx, button))
889 .collect()
890 }
891
892 fn a11y_focus_key(&self) -> Option<String> {
893 let idx = self.focused_index?;
894 let button = self.buttons.get(idx)?;
895 Some(toolbar_child_key(idx, button))
896 }
897}
898
899fn toolbar_child_key(idx: usize, button: &ToolbarButton) -> String {
902 match button {
903 ToolbarButton::Action { id, .. } => format!("button.{}", id.as_str()),
904 ToolbarButton::Separator => format!("separator.{idx}"),
905 ToolbarButton::Label { .. } => format!("label.{idx}"),
906 }
907}
908
909fn toolbar_child(idx: usize, button: &ToolbarButton) -> A11yChild {
910 let key = toolbar_child_key(idx, button);
911 let info = match button {
912 ToolbarButton::Action {
913 label,
914 enabled,
915 is_active,
916 tooltip,
917 ..
918 } => {
919 let mut info = A11yInfo::new()
920 .with_role(A11yRole::Button)
921 .with_disabled(!*enabled);
922 if let Some(label) = non_empty(label.clone()) {
923 info = info.with_label(label);
924 }
925 if *is_active {
926 info = info.with_checked(A11yChecked::True);
927 }
928 if let Some(tooltip) = non_empty(tooltip.clone()) {
929 info = info.with_value(tooltip);
930 }
931 info
932 }
933 ToolbarButton::Separator => A11yInfo::new().with_role(A11yRole::Presentation),
936 ToolbarButton::Label { text, .. } => {
939 let mut info = A11yInfo::new().with_role(A11yRole::Group);
940 if let Some(label) = non_empty(text.clone()) {
941 info = info.with_label(label);
942 }
943 info
944 }
945 };
946 A11yChild::new(key, info)
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use crate::frame::{ScreenLayout, Surface};
953 use crate::primitives::list::ListItem;
954 use crate::primitives::scrollbar::Scrollbar;
955 use crate::primitives::toolbar::ToolbarButton;
956 use crate::types::{Decoration, WidgetId};
957
958 fn list(id: &str, title: Option<&str>, items: &[&str], selected: usize) -> ListView {
959 ListView {
960 id: WidgetId::new(id),
961 title: title.map(StyledText::plain),
962 items: items
963 .iter()
964 .map(|text| ListItem {
965 text: StyledText::plain(*text),
966 icon: None,
967 detail: None,
968 decoration: Decoration::default(),
969 })
970 .collect(),
971 selected_idx: selected,
972 scroll_offset: 0,
973 has_focus: false,
974 bordered: false,
975 h_scroll: 0,
976 max_content_width: None,
977 show_v_scrollbar: false,
978 }
979 }
980
981 fn toolbar(id: &str) -> Toolbar {
982 Toolbar::new(WidgetId::new(id)).with_buttons(vec![
983 ToolbarButton::Action {
984 id: WidgetId::new("run"),
985 label: "Run".to_string(),
986 icon: None,
987 key_hint: None,
988 enabled: true,
989 is_active: false,
990 tooltip: String::new(),
991 },
992 ToolbarButton::Separator,
993 ToolbarButton::Action {
994 id: WidgetId::new("stop"),
995 label: "Stop".to_string(),
996 icon: None,
997 key_hint: None,
998 enabled: false,
999 is_active: false,
1000 tooltip: String::new(),
1001 },
1002 ])
1003 }
1004
1005 fn frame_with(surfaces: Vec<Surface>) -> OwnedFrame {
1006 let mut layout = ScreenLayout::new();
1007 for surface in surfaces {
1008 layout.push(surface);
1009 }
1010 OwnedFrame::from_layout(&layout)
1011 }
1012
1013 fn list_surface() -> Surface {
1014 Surface::List {
1015 rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1016 list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 1),
1017 }
1018 }
1019
1020 fn scrollbar_surface() -> Surface {
1023 let rect = Rect::new(29.0, 0.0, 1.0, 10.0);
1024 Surface::Scrollbar {
1025 rect,
1026 sb: Scrollbar::vertical(WidgetId::new("files.vscroll"), rect, 0.0, 30.0, 10.0, 1.0),
1027 }
1028 }
1029
1030 fn toolbar_surface() -> Surface {
1031 Surface::Toolbar {
1032 rect: Rect::new(0.0, 10.0, 30.0, 2.0),
1033 bar: toolbar("tools"),
1034 hovered: None,
1035 pressed: None,
1036 }
1037 }
1038
1039 #[test]
1040 fn root_is_a_window_covering_every_surface() {
1041 let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1042 let tree = frame_to_tree(&frame, &FocusManager::new());
1043 let root = tree.node(tree.root()).expect("root node");
1044 assert_eq!(root.role(), A11yRole::Window);
1045 assert_eq!(root.label(), Some("quadraui"));
1046 assert_eq!(root.children.len(), 2, "one child per surface");
1047 assert_eq!(root.bounds, Some(Rect::new(0.0, 0.0, 30.0, 12.0)));
1048 }
1049
1050 #[test]
1051 fn list_maps_to_a_list_with_item_children() {
1052 let frame = frame_with(vec![list_surface()]);
1053 let tree = frame_to_tree(&frame, &FocusManager::new());
1054 assert_eq!(
1055 tree.to_snapshot(),
1056 "window \"quadraui\" [focused]\n \
1057 list \"Files\" [size=3]\n \
1058 list_item \"a.rs\" [pos=1/3]\n \
1059 list_item \"b.rs\" [selected pos=2/3]\n \
1060 list_item \"c.rs\" [pos=3/3]\n"
1061 );
1062 }
1063
1064 #[test]
1065 fn toolbar_maps_buttons_with_disabled_state() {
1066 let frame = frame_with(vec![toolbar_surface()]);
1067 let tree = frame_to_tree(&frame, &FocusManager::new());
1068 assert_eq!(
1069 tree.to_snapshot(),
1070 "window \"quadraui\" [focused]\n \
1071 toolbar\n \
1072 button \"Run\"\n \
1073 presentation\n \
1074 button \"Stop\" [disabled]\n"
1075 );
1076 }
1077
1078 #[test]
1079 fn unmapped_primitive_is_a_labelled_group() {
1080 let frame = frame_with(vec![scrollbar_surface()]);
1081 let tree = frame_to_tree(&frame, &FocusManager::new());
1082 let node = tree
1083 .children_of(tree.root())
1084 .first()
1085 .copied()
1086 .expect("surface node");
1087 assert_eq!(node.role(), A11yRole::Group);
1088 assert_eq!(node.label(), Some("Scrollbar"));
1089 }
1090
1091 #[test]
1092 fn node_ids_are_stable_across_frames_and_positions() {
1093 let first = frame_with(vec![list_surface(), toolbar_surface()]);
1094 let second = frame_with(vec![
1096 Surface::Toolbar {
1097 rect: Rect::new(0.0, 0.0, 30.0, 2.0),
1098 bar: toolbar("tools"),
1099 hovered: None,
1100 pressed: None,
1101 },
1102 Surface::List {
1103 rect: Rect::new(0.0, 2.0, 30.0, 10.0),
1104 list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 1),
1105 },
1106 ]);
1107 let fm = FocusManager::new();
1108 let a = frame_to_tree(&first, &fm);
1109 let b = frame_to_tree(&second, &fm);
1110 let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1111 assert_eq!(a.node(list_id).map(|n| n.id), Some(list_id));
1112 assert_eq!(b.node(list_id).map(|n| n.id), Some(list_id));
1113 assert_eq!(
1114 a.find("b.rs").map(|n| n.id),
1115 b.find("b.rs").map(|n| n.id),
1116 "a row keeps its id when its parent moves"
1117 );
1118 }
1119
1120 #[test]
1121 fn focus_follows_the_focus_manager_into_the_active_row() {
1122 let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1123 let mut fm = FocusManager::new();
1124 let unfocused = frame_to_tree(&frame, &fm);
1125 assert_eq!(
1126 unfocused.focus(),
1127 unfocused.root(),
1128 "nothing focused parks focus on the root"
1129 );
1130
1131 fm.set_focus(Some(WidgetId::new("files")));
1132 let tree = frame_to_tree(&frame, &fm);
1133 assert_eq!(
1134 tree.focused().and_then(|n| n.label()),
1135 Some("b.rs"),
1136 "a focused list points focus at its selected row"
1137 );
1138 assert!(tree.to_snapshot().contains("list_item \"b.rs\" [focused"));
1139 }
1140
1141 #[test]
1142 fn focus_lands_on_the_focused_toolbar_button() {
1143 let mut bar = toolbar("tools");
1144 bar.focused_index = Some(2);
1145 let frame = frame_with(vec![Surface::Toolbar {
1146 rect: Rect::new(0.0, 0.0, 30.0, 2.0),
1147 bar,
1148 hovered: None,
1149 pressed: None,
1150 }]);
1151 let mut fm = FocusManager::new();
1152 fm.set_focus(Some(WidgetId::new("tools")));
1153 let tree = frame_to_tree(&frame, &fm);
1154 assert_eq!(tree.focused().and_then(|n| n.label()), Some("Stop"));
1155 }
1156
1157 #[test]
1158 fn consumer_a11y_override_wins_field_by_field() {
1159 let mut frame = frame_with(vec![list_surface()]);
1160 frame.nodes_mut()[0].a11y = Some(A11yInfo::new().with_label("Open editors"));
1161 let tree = frame_to_tree(&frame, &FocusManager::new());
1162 let node = tree
1163 .children_of(tree.root())
1164 .first()
1165 .copied()
1166 .expect("surface node");
1167 assert_eq!(node.label(), Some("Open editors"), "label is overridden");
1168 assert_eq!(node.role(), A11yRole::List, "derived role survives");
1169 }
1170
1171 #[test]
1172 fn first_update_is_full_then_only_changed_nodes_are_emitted() {
1173 let mut builder = A11yTreeBuilder::new();
1174 let frame = frame_with(vec![list_surface(), toolbar_surface()]);
1175 let fm = FocusManager::new();
1176
1177 let first = builder.update(&frame, &fm);
1178 assert!(first.full);
1179 assert_eq!(first.root, Some(A11yNodeId::ROOT));
1180 assert_eq!(first.nodes.len(), frame_to_tree(&frame, &fm).nodes().len());
1181
1182 let second = builder.update(&frame, &fm);
1183 assert!(!second.full);
1184 assert_eq!(second.root, None, "the tree is only declared once");
1185 assert!(
1186 second.nodes.is_empty(),
1187 "an unchanged frame emits no nodes, got {:?}",
1188 second.nodes
1189 );
1190 assert_eq!(second.focus, first.focus, "focus is always reported");
1191 }
1192
1193 #[test]
1194 fn changed_selection_emits_only_the_affected_rows() {
1195 let mut builder = A11yTreeBuilder::new();
1196 let fm = FocusManager::new();
1197 builder.update(&frame_with(vec![list_surface()]), &fm);
1198
1199 let moved = frame_with(vec![Surface::List {
1200 rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1201 list: list("files", Some("Files"), &["a.rs", "b.rs", "c.rs"], 2),
1202 }]);
1203 let update = builder.update(&moved, &fm);
1204 let labels: Vec<_> = update.nodes.iter().filter_map(|n| n.label()).collect();
1205 assert_eq!(
1206 labels,
1207 vec!["b.rs", "c.rs"],
1208 "only the deselected and newly selected rows changed"
1209 );
1210 }
1211
1212 #[test]
1213 fn removing_a_row_re_emits_the_parent_without_it() {
1214 let mut builder = A11yTreeBuilder::new();
1215 let fm = FocusManager::new();
1216 builder.update(&frame_with(vec![list_surface()]), &fm);
1217
1218 let shorter = frame_with(vec![Surface::List {
1219 rect: Rect::new(0.0, 0.0, 30.0, 10.0),
1220 list: list("files", Some("Files"), &["a.rs", "b.rs"], 1),
1221 }]);
1222 let update = builder.update(&shorter, &fm);
1223 let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1224 let parent = update
1225 .nodes
1226 .iter()
1227 .find(|n| n.id == list_id)
1228 .expect("parent is re-emitted when its child list shrinks");
1229 assert_eq!(parent.children.len(), 2);
1230 let gone = list_id.child("item.2");
1231 assert!(
1232 !update.nodes.iter().any(|n| n.id == gone),
1233 "the removed row is not part of the update"
1234 );
1235 }
1236
1237 #[test]
1238 fn reset_forces_a_full_tree_again() {
1239 let mut builder = A11yTreeBuilder::new();
1240 let frame = frame_with(vec![list_surface()]);
1241 let fm = FocusManager::new();
1242 builder.update(&frame, &fm);
1243 builder.reset();
1244 let again = builder.update(&frame, &fm);
1245 assert!(again.full);
1246 assert_eq!(again.root, Some(A11yNodeId::ROOT));
1247 }
1248
1249 #[test]
1250 fn root_label_is_overridable() {
1251 let builder = A11yTreeBuilder::new().with_root_label("kubeui");
1252 let tree = builder.tree(&frame_with(vec![list_surface()]), &FocusManager::new());
1253 assert_eq!(
1254 tree.node(tree.root()).and_then(|n| n.label()),
1255 Some("kubeui")
1256 );
1257 }
1258
1259 #[test]
1260 fn surfaces_without_a_widget_id_still_appear() {
1261 let frame = frame_with(vec![scrollbar_surface()]);
1262 let tree = frame_to_tree(&frame, &FocusManager::new());
1263 assert_eq!(tree.nodes().len(), 2, "root plus the id-less surface");
1264 assert_eq!(
1265 tree.children_of(tree.root())[0].id,
1266 A11yNodeId::for_key("surface.0")
1267 );
1268 }
1269
1270 #[cfg(feature = "a11y")]
1271 #[test]
1272 fn tree_update_carries_tree_info_focus_and_nodes() {
1273 let mut builder = A11yTreeBuilder::new();
1274 let frame = frame_with(vec![list_surface()]);
1275 let mut fm = FocusManager::new();
1276 fm.set_focus(Some(WidgetId::new("files")));
1277
1278 let update = builder.tree_update(&frame, &fm);
1279 let tree_info = update.tree.expect("first update declares the tree");
1280 assert_eq!(tree_info.root, A11yNodeId::ROOT.to_accesskit());
1281 assert_eq!(update.tree_id, accesskit::TreeId::ROOT);
1282 let list_id = A11yNodeId::for_widget(&WidgetId::new("files"));
1283 assert_eq!(update.focus, list_id.child("item.1").to_accesskit());
1284
1285 let (_, list_node) = update
1286 .nodes
1287 .iter()
1288 .find(|(id, _)| *id == list_id.to_accesskit())
1289 .expect("the list node is in the update");
1290 assert_eq!(list_node.role(), accesskit::Role::List);
1291 assert_eq!(list_node.label(), Some("Files"));
1292 assert_eq!(list_node.children().len(), 3);
1293 assert_eq!(
1294 list_node.bounds(),
1295 Some(accesskit::Rect::new(0.0, 0.0, 30.0, 10.0))
1296 );
1297
1298 let (_, row) = update
1299 .nodes
1300 .iter()
1301 .find(|(id, _)| *id == list_id.child("item.1").to_accesskit())
1302 .expect("the selected row is in the update");
1303 assert_eq!(row.is_selected(), Some(true));
1304 assert_eq!(
1305 row.position_in_set(),
1306 Some(1),
1307 "accesskit's position_in_set is zero-based"
1308 );
1309 assert_eq!(row.size_of_set(), Some(3));
1310
1311 let unchanged = builder.tree_update(&frame, &fm);
1312 assert!(unchanged.nodes.is_empty());
1313 assert!(unchanged.tree.is_none());
1314 assert_eq!(unchanged.focus, update.focus);
1315 }
1316}