#[cfg(feature = "a11y")]
use std::collections::HashMap;
#[cfg(feature = "a11y")]
use accesskit::{Action, ActionRequest, Node, NodeId as A11yNodeId, Rect, Role, Tree, TreeUpdate};
use azul_core::{
dom::{
AccessibilityAction, AccessibilityInfo, AccessibilityRole, AccessibilityState, DomId,
DomNodeId, NodeData, NodeId, NodeType, TextSelectionStartEnd,
},
geom::{LogicalPosition, LogicalSize},
styled_dom::NodeHierarchyItemId,
};
use azul_css::AzString;
use crate::{solver3::layout_tree::LayoutNodeHot, window::DomLayoutResult};
#[cfg(feature = "a11y")]
pub struct CursorA11yInfo {
pub dom_id: DomId,
pub node_id: NodeId,
pub anchor_offset: usize,
pub focus_offset: usize,
}
#[cfg(feature = "a11y")]
pub struct A11yManager {
pub root_id: A11yNodeId,
pub tree: Option<Tree>,
pub last_tree_update: Option<TreeUpdate>,
pub tree_initialized: bool,
}
#[cfg(feature = "a11y")]
impl A11yManager {
pub fn new() -> Self {
let root_id = A11yNodeId(0);
Self {
root_id,
tree: None,
last_tree_update: None,
tree_initialized: false,
}
}
pub fn update_tree(
root_id: A11yNodeId,
layout_results: &std::collections::BTreeMap<DomId, DomLayoutResult>,
window_title: &AzString,
window_size: LogicalSize,
focused_node: Option<azul_core::dom::DomNodeId>,
hidpi_factor: f32,
dirty_text_overrides: &std::collections::BTreeMap<(DomId, NodeId), String>,
cursor_info: Option<CursorA11yInfo>,
) -> TreeUpdate {
let mut nodes = Vec::new();
let mut root_children = Vec::new();
let mut node_id_map: HashMap<(u32, u32), A11yNodeId> = HashMap::new();
let mut parent_children_map: HashMap<A11yNodeId, Vec<A11yNodeId>> = HashMap::new();
let mut root_node = Node::new(Role::Window);
root_node.set_label(window_title.as_str());
nodes.push((root_id, root_node));
for (dom_id, layout_result) in layout_results {
let styled_dom = &layout_result.styled_dom;
let node_hierarchy = styled_dom.node_hierarchy.as_ref();
let node_data_slice = styled_dom.node_data.as_ref();
for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
let a11y_info = node_data.get_accessibility_info();
let should_create_node = a11y_info.is_some()
|| node_data.is_contenteditable()
|| node_data.is_focusable()
|| !matches!(node_data.node_type,
NodeType::Head | NodeType::Meta | NodeType::Link
| NodeType::Script | NodeType::Style | NodeType::Base
| NodeType::Before | NodeType::After | NodeType::Marker
| NodeType::Placeholder | NodeType::Source | NodeType::Track
| NodeType::Param | NodeType::Col | NodeType::ColGroup
| NodeType::Wbr | NodeType::Rp | NodeType::Rtc
| NodeType::Bdo | NodeType::Bdi | NodeType::Data
| NodeType::Map | NodeType::Area | NodeType::VirtualView
);
if !should_create_node {
continue;
}
let a11y_node_id = A11yNodeId(((dom_id.inner as u64) << 32) | ((dom_idx as u64) + 1));
let dom_node_id = NodeId::new(dom_idx);
let layout_info = layout_result.layout_tree.dom_to_layout
.get(&dom_node_id)
.and_then(|indices| indices.first())
.and_then(|&layout_idx| {
let hot = layout_result.layout_tree.get(layout_idx)?;
let abs_pos = layout_result.calculated_positions
.get(layout_idx).copied();
Some((hot, layout_idx, abs_pos))
});
let a11y_info_ref = a11y_info.as_ref().map(|b| b.as_ref());
let mut node = match layout_info {
Some((layout_node, _layout_idx, abs_pos)) => {
Self::build_node(node_data, layout_node, abs_pos, a11y_info_ref, hidpi_factor, window_size)
}
None => {
let role = if let Some(info) = a11y_info_ref {
Self::map_role(&info.role)
} else {
Self::node_type_to_role(&node_data.node_type)
};
let mut builder = Node::new(role);
if let NodeType::Text(text) = &node_data.node_type {
builder.set_label(text.as_str());
}
builder
}
};
{
let hierarchy_item = &node_hierarchy[dom_idx];
let dom_node_id_key = (*dom_id, NodeId::new(dom_idx));
let (text_content, has_non_text_children) = if let Some(override_text) = dirty_text_overrides.get(&dom_node_id_key) {
(override_text.clone(), false)
} else {
let mut text = String::new();
let mut has_non_text = false;
let mut child = hierarchy_item.first_child_id(NodeId::new(dom_idx));
while let Some(child_id) = child {
if let Some(child_data) = node_data_slice.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)
};
if !text_content.is_empty() {
if node_data.is_contenteditable()
|| matches!(node_data.node_type, NodeType::TextArea | NodeType::Input)
{
node.set_value(text_content.as_str());
node.add_action(Action::SetTextSelection);
node.add_action(Action::ReplaceSelectedText);
node.add_action(Action::SetValue);
if let Some(ref ci) = cursor_info {
if ci.dom_id == *dom_id && ci.node_id == NodeId::new(dom_idx) {
let char_lengths: Vec<u8> = text_content.chars()
.map(|c| c.len_utf16() as u8)
.collect();
node.set_character_lengths(char_lengths.clone());
let byte_to_char_idx = |byte_off: usize| -> usize {
text_content
.char_indices()
.take_while(|(b, _)| *b < byte_off)
.count()
.min(char_lengths.len())
};
let anchor_idx = byte_to_char_idx(ci.anchor_offset);
let focus_idx = byte_to_char_idx(ci.focus_offset);
node.set_text_selection(accesskit::TextSelection {
anchor: accesskit::TextPosition {
node: a11y_node_id,
character_index: anchor_idx,
},
focus: accesskit::TextPosition {
node: a11y_node_id,
character_index: focus_idx,
},
});
}
}
} else if !has_non_text_children {
node.set_label(text_content.as_str());
}
}
}
node_id_map.insert((dom_id.inner as u32, dom_idx as u32), a11y_node_id);
nodes.push((a11y_node_id, node));
}
for (dom_idx, _) in node_data_slice.iter().enumerate() {
let a11y_node_id = match node_id_map.get(&(dom_id.inner as u32, dom_idx as u32)) {
Some(id) => *id,
None => continue,
};
let hierarchy_item = &node_hierarchy[dom_idx];
let mut current_parent = hierarchy_item.parent_id();
let mut accessible_parent_id = None;
let mut iterations = 0;
while let Some(parent_node_id) = current_parent {
iterations += 1;
if iterations > 10_000 { break; }
let parent_idx = parent_node_id.index();
if let Some(parent_a11y_id) =
node_id_map.get(&(dom_id.inner as u32, parent_idx as u32))
{
accessible_parent_id = Some(*parent_a11y_id);
break;
}
if parent_idx >= node_hierarchy.len() { break; }
current_parent = node_hierarchy[parent_idx].parent_id();
}
if let Some(parent_id) = accessible_parent_id {
parent_children_map
.entry(parent_id)
.or_insert_with(Vec::new)
.push(a11y_node_id);
} else {
root_children.push(a11y_node_id);
}
}
}
for (node_id, node) in nodes.iter_mut() {
if *node_id == root_id {
node.set_children(root_children.clone());
} else if let Some(children) = parent_children_map.get(node_id) {
node.set_children(children.clone());
}
}
let focus = focused_node
.and_then(|dom_node_id| {
let dom_idx = dom_node_id.node.into_crate_internal()?.index();
node_id_map.get(&(dom_node_id.dom.inner as u32, dom_idx as u32)).copied()
})
.unwrap_or_else(|| {
nodes.iter()
.find(|(id, node)| {
*id != root_id && !matches!(node.role(), Role::GenericContainer | Role::Window)
})
.map(|(id, _)| *id)
.unwrap_or(root_id)
});
let tree_update = TreeUpdate {
nodes,
tree: Some(Tree::new(root_id)),
focus,
tree_id: accesskit::TreeId::ROOT,
};
tree_update
}
fn build_node(
node_data: &NodeData,
layout_node: &LayoutNodeHot,
abs_pos: Option<LogicalPosition>,
a11y_info: Option<&AccessibilityInfo>,
hidpi_factor: f32,
window_size: LogicalSize,
) -> Node {
let role = if node_data.is_contenteditable() {
Role::MultilineTextInput
} else if let Some(info) = a11y_info {
Self::map_role(&info.role)
} else {
Self::node_type_to_role(&node_data.node_type)
};
let mut builder = Node::new(role);
let tag = node_data.node_type.get_path().to_string();
if !tag.is_empty() {
builder.set_html_tag(tag.as_str());
}
if let Some(info) = a11y_info {
if let Some(name) = info.accessibility_name.as_option() {
builder.set_label(name.as_str());
}
if let Some(value) = info.accessibility_value.as_option() {
builder.set_value(value.as_str());
}
if let Some(desc) = info.description.as_option() {
builder.set_description(desc.as_str());
}
}
if let Some(label) = node_data.get_accessible_label() {
builder.set_label(label);
}
if let Some(value) = node_data.get_accessible_value() {
builder.set_value(value);
}
if let NodeType::Text(text) = &node_data.node_type {
builder.set_label(text.as_str());
}
if let Some(info) = a11y_info {
for state in info.states.as_ref() {
match state {
AccessibilityState::Unavailable => { builder.set_disabled(); }
AccessibilityState::Readonly => { builder.set_read_only(); }
AccessibilityState::CheckedTrue => { builder.set_toggled(accesskit::Toggled::True); }
AccessibilityState::CheckedFalse => { builder.set_toggled(accesskit::Toggled::False); }
AccessibilityState::Expanded => { builder.set_expanded(true); }
AccessibilityState::Collapsed => { builder.set_expanded(false); }
AccessibilityState::Focusable => { builder.add_action(Action::Focus); }
AccessibilityState::Selected => { builder.set_selected(true); }
AccessibilityState::Busy => { builder.set_busy(); }
AccessibilityState::Offscreen => { builder.set_hidden(); }
_ => {}
}
}
}
match &node_data.node_type {
NodeType::H1 => { builder.set_level(1); }
NodeType::H2 => { builder.set_level(2); }
NodeType::H3 => { builder.set_level(3); }
NodeType::H4 => { builder.set_level(4); }
NodeType::H5 => { builder.set_level(5); }
NodeType::H6 => { builder.set_level(6); }
_ => {}
}
for attr in node_data.attributes().as_ref() {
match attr {
azul_core::dom::AttributeType::AriaLabel(s) => {
builder.set_label(s.as_str());
}
azul_core::dom::AttributeType::Title(s)
| azul_core::dom::AttributeType::Alt(s) => {
builder.set_description(s.as_str());
}
azul_core::dom::AttributeType::Placeholder(s) => {
builder.set_placeholder(s.as_str());
}
azul_core::dom::AttributeType::Value(s) => {
builder.set_value(s.as_str());
}
azul_core::dom::AttributeType::Disabled => {
builder.set_disabled();
}
azul_core::dom::AttributeType::Readonly => {
builder.set_read_only();
}
azul_core::dom::AttributeType::CheckedTrue => {
builder.set_toggled(accesskit::Toggled::True);
}
azul_core::dom::AttributeType::CheckedFalse => {
builder.set_toggled(accesskit::Toggled::False);
}
azul_core::dom::AttributeType::Required => {
builder.set_required();
}
azul_core::dom::AttributeType::Hidden => {
builder.set_hidden();
}
azul_core::dom::AttributeType::Lang(s) => {
builder.set_language(s.as_str());
}
azul_core::dom::AttributeType::ColSpan(n) => {
builder.set_column_span(*n as usize);
}
azul_core::dom::AttributeType::RowSpan(n) => {
builder.set_row_span(*n as usize);
}
_ => {}
}
}
if let (Some(pos), Some(size)) = (abs_pos, layout_node.used_size) {
let bp = layout_node.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 s = hidpi_factor as f64;
let ww = window_size.width as f64 * s;
let wh = window_size.height as f64 * s;
let x0 = ((pos.x + pad_left) as f64 * s).max(0.0).min(ww);
let y0 = ((pos.y + pad_top) as f64 * s).max(0.0).min(wh);
let x1 = ((pos.x + size.width - pad_right) as f64 * s).max(0.0).min(ww);
let y1 = ((pos.y + size.height - pad_bottom) as f64 * s).max(0.0).min(wh);
if x1 > x0 && y1 > y0 {
builder.set_bounds(Rect { x0, y0, x1, y1 });
}
}
if node_data.is_focusable() || node_data.is_contenteditable() {
builder.add_action(Action::Focus);
}
if node_data.has_activation_behavior() {
builder.add_action(Action::Click);
}
builder
}
const fn node_type_to_role(node_type: &NodeType) -> Role {
match node_type {
NodeType::Text(_) => Role::Label,
NodeType::P => Role::Paragraph,
NodeType::Pre => Role::Code,
NodeType::BlockQuote => Role::Blockquote,
NodeType::Code => Role::Code,
NodeType::Em | NodeType::I => Role::Emphasis,
NodeType::Strong | NodeType::B => Role::Strong,
NodeType::Mark => Role::Mark,
NodeType::Del => Role::ContentDeletion,
NodeType::Ins => Role::ContentInsertion,
NodeType::Abbr | NodeType::Acronym => Role::Abbr,
NodeType::Q => Role::Blockquote,
NodeType::Time => Role::Time,
NodeType::Cite | NodeType::Dfn | NodeType::Var
| NodeType::Samp | NodeType::Kbd => Role::Label,
NodeType::Small | NodeType::Big | NodeType::Sub
| NodeType::Sup | NodeType::U | NodeType::S => Role::Label,
NodeType::Ruby => Role::Ruby,
NodeType::Rt => Role::RubyAnnotation,
NodeType::Br => Role::LineBreak,
NodeType::Hr => Role::Splitter,
NodeType::Body => Role::Group,
NodeType::Div => Role::Group,
NodeType::Span => Role::Group,
NodeType::Html => Role::Group,
NodeType::Article => Role::Article,
NodeType::Section => Role::Section,
NodeType::Nav => Role::Navigation,
NodeType::Main => Role::Main,
NodeType::Header => Role::Header,
NodeType::Footer => Role::Footer,
NodeType::Aside => Role::Complementary,
NodeType::Address => Role::Group,
NodeType::Figure => Role::Figure,
NodeType::FigCaption => Role::FigureCaption,
NodeType::Details => Role::Details,
NodeType::Summary => Role::DisclosureTriangle,
NodeType::Dialog => Role::Dialog,
NodeType::H1 | NodeType::H2 | NodeType::H3
| NodeType::H4 | NodeType::H5 | NodeType::H6 => Role::Heading,
NodeType::Ul | NodeType::Ol | NodeType::Dir => Role::List,
NodeType::Li => Role::ListItem,
NodeType::Dl => Role::DescriptionList,
NodeType::Dt => Role::Term,
NodeType::Dd => Role::Definition,
NodeType::Menu => Role::Menu,
NodeType::MenuItem => Role::MenuItem,
NodeType::Table => Role::Table,
NodeType::Caption => Role::Caption,
NodeType::THead | NodeType::TBody | NodeType::TFoot => Role::RowGroup,
NodeType::Tr => Role::Row,
NodeType::Th => Role::ColumnHeader,
NodeType::Td => Role::Cell,
NodeType::ColGroup | NodeType::Col => Role::GenericContainer,
NodeType::Form => Role::Form,
NodeType::FieldSet => Role::Group,
NodeType::Legend => Role::Legend,
NodeType::Label => Role::Label,
NodeType::Input => Role::TextInput,
NodeType::Button => Role::Button,
NodeType::Select => Role::ComboBox,
NodeType::OptGroup => Role::Group,
NodeType::SelectOption => Role::ListBoxOption,
NodeType::TextArea => Role::MultilineTextInput,
NodeType::Output => Role::Status,
NodeType::Progress => Role::ProgressIndicator,
NodeType::Meter => Role::Meter,
NodeType::DataList => Role::ListBox,
NodeType::A => Role::Link,
NodeType::Image(_) => Role::Image,
NodeType::Icon(_) => Role::Image,
NodeType::Canvas => Role::Canvas,
NodeType::Audio => Role::Audio,
NodeType::Video => Role::Video,
NodeType::Svg => Role::SvgRoot,
NodeType::Object | NodeType::Embed => Role::EmbeddedObject,
_ => Role::Group,
}
}
fn map_role(role: &AccessibilityRole) -> Role {
match role {
AccessibilityRole::TitleBar => Role::TitleBar,
AccessibilityRole::MenuBar => Role::MenuBar,
AccessibilityRole::ScrollBar => Role::ScrollBar,
AccessibilityRole::Grip => Role::Splitter,
AccessibilityRole::Sound => Role::Audio,
AccessibilityRole::Cursor => Role::Caret,
AccessibilityRole::Caret => Role::Caret,
AccessibilityRole::Alert => Role::Alert,
AccessibilityRole::Window => Role::Window,
AccessibilityRole::Client => Role::GenericContainer,
AccessibilityRole::MenuPopup => Role::Menu,
AccessibilityRole::MenuItem => Role::MenuItem,
AccessibilityRole::Tooltip => Role::Tooltip,
AccessibilityRole::Application => Role::Application,
AccessibilityRole::Document => Role::Document,
AccessibilityRole::Pane => Role::Pane,
AccessibilityRole::Chart => Role::Figure,
AccessibilityRole::Dialog => Role::Dialog,
AccessibilityRole::Border => Role::GenericContainer,
AccessibilityRole::Grouping => Role::Group,
AccessibilityRole::Separator => Role::GenericContainer,
AccessibilityRole::Toolbar => Role::Toolbar,
AccessibilityRole::StatusBar => Role::Status,
AccessibilityRole::Table => Role::Table,
AccessibilityRole::ColumnHeader => Role::ColumnHeader,
AccessibilityRole::RowHeader => Role::RowHeader,
AccessibilityRole::Column => Role::GenericContainer, AccessibilityRole::Row => Role::Row,
AccessibilityRole::Cell => Role::Cell,
AccessibilityRole::Link => Role::Link,
AccessibilityRole::HelpBalloon => Role::Tooltip,
AccessibilityRole::Character => Role::GenericContainer,
AccessibilityRole::List => Role::List,
AccessibilityRole::ListItem => Role::ListItem,
AccessibilityRole::Outline => Role::Tree,
AccessibilityRole::OutlineItem => Role::TreeItem,
AccessibilityRole::PageTab => Role::Tab,
AccessibilityRole::PropertyPage => Role::TabPanel,
AccessibilityRole::Indicator => Role::Meter,
AccessibilityRole::Graphic => Role::Image,
AccessibilityRole::StaticText => Role::Label,
AccessibilityRole::Text => Role::TextInput,
AccessibilityRole::PushButton => Role::Button,
AccessibilityRole::CheckButton => Role::CheckBox,
AccessibilityRole::RadioButton => Role::RadioButton,
AccessibilityRole::ComboBox => Role::ComboBox,
AccessibilityRole::DropList => Role::ListBox,
AccessibilityRole::ProgressBar => Role::ProgressIndicator,
AccessibilityRole::Dial => Role::Meter,
AccessibilityRole::HotkeyField => Role::TextInput,
AccessibilityRole::Slider => Role::Slider,
AccessibilityRole::SpinButton => Role::SpinButton,
AccessibilityRole::Diagram => Role::Figure,
AccessibilityRole::Animation => Role::GenericContainer,
AccessibilityRole::Equation => Role::Math,
AccessibilityRole::ButtonDropdown => Role::Button,
AccessibilityRole::ButtonMenu => Role::Button,
AccessibilityRole::ButtonDropdownGrid => Role::Button,
AccessibilityRole::Whitespace => Role::GenericContainer,
AccessibilityRole::PageTabList => Role::TabList,
AccessibilityRole::Clock => Role::Timer,
AccessibilityRole::SplitButton => Role::Button,
AccessibilityRole::IpAddress => Role::TextInput,
AccessibilityRole::Unknown => Role::Unknown,
AccessibilityRole::Nothing => Role::GenericContainer,
}
}
pub fn handle_action_request(
&self,
request: ActionRequest,
) -> Option<(DomNodeId, AccessibilityAction)> {
let dom_id = DomId {
inner: (request.target_node.0 >> 32) as usize,
};
let node_id = NodeId::new(((request.target_node.0 & 0xFFFF_FFFF) - 1) as usize);
let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
let dom_node_id = DomNodeId {
dom: dom_id,
node: hierarchy_id,
};
Some((dom_node_id, map_accesskit_action(request)?))
}
}
#[cfg(feature = "a11y")]
fn map_accesskit_action(request: ActionRequest) -> Option<AccessibilityAction> {
use azul_css::{props::basic::FloatValue, AzString};
let action = match request.action {
Action::Click => AccessibilityAction::Default,
Action::Focus => AccessibilityAction::Focus,
Action::Blur => AccessibilityAction::Blur,
Action::Collapse => AccessibilityAction::Collapse,
Action::Expand => AccessibilityAction::Expand,
Action::ScrollIntoView => AccessibilityAction::ScrollIntoView,
Action::Increment => AccessibilityAction::Increment,
Action::Decrement => AccessibilityAction::Decrement,
Action::ShowContextMenu => AccessibilityAction::ShowContextMenu,
Action::HideTooltip => AccessibilityAction::HideTooltip,
Action::ShowTooltip => AccessibilityAction::ShowTooltip,
Action::ScrollUp => AccessibilityAction::ScrollUp,
Action::ScrollDown => AccessibilityAction::ScrollDown,
Action::ScrollLeft => AccessibilityAction::ScrollLeft,
Action::ScrollRight => AccessibilityAction::ScrollRight,
Action::SetSequentialFocusNavigationStartingPoint => {
AccessibilityAction::SetSequentialFocusNavigationStartingPoint
}
Action::ReplaceSelectedText => {
let accesskit::ActionData::Value(value) = request.data? else {
return None;
};
AccessibilityAction::ReplaceSelectedText(AzString::from(value.as_ref()))
}
Action::ScrollToPoint => {
let accesskit::ActionData::ScrollToPoint(point) = request.data? else {
return None;
};
AccessibilityAction::ScrollToPoint(LogicalPosition {
x: point.x as f32,
y: point.y as f32,
})
}
Action::SetScrollOffset => {
let accesskit::ActionData::SetScrollOffset(point) = request.data? else {
return None;
};
AccessibilityAction::SetScrollOffset(LogicalPosition {
x: point.x as f32,
y: point.y as f32,
})
}
Action::SetTextSelection => {
let accesskit::ActionData::SetTextSelection(selection) = request.data? else {
return None;
};
AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
selection_start: selection.anchor.character_index,
selection_end: selection.focus.character_index,
})
}
Action::SetValue => match request.data? {
accesskit::ActionData::Value(value) => {
AccessibilityAction::SetValue(AzString::from(value.as_ref()))
}
accesskit::ActionData::NumericValue(value) => {
AccessibilityAction::SetNumericValue(FloatValue::new(value as f32))
}
_ => return None,
},
Action::CustomAction => {
let accesskit::ActionData::CustomAction(id) = request.data? else {
return None;
};
AccessibilityAction::CustomAction(id)
}
};
Some(action)
}
#[cfg(not(feature = "a11y"))]
pub struct A11yManager {
_private: (),
}
#[cfg(not(feature = "a11y"))]
impl A11yManager {
pub fn new() -> Self {
Self { _private: () }
}
}