use alloc::{string::{String, ToString}, vec::Vec};
use std::collections::BTreeMap;
use azul_core::{
dom::{
AccessibilityAction, AccessibilityRole, AccessibilityState, AttributeType, DomId,
DomNodeId, NodeData, NodeId, NodeType,
},
geom::{LogicalPosition, LogicalRect, LogicalSize},
};
use crate::{
managers::{a11y::is_exposed_to_accessibility, scroll_state::ScrollManager},
window::DomLayoutResult,
};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct A11yElement {
pub dom_id: DomId,
pub node_id: NodeId,
pub parent: Option<usize>,
pub children: Vec<usize>,
pub label: String,
pub value: Option<String>,
pub role: AccessibilityRole,
pub bounds: LogicalRect,
pub actions: Vec<AccessibilityAction>,
pub focusable: bool,
pub focused: bool,
pub editable: bool,
pub checked: Option<bool>,
pub disabled: bool,
}
impl A11yElement {
#[must_use]
pub fn supports(&self, action: &AccessibilityAction) -> bool {
self.actions.contains(action)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct A11ySnapshot {
pub title: String,
pub elements: Vec<A11yElement>,
pub roots: Vec<usize>,
pub window_size: LogicalSize,
}
impl A11ySnapshot {
#[must_use]
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub fn build(
layout_results: &BTreeMap<DomId, DomLayoutResult>,
scroll_manager: &ScrollManager,
focused_node: Option<DomNodeId>,
title: &str,
window_size: LogicalSize,
) -> Self {
let mut elements: Vec<A11yElement> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
let mut index_of: BTreeMap<(usize, usize), usize> = BTreeMap::new();
let focused = focused_node.and_then(|f| f.node.into_crate_internal().map(|n| (f.dom, n)));
for (dom_id, layout_result) in layout_results {
let styled_dom = &layout_result.styled_dom;
let node_data_slice = styled_dom.node_data.as_ref();
let node_hierarchy = styled_dom.node_hierarchy.as_ref();
for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
if !is_exposed_to_accessibility(node_data) {
continue;
}
let node_id = NodeId::new(dom_idx);
let bounds = element_bounds(layout_result, node_id, window_size);
let (child_text, has_non_text_children) =
collect_child_text(node_data_slice, node_hierarchy, dom_idx);
let editable = node_data.is_contenteditable()
|| matches!(node_data.node_type, NodeType::TextArea | NodeType::Input);
let mut label = String::new();
let mut value: Option<String> = None;
if let Some(info) = node_data.get_accessibility_info() {
if let Some(name) = info.accessibility_name.as_option() {
label = name.as_str().to_string();
}
if let Some(v) = info.accessibility_value.as_option() {
value = Some(v.as_str().to_string());
}
}
if let Some(l) = node_data.get_accessible_label() {
label = l.to_string();
}
if let Some(v) = node_data.get_accessible_value() {
value = Some(v.to_string());
}
if let NodeType::Text(text) = &node_data.node_type {
label = text.as_str().to_string();
}
if !child_text.is_empty() {
if editable {
value = Some(child_text);
} else if !has_non_text_children && label.is_empty() {
label = child_text;
}
}
let role = node_data.get_accessibility_info().map_or_else(
|| node_type_to_role(&node_data.node_type),
|info| info.role,
);
let mut checked = None;
let mut disabled = false;
if let Some(info) = node_data.get_accessibility_info() {
for state in info.states.as_ref() {
match state {
AccessibilityState::CheckedTrue => checked = Some(true),
AccessibilityState::CheckedFalse => checked = Some(false),
AccessibilityState::Unavailable => disabled = true,
_ => {}
}
}
}
for attr in node_data.attributes().as_ref() {
match attr {
AttributeType::CheckedTrue => checked = Some(true),
AttributeType::CheckedFalse => checked = Some(false),
AttributeType::Disabled => disabled = true,
_ => {}
}
}
let actions = supported_actions(node_data, scroll_manager, *dom_id, node_id);
index_of.insert((dom_id.inner, dom_idx), elements.len());
elements.push(A11yElement {
dom_id: *dom_id,
node_id,
parent: None,
children: Vec::new(),
label,
value,
role,
bounds,
actions,
focusable: node_data.is_focusable(),
focused: focused == Some((*dom_id, node_id)),
editable,
checked,
disabled,
});
}
}
for (dom_id, layout_result) in layout_results {
let styled_dom = &layout_result.styled_dom;
let node_hierarchy = styled_dom.node_hierarchy.as_ref();
for dom_idx in 0..styled_dom.node_data.as_ref().len() {
let Some(&self_idx) = index_of.get(&(dom_id.inner, dom_idx)) else {
continue;
};
let mut current = node_hierarchy[dom_idx].parent_id();
let mut parent_idx = None;
let mut guard = 0usize;
while let Some(parent_node_id) = current {
guard += 1;
if guard > 10_000 {
break;
}
let p = parent_node_id.index();
if let Some(&idx) = index_of.get(&(dom_id.inner, p)) {
parent_idx = Some(idx);
break;
}
if p >= node_hierarchy.len() {
break;
}
current = node_hierarchy[p].parent_id();
}
match parent_idx {
Some(p) => {
elements[self_idx].parent = Some(p);
elements[p].children.push(self_idx);
}
None => roots.push(self_idx),
}
}
}
Self {
title: title.to_string(),
elements,
roots,
window_size,
}
}
#[must_use]
pub fn element(&self, index: usize) -> Option<&A11yElement> {
self.elements.get(index)
}
#[must_use]
pub fn index_of(&self, dom_id: DomId, node_id: NodeId) -> Option<usize> {
self.elements
.iter()
.position(|e| e.dom_id == dom_id && e.node_id == node_id)
}
#[must_use]
pub fn focused(&self) -> Option<usize> {
self.elements.iter().position(|e| e.focused)
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.elements.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.elements.len()
}
}
fn element_bounds(
layout_result: &DomLayoutResult,
node_id: NodeId,
window_size: LogicalSize,
) -> LogicalRect {
let zero = LogicalRect {
origin: LogicalPosition { x: 0.0, y: 0.0 },
size: LogicalSize {
width: 0.0,
height: 0.0,
},
};
let Some(layout_idx) = layout_result
.layout_tree
.dom_to_layout
.get(&node_id)
.and_then(|indices| indices.first())
.copied()
else {
return zero;
};
let Some(hot) = layout_result.layout_tree.get(layout_idx) else {
return zero;
};
let (Some(pos), Some(size)) = (
layout_result.calculated_positions.get(layout_idx).copied(),
hot.used_size,
) else {
return zero;
};
let bp = hot.box_props.unpack();
let pad_left = bp.padding.left + bp.border.left;
let pad_top = bp.padding.top + bp.border.top;
let pad_right = bp.padding.right + bp.border.right;
let pad_bottom = bp.padding.bottom + bp.border.bottom;
let clamp = |v: f32, max: f32| v.max(0.0).min(max);
let x0 = clamp(pos.x + pad_left, window_size.width);
let y0 = clamp(pos.y + pad_top, window_size.height);
let x1 = clamp(pos.x + size.width - pad_right, window_size.width);
let y1 = clamp(pos.y + size.height - pad_bottom, window_size.height);
if x1 <= x0 || y1 <= y0 {
return zero;
}
LogicalRect {
origin: LogicalPosition { x: x0, y: y0 },
size: LogicalSize {
width: x1 - x0,
height: y1 - y0,
},
}
}
fn collect_child_text(
node_data: &[NodeData],
node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
dom_idx: usize,
) -> (String, bool) {
let mut text = String::new();
let mut has_non_text = false;
let mut child = node_hierarchy[dom_idx].first_child_id(NodeId::new(dom_idx));
let mut guard = 0usize;
while let Some(child_id) = child {
guard += 1;
if guard > 10_000 {
break;
}
if let Some(child_data) = node_data.get(child_id.index()) {
if let NodeType::Text(t) = &child_data.node_type {
if !text.is_empty() {
text.push(' ');
}
text.push_str(t.as_str());
} else {
has_non_text = true;
}
}
if child_id.index() >= node_hierarchy.len() {
break;
}
child = node_hierarchy[child_id.index()].next_sibling_id();
}
(text, has_non_text)
}
fn supported_actions(
node_data: &NodeData,
scroll_manager: &ScrollManager,
dom_id: DomId,
node_id: NodeId,
) -> Vec<AccessibilityAction> {
let mut actions = Vec::new();
actions.push(AccessibilityAction::ScrollIntoView);
if node_data.is_focusable() || node_data.is_contenteditable() {
actions.push(AccessibilityAction::Focus);
actions.push(AccessibilityAction::Blur);
}
if node_data.has_activation_behavior() {
actions.push(AccessibilityAction::Default);
}
if node_data.is_contenteditable()
|| matches!(node_data.node_type, NodeType::TextArea | NodeType::Input)
{
actions.push(AccessibilityAction::SetValue(azul_css::AzString::from("")));
actions.push(AccessibilityAction::ReplaceSelectedText(
azul_css::AzString::from(""),
));
}
if let Some((_offset, max_x, max_y)) = scroll_manager.a11y_scroll_info(dom_id, node_id) {
if max_y > 0.0 {
actions.push(AccessibilityAction::ScrollUp);
actions.push(AccessibilityAction::ScrollDown);
}
if max_x > 0.0 {
actions.push(AccessibilityAction::ScrollLeft);
actions.push(AccessibilityAction::ScrollRight);
}
actions.push(AccessibilityAction::SetScrollOffset(LogicalPosition {
x: 0.0,
y: 0.0,
}));
}
if let Some(info) = node_data.get_accessibility_info() {
for declared in info.supported_actions.as_ref() {
if !actions.contains(declared) {
actions.push(declared.clone());
}
}
}
actions
}
const fn node_type_to_role(node_type: &NodeType) -> AccessibilityRole {
match node_type {
NodeType::Button => AccessibilityRole::PushButton,
NodeType::A => AccessibilityRole::Link,
NodeType::Text(_)
| NodeType::P
| NodeType::Span
| NodeType::H1
| NodeType::H2
| NodeType::H3
| NodeType::H4
| NodeType::H5
| NodeType::H6 => AccessibilityRole::StaticText,
NodeType::Input | NodeType::TextArea => AccessibilityRole::Text,
NodeType::Image(_) => AccessibilityRole::Graphic,
NodeType::Ul | NodeType::Ol => AccessibilityRole::List,
NodeType::Li => AccessibilityRole::ListItem,
NodeType::Table => AccessibilityRole::Table,
NodeType::Td | NodeType::Th => AccessibilityRole::Cell,
_ => AccessibilityRole::Grouping,
}
}