use ahash::{AHashMap, AHashSet};
use crate::node::{A11yNodeInfo, A11yRole, LiveRegion};
const MAX_TRAVERSAL_DEPTH: usize = 1000;
pub struct A11yTreeBuilder {
nodes: AHashMap<u64, A11yNodeInfo>,
root: Option<u64>,
focused: Option<u64>,
}
impl Default for A11yTreeBuilder {
fn default() -> Self {
Self::new()
}
}
impl A11yTreeBuilder {
#[inline]
pub fn new() -> Self {
Self {
nodes: AHashMap::new(),
root: None,
focused: None,
}
}
#[inline]
pub fn with_capacity(capacity: usize) -> Self {
Self {
nodes: AHashMap::with_capacity(capacity),
root: None,
focused: None,
}
}
#[inline]
pub fn add_node(&mut self, node: A11yNodeInfo) {
self.nodes.insert(node.id, node);
}
#[inline]
pub fn set_root(&mut self, id: u64) {
self.root = Some(id);
}
#[inline]
pub fn set_focused(&mut self, id: Option<u64>) {
self.focused = id;
}
#[inline]
pub fn build(self) -> A11yTree {
A11yTree {
nodes: self.nodes,
root: self.root,
focused: self.focused,
}
}
}
pub struct A11yTree {
nodes: AHashMap<u64, A11yNodeInfo>,
root: Option<u64>,
focused: Option<u64>,
}
impl Default for A11yTree {
fn default() -> Self {
Self::empty()
}
}
impl A11yTree {
#[inline]
pub fn empty() -> Self {
Self {
nodes: AHashMap::new(),
root: None,
focused: None,
}
}
#[inline]
pub fn node(&self, id: u64) -> Option<&A11yNodeInfo> {
self.nodes.get(&id)
}
#[inline]
pub fn root(&self) -> Option<&A11yNodeInfo> {
self.root.and_then(|id| self.nodes.get(&id))
}
#[inline]
pub fn root_id(&self) -> Option<u64> {
self.root
}
#[inline]
pub fn focused(&self) -> Option<&A11yNodeInfo> {
self.focused.and_then(|id| self.nodes.get(&id))
}
#[inline]
pub fn focused_id(&self) -> Option<u64> {
self.focused
}
#[inline]
pub fn nodes(&self) -> impl Iterator<Item = &A11yNodeInfo> {
self.nodes.values()
}
#[inline]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn children_of(&self, id: u64) -> Vec<&A11yNodeInfo> {
self.nodes
.get(&id)
.map(|n| {
n.children
.iter()
.filter_map(|cid| self.nodes.get(cid))
.collect()
})
.unwrap_or_default()
}
pub fn ancestors(&self, id: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut visited = AHashSet::new();
let mut current = Some(id);
while let Some(cid) = current {
if path.len() >= MAX_TRAVERSAL_DEPTH || !visited.insert(cid) {
break;
}
if let Some(node) = self.nodes.get(&cid) {
path.push(cid);
current = node.parent;
} else {
break;
}
}
path
}
pub fn screen_reader_mirror(&self, policy: ScreenReaderPolicy) -> ScreenReaderMirror {
let mut order = Vec::with_capacity(self.nodes.len());
let mut visited = AHashSet::with_capacity(self.nodes.len());
if let Some(root) = self.root {
self.collect_mirror_order(root, 0, &mut visited, &mut order);
}
let mut disconnected: Vec<u64> = self
.nodes
.keys()
.copied()
.filter(|id| !visited.contains(id))
.collect();
disconnected.sort_unstable();
for id in disconnected {
self.collect_mirror_order(id, 0, &mut visited, &mut order);
}
let mut lines = Vec::new();
let mut omitted_nodes = 0;
for (id, depth) in order {
let Some(node) = self.nodes.get(&id) else {
continue;
};
if node.role == A11yRole::Presentation {
continue;
}
if lines.len() >= policy.max_mirror_nodes {
omitted_nodes += 1;
continue;
}
let indent = " ".repeat(depth.min(16));
let summary = node_summary(node, self.focused == Some(id), true);
let mut line = String::with_capacity(indent.len() + summary.len());
line.push_str(&indent);
line.push_str(&summary);
lines.push(limit_text(line, policy.max_text_chars));
}
ScreenReaderMirror {
lines,
omitted_nodes,
}
}
pub fn screen_reader_announcements_since(
&self,
previous: &A11yTree,
policy: ScreenReaderPolicy,
) -> ScreenReaderAnnouncements {
self.diff(previous)
.screen_reader_announcements(self, policy)
}
fn collect_mirror_order(
&self,
id: u64,
depth: usize,
visited: &mut AHashSet<u64>,
order: &mut Vec<(u64, usize)>,
) {
if depth >= MAX_TRAVERSAL_DEPTH || !visited.insert(id) {
return;
}
let Some(node) = self.nodes.get(&id) else {
return;
};
order.push((id, depth));
for child_id in &node.children {
self.collect_mirror_order(*child_id, depth + 1, visited, order);
}
}
pub fn diff(&self, previous: &A11yTree) -> A11yTreeDiff {
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (&id, node) in &self.nodes {
match previous.nodes.get(&id) {
None => added.push(id),
Some(old) => {
let changes = diff_node(old, node);
if !changes.is_empty() {
changed.push((id, changes));
}
}
}
}
for &id in previous.nodes.keys() {
if !self.nodes.contains_key(&id) {
removed.push(id);
}
}
added.sort_unstable();
removed.sort_unstable();
changed.sort_unstable_by_key(|(id, _)| *id);
let focus_changed = if self.focused != previous.focused {
Some((previous.focused, self.focused))
} else {
None
};
A11yTreeDiff {
added,
removed,
changed,
focus_changed,
}
}
}
#[derive(Debug, Clone)]
pub struct A11yTreeDiff {
pub added: Vec<u64>,
pub removed: Vec<u64>,
pub changed: Vec<(u64, Vec<A11yChange>)>,
pub focus_changed: Option<(Option<u64>, Option<u64>)>,
}
impl A11yTreeDiff {
#[inline]
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.changed.is_empty()
&& self.focus_changed.is_none()
}
pub fn screen_reader_announcements(
&self,
current: &A11yTree,
policy: ScreenReaderPolicy,
) -> ScreenReaderAnnouncements {
let mut candidates = Vec::new();
if let Some((_, Some(new_focus))) = self.focus_changed
&& let Some(node) = current.node(new_focus)
&& node.role != A11yRole::Presentation
&& let Some(text) = announcement_text(node, current.focused == Some(new_focus), false)
{
candidates.push(ScreenReaderAnnouncement {
node_id: Some(new_focus),
urgency: LiveRegion::Polite,
reason: AnnouncementReason::FocusChanged,
text,
});
}
for id in &self.added {
if let Some(node) = current.node(*id)
&& let Some(urgency) = node.live_region
&& let Some(text) = announcement_text(node, current.focused == Some(*id), true)
{
candidates.push(ScreenReaderAnnouncement {
node_id: Some(*id),
urgency,
reason: AnnouncementReason::LiveRegionAdded,
text,
});
}
}
for (id, changes) in &self.changed {
if let Some(node) = current.node(*id)
&& let Some(urgency) = node.live_region
&& let Some(reason) = announcement_reason(changes)
&& let Some(text) = announcement_text(node, current.focused == Some(*id), true)
{
candidates.push(ScreenReaderAnnouncement {
node_id: Some(*id),
urgency,
reason,
text,
});
}
}
candidates
.sort_by(|left, right| announcement_sort_key(left).cmp(&announcement_sort_key(right)));
let max = policy.max_announcements;
let dropped_count = candidates.len().saturating_sub(max);
let announcements = candidates
.into_iter()
.take(max)
.map(|mut announcement| {
announcement.text = limit_text(announcement.text, policy.max_text_chars);
announcement
})
.collect();
ScreenReaderAnnouncements {
announcements,
dropped_count,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenReaderPolicy {
pub max_mirror_nodes: usize,
pub max_announcements: usize,
pub max_text_chars: usize,
}
impl Default for ScreenReaderPolicy {
fn default() -> Self {
Self {
max_mirror_nodes: 128,
max_announcements: 8,
max_text_chars: 240,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenReaderMirror {
pub lines: Vec<String>,
pub omitted_nodes: usize,
}
impl ScreenReaderMirror {
#[must_use]
pub fn text(&self) -> String {
self.lines.join("\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenReaderAnnouncements {
pub announcements: Vec<ScreenReaderAnnouncement>,
pub dropped_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenReaderAnnouncement {
pub node_id: Option<u64>,
pub urgency: LiveRegion,
pub reason: AnnouncementReason,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnnouncementReason {
FocusChanged,
LiveRegionAdded,
LiveContentChanged,
LiveRegionChanged,
}
#[derive(Debug, Clone, PartialEq)]
pub enum A11yChange {
NameChanged {
old: Option<String>,
new: Option<String>,
},
RoleChanged { old: A11yRole, new: A11yRole },
StateChanged { field: String, description: String },
BoundsChanged,
ChildrenChanged,
LiveRegionChanged {
old: Option<LiveRegion>,
new: Option<LiveRegion>,
},
DescriptionChanged {
old: Option<String>,
new: Option<String>,
},
ShortcutChanged {
old: Option<String>,
new: Option<String>,
},
ParentChanged { old: Option<u64>, new: Option<u64> },
}
fn diff_node(old: &A11yNodeInfo, new: &A11yNodeInfo) -> Vec<A11yChange> {
let mut changes = Vec::new();
if old.name != new.name {
changes.push(A11yChange::NameChanged {
old: old.name.clone(),
new: new.name.clone(),
});
}
if old.role != new.role {
changes.push(A11yChange::RoleChanged {
old: old.role,
new: new.role,
});
}
if old.bounds != new.bounds {
changes.push(A11yChange::BoundsChanged);
}
if old.children != new.children {
changes.push(A11yChange::ChildrenChanged);
}
if old.live_region != new.live_region {
changes.push(A11yChange::LiveRegionChanged {
old: old.live_region,
new: new.live_region,
});
}
if old.description != new.description {
changes.push(A11yChange::DescriptionChanged {
old: old.description.clone(),
new: new.description.clone(),
});
}
if old.shortcut != new.shortcut {
changes.push(A11yChange::ShortcutChanged {
old: old.shortcut.clone(),
new: new.shortcut.clone(),
});
}
if old.parent != new.parent {
changes.push(A11yChange::ParentChanged {
old: old.parent,
new: new.parent,
});
}
diff_state(&old.state, &new.state, &mut changes);
changes
}
fn diff_state(
old: &crate::node::A11yState,
new: &crate::node::A11yState,
changes: &mut Vec<A11yChange>,
) {
macro_rules! check_bool {
($field:ident) => {
if old.$field != new.$field {
changes.push(A11yChange::StateChanged {
field: stringify!($field).to_owned(),
description: new.$field.to_string(),
});
}
};
}
macro_rules! check_option {
($field:ident) => {
if old.$field != new.$field {
changes.push(A11yChange::StateChanged {
field: stringify!($field).to_owned(),
description: format!("{:?}", new.$field),
});
}
};
}
check_bool!(focused);
check_bool!(disabled);
check_option!(checked);
check_option!(expanded);
check_bool!(selected);
check_bool!(readonly);
check_bool!(required);
check_bool!(busy);
check_option!(value_now);
check_option!(value_min);
check_option!(value_max);
if old.value_text != new.value_text {
changes.push(A11yChange::StateChanged {
field: "value_text".to_owned(),
description: new.value_text.as_deref().unwrap_or("<none>").to_owned(),
});
}
}
fn announcement_reason(changes: &[A11yChange]) -> Option<AnnouncementReason> {
if changes
.iter()
.any(|change| matches!(change, A11yChange::LiveRegionChanged { .. }))
{
return Some(AnnouncementReason::LiveRegionChanged);
}
changes
.iter()
.any(|change| {
matches!(
change,
A11yChange::NameChanged { .. }
| A11yChange::DescriptionChanged { .. }
| A11yChange::RoleChanged { .. }
) || matches!(
change,
A11yChange::StateChanged { field, .. }
if matches!(
field.as_str(),
"busy" | "checked" | "expanded" | "selected" | "value_now" | "value_text"
)
)
})
.then_some(AnnouncementReason::LiveContentChanged)
}
fn announcement_sort_key(announcement: &ScreenReaderAnnouncement) -> (u8, u8, Option<u64>, &str) {
let reason_rank = match announcement.reason {
AnnouncementReason::FocusChanged => 0,
AnnouncementReason::LiveRegionChanged => 1,
AnnouncementReason::LiveRegionAdded | AnnouncementReason::LiveContentChanged => 2,
};
let urgency_rank = match announcement.urgency {
LiveRegion::Assertive => 0,
LiveRegion::Polite => 1,
};
(
reason_rank,
urgency_rank,
announcement.node_id,
announcement.text.as_str(),
)
}
fn announcement_text(node: &A11yNodeInfo, focused: bool, require_content: bool) -> Option<String> {
if node.role == A11yRole::Presentation {
return None;
}
if require_content && !has_announcement_content(node) {
return None;
}
let text = node_summary(node, focused, false);
normalized_text(&text)
}
fn has_announcement_content(node: &A11yNodeInfo) -> bool {
normalized_option(node.name.as_deref()).is_some()
|| normalized_option(node.description.as_deref()).is_some()
|| !state_summaries(&node.state).is_empty()
}
fn node_summary(node: &A11yNodeInfo, focused: bool, include_live_region: bool) -> String {
let mut parts = Vec::new();
let mut heading = node.role.to_string();
if let Some(name) = normalized_option(node.name.as_deref()) {
heading.push_str(": ");
heading.push_str(&name);
}
parts.push(heading);
if let Some(description) = normalized_option(node.description.as_deref())
&& normalized_option(node.name.as_deref()) != Some(description.clone())
{
parts.push(description);
}
let mut states = state_summaries(&node.state);
if focused || node.state.focused {
states.insert(0, "focused".to_owned());
}
if !states.is_empty() {
parts.push(states.join(", "));
}
if let Some(shortcut) = normalized_option(node.shortcut.as_deref()) {
parts.push(format!("shortcut {shortcut}"));
}
if include_live_region && let Some(region) = node.live_region {
parts.push(format!("live {region}"));
}
parts.join(". ")
}
fn state_summaries(state: &crate::node::A11yState) -> Vec<String> {
let mut states = Vec::new();
if state.disabled {
states.push("disabled".to_owned());
}
if let Some(checked) = state.checked {
states.push(if checked { "checked" } else { "not checked" }.to_owned());
}
if let Some(expanded) = state.expanded {
states.push(if expanded { "expanded" } else { "collapsed" }.to_owned());
}
if state.selected {
states.push("selected".to_owned());
}
if state.readonly {
states.push("read only".to_owned());
}
if state.required {
states.push("required".to_owned());
}
if state.busy {
states.push("busy".to_owned());
}
if let Some(value_text) = normalized_option(state.value_text.as_deref()) {
states.push(format!("value {value_text}"));
} else if let Some(value_now) = state.value_now {
states.push(format!("value {value_now}"));
}
states
}
fn normalized_option(value: Option<&str>) -> Option<String> {
value.and_then(normalized_text)
}
fn normalized_text(value: &str) -> Option<String> {
let mut normalized = String::new();
for word in value.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(word);
}
(!normalized.is_empty()).then_some(normalized)
}
fn limit_text(text: String, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if text.chars().count() <= max_chars {
return text;
}
text.chars().take(max_chars).collect()
}