use core::ops::Index;
use std::collections::BTreeMap;
use accesskit::{
Node as AccessibilityNode, NodeId as AccessibilityNodeId, Rect as AccessibilityRect,
Role as AccessibilityRole, Toggled as AccessibilityToggled,
TreeUpdate as AccessibilityTreeUpdate,
};
use crate::selector::{ScopeRelation, Selector};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Role(AccessibilityRole);
impl Role {
pub const BUTTON: Self = Self(AccessibilityRole::Button);
pub const LABEL: Self = Self(AccessibilityRole::Label);
pub const TEXT_INPUT: Self = Self(AccessibilityRole::TextInput);
pub const PASSWORD_INPUT: Self = Self(AccessibilityRole::PasswordInput);
pub const CHECKBOX: Self = Self(AccessibilityRole::CheckBox);
pub const SWITCH: Self = Self(AccessibilityRole::Switch);
pub const SLIDER: Self = Self(AccessibilityRole::Slider);
pub const IMAGE: Self = Self(AccessibilityRole::Image);
pub const SCROLL_VIEW: Self = Self(AccessibilityRole::ScrollView);
pub const LIST: Self = Self(AccessibilityRole::List);
pub const LIST_ITEM: Self = Self(AccessibilityRole::ListItem);
pub const TAB: Self = Self(AccessibilityRole::Tab);
pub const TAB_LIST: Self = Self(AccessibilityRole::TabList);
pub const COMBOBOX: Self = Self(AccessibilityRole::ComboBox);
pub const OPTION: Self = Self(AccessibilityRole::ListBoxOption);
pub const MULTILINE_TEXT_INPUT: Self = Self(AccessibilityRole::MultilineTextInput);
pub const LINK: Self = Self(AccessibilityRole::Link);
pub const HEADER: Self = Self(AccessibilityRole::Header);
pub const FOOTER: Self = Self(AccessibilityRole::Footer);
pub const PROGRESS_INDICATOR: Self = Self(AccessibilityRole::ProgressIndicator);
pub const SPIN_BUTTON: Self = Self(AccessibilityRole::SpinButton);
pub const RADIO_BUTTON: Self = Self(AccessibilityRole::RadioButton);
pub const MENU: Self = Self(AccessibilityRole::Menu);
pub const MENU_BAR: Self = Self(AccessibilityRole::MenuBar);
pub const MENU_ITEM: Self = Self(AccessibilityRole::MenuItem);
pub const MENU_ITEM_CHECKBOX: Self = Self(AccessibilityRole::MenuItemCheckBox);
pub const MENU_ITEM_RADIO: Self = Self(AccessibilityRole::MenuItemRadio);
pub const TAB_PANEL: Self = Self(AccessibilityRole::TabPanel);
pub const TABLE: Self = Self(AccessibilityRole::Table);
pub const CELL: Self = Self(AccessibilityRole::Cell);
pub const COLUMN_HEADER: Self = Self(AccessibilityRole::ColumnHeader);
pub const GROUP: Self = Self(AccessibilityRole::Group);
pub const WINDOW: Self = Self(AccessibilityRole::Window);
pub const MAIN: Self = Self(AccessibilityRole::Main);
pub const NAVIGATION: Self = Self(AccessibilityRole::Navigation);
pub const SEARCH: Self = Self(AccessibilityRole::Search);
pub const ARTICLE: Self = Self(AccessibilityRole::Article);
pub const SECTION: Self = Self(AccessibilityRole::Section);
#[must_use]
pub const fn new(role: AccessibilityRole) -> Self {
Self(role)
}
#[must_use]
pub const fn as_accesskit(self) -> AccessibilityRole {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CheckedState {
False,
True,
Mixed,
}
impl From<AccessibilityRole> for Role {
fn from(role: AccessibilityRole) -> Self {
Self(role)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NodeId(AccessibilityNodeId);
impl NodeId {
#[must_use]
pub const fn as_u64(self) -> u64 {
self.0.0
}
pub(crate) const fn as_accesskit(self) -> AccessibilityNodeId {
self.0
}
}
impl From<AccessibilityNodeId> for NodeId {
fn from(value: AccessibilityNodeId) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NodeBounds {
x: f32,
y: f32,
width: f32,
height: f32,
}
impl NodeBounds {
#[must_use]
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
y,
width,
height,
}
}
#[must_use]
pub const fn x(self) -> f32 {
self.x
}
#[must_use]
pub const fn y(self) -> f32 {
self.y
}
#[must_use]
pub const fn width(self) -> f32 {
self.width
}
#[must_use]
pub const fn height(self) -> f32 {
self.height
}
#[must_use]
pub const fn center(self) -> (f32, f32) {
(
self.width.mul_add(0.5, self.x),
self.height.mul_add(0.5, self.y),
)
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "waterui-testing exposes f32 logical coordinates for pointer synthesis"
)]
fn accesskit_rect_to_node_bounds(rect: AccessibilityRect) -> NodeBounds {
NodeBounds::new(
rect.x0 as f32,
rect.y0 as f32,
(rect.x1 - rect.x0) as f32,
(rect.y1 - rect.y0) as f32,
)
}
#[derive(Debug, Clone, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "mirrors independent accessibility attributes reported by the platform tree"
)]
pub struct NodeSnapshot {
pub(crate) id: NodeId,
pub(crate) role: Role,
pub(crate) label: Option<String>,
pub(crate) identifier: Option<String>,
pub(crate) value: Option<String>,
pub(crate) bounds: Option<NodeBounds>,
pub(crate) enabled: bool,
pub(crate) selected: bool,
pub(crate) checked: Option<CheckedState>,
pub(crate) expanded: Option<bool>,
pub(crate) busy: bool,
pub(crate) hidden: bool,
pub(crate) children: Vec<NodeId>,
}
impl NodeSnapshot {
#[must_use]
pub const fn id(&self) -> NodeId {
self.id
}
#[must_use]
pub const fn role(&self) -> Role {
self.role
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
#[must_use]
pub fn identifier(&self) -> Option<&str> {
self.identifier.as_deref()
}
#[must_use]
pub fn value(&self) -> Option<&str> {
self.value.as_deref()
}
#[must_use]
pub const fn enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub const fn selected(&self) -> bool {
self.selected
}
#[must_use]
pub const fn checked(&self) -> Option<bool> {
match self.checked_state() {
Some(CheckedState::False) => Some(false),
Some(CheckedState::True) => Some(true),
Some(CheckedState::Mixed) | None => None,
}
}
#[must_use]
pub const fn checked_state(&self) -> Option<CheckedState> {
self.checked
}
#[must_use]
pub const fn expanded(&self) -> Option<bool> {
self.expanded
}
#[must_use]
pub const fn busy(&self) -> bool {
self.busy
}
#[must_use]
pub const fn bounds(&self) -> Option<NodeBounds> {
self.bounds
}
#[must_use]
pub const fn hidden(&self) -> bool {
self.hidden
}
#[must_use]
pub fn children(&self) -> &[NodeId] {
&self.children
}
fn from_accesskit(id: AccessibilityNodeId, node: &AccessibilityNode) -> Self {
let checked = match node.toggled() {
Some(AccessibilityToggled::True) => Some(CheckedState::True),
Some(AccessibilityToggled::False) => Some(CheckedState::False),
Some(AccessibilityToggled::Mixed) => Some(CheckedState::Mixed),
None => None,
};
let expanded = node.is_expanded();
let value = node
.value()
.map(ToOwned::to_owned)
.or_else(|| node.numeric_value().map(|v| v.to_string()));
Self {
id: NodeId::from(id),
role: Role(node.role()),
label: node.label().map(ToOwned::to_owned),
identifier: node.author_id().map(ToOwned::to_owned),
value,
bounds: node.bounds().map(accesskit_rect_to_node_bounds),
enabled: !node.is_disabled(),
selected: node.is_selected().unwrap_or(false),
checked,
expanded,
busy: node.is_busy(),
hidden: node.is_hidden(),
children: node.children().iter().copied().map(NodeId::from).collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct TreeSnapshot {
pub(crate) revision: u64,
pub(crate) root: NodeId,
pub(crate) focus: NodeId,
pub(crate) nodes: BTreeMap<NodeId, NodeSnapshot>,
}
impl TreeSnapshot {
pub(crate) fn empty() -> Self {
let root = NodeId::from(AccessibilityNodeId(0));
Self {
revision: 0,
root,
focus: root,
nodes: BTreeMap::new(),
}
}
pub(crate) fn from_update(revision: u64, update: AccessibilityTreeUpdate) -> Self {
let root = update.tree.as_ref().map_or_else(
|| NodeId::from(AccessibilityNodeId(0)),
|tree| NodeId::from(tree.root),
);
let focus = NodeId::from(update.focus);
let mut nodes = BTreeMap::new();
for (id, node) in update.nodes {
let stable_id = NodeId::from(id);
nodes.insert(stable_id, NodeSnapshot::from_accesskit(id, &node));
}
Self {
revision,
root,
focus,
nodes,
}
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub const fn root(&self) -> NodeId {
self.root
}
#[must_use]
pub const fn focus(&self) -> NodeId {
self.focus
}
#[must_use]
pub const fn nodes(&self) -> &BTreeMap<NodeId, NodeSnapshot> {
&self.nodes
}
#[must_use]
pub fn node(&self, id: NodeId) -> Option<&NodeSnapshot> {
self.nodes.get(&id)
}
pub(crate) fn matching(&self, selector: &Selector) -> Vec<NodeId> {
self.scoped_ids(selector)
.into_iter()
.filter(|id| selector.matches(&self[*id]))
.collect()
}
fn scoped_ids(&self, selector: &Selector) -> Vec<NodeId> {
let Some(scope) = selector.scope() else {
return self.nodes.keys().copied().collect();
};
match scope.relation() {
ScopeRelation::Descendants => self.descendants_of(scope.handle().id()),
ScopeRelation::Children => self[scope.handle().id()].children().to_vec(),
}
}
fn descendants_of(&self, parent: NodeId) -> Vec<NodeId> {
let mut descendants = Vec::new();
let mut stack = self[parent]
.children()
.iter()
.rev()
.copied()
.collect::<Vec<_>>();
while let Some(node_id) = stack.pop() {
descendants.push(node_id);
stack.extend(self[node_id].children().iter().rev().copied());
}
descendants
}
}
impl Index<NodeId> for TreeSnapshot {
type Output = NodeSnapshot;
fn index(&self, index: NodeId) -> &Self::Output {
self.nodes.get(&index).unwrap_or_else(|| {
panic!(
"waterui-testing tree index missing node id {} (revision {})",
index.as_u64(),
self.revision
)
})
}
}