#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![doc(html_root_url = "https://docs.smix.dev/smix-selector-resolver")]
use smix_screen::{A11yNode, Rect, Role, is_visible_enough};
use smix_selector::{
AnchorBox, CompiledPattern, Modifiers, Pattern, Selector, match_text_compiled,
};
use std::collections::HashMap;
const NEAR_THRESHOLD_PT: f64 = 100.0;
#[must_use]
pub fn resolve_selector<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
) -> Option<&'tree A11yNode> {
let ctx = ResolverContext::new(selector)?;
resolve_selector_compiled(tree, selector, &ctx)
}
#[must_use]
pub fn resolve_selector_all<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
) -> Vec<&'tree A11yNode> {
let Some(ctx) = ResolverContext::new(selector) else {
return vec![];
};
resolve_selector_all_compiled(tree, selector, &ctx)
}
#[must_use]
pub fn resolve_selector_compiled<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
ctx: &ResolverContext,
) -> Option<&'tree A11yNode> {
resolve_inner(tree, selector, ctx).into_iter().next()
}
#[must_use]
pub fn resolve_selector_all_compiled<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
ctx: &ResolverContext,
) -> Vec<&'tree A11yNode> {
resolve_inner_no_index(tree, selector, ctx)
}
pub struct ResolverContext {
compiled: HashMap<*const Pattern, CompiledPattern>,
}
unsafe impl Send for ResolverContext {}
unsafe impl Sync for ResolverContext {}
impl ResolverContext {
pub fn new(selector: &Selector) -> Option<Self> {
let mut compiled = HashMap::new();
if !Self::compile_selector(selector, &mut compiled) {
return None;
}
Some(ResolverContext { compiled })
}
fn compile_selector(
selector: &Selector,
out: &mut HashMap<*const Pattern, CompiledPattern>,
) -> bool {
match selector {
Selector::Text { text, modifiers } => {
if !Self::cache_pattern(text, out) {
return false;
}
Self::compile_modifiers(modifiers, out)
}
Selector::Id { modifiers, .. } | Selector::Label { modifiers, .. } => {
Self::compile_modifiers(modifiers, out)
}
Selector::Role {
name, modifiers, ..
} => {
if let Some(name_pat) = name
&& !Self::cache_pattern(name_pat, out)
{
return false;
}
Self::compile_modifiers(modifiers, out)
}
Selector::Focused { .. } => true,
Selector::Anchor { anchor, .. } => Self::compile_anchor(anchor, out),
Selector::LocalizedText { modifiers, .. } => {
Self::compile_modifiers(modifiers, out)
}
Selector::OcrText { modifiers, .. } => {
Self::compile_modifiers(modifiers, out)
}
Selector::AnchorRelative { anchor, .. } => {
Self::compile_selector(anchor, out)
}
Selector::Point { .. } => true,
Selector::Fallback { fallback } => {
fallback.iter().all(|s| Self::compile_selector(s, out))
}
}
}
fn cache_pattern(p: &Pattern, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
let key = p as *const Pattern;
if out.contains_key(&key) {
return true;
}
match p.compile() {
Ok(cp) => {
out.insert(key, cp);
true
}
Err(_) => false,
}
}
fn compile_modifiers(
m: &Modifiers,
out: &mut HashMap<*const Pattern, CompiledPattern>,
) -> bool {
let slots = [
m.near.as_deref(),
m.below.as_deref(),
m.above.as_deref(),
m.left_of.as_deref(),
m.right_of.as_deref(),
m.inside.as_deref(),
m.ancestor.as_deref(),
];
for child in slots.iter().flatten() {
if !Self::compile_selector(child, out) {
return false;
}
}
true
}
fn compile_anchor(a: &AnchorBox, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
let slots = [
a.near.as_deref(),
a.below.as_deref(),
a.above.as_deref(),
a.left_of.as_deref(),
a.right_of.as_deref(),
a.inside.as_deref(),
];
for child in slots.iter().flatten() {
if !Self::compile_selector(child, out) {
return false;
}
}
true
}
pub fn pattern(&self, p: &Pattern) -> Option<&CompiledPattern> {
self.compiled.get(&(p as *const Pattern))
}
}
fn resolve_inner<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
ctx: &ResolverContext,
) -> Vec<&'tree A11yNode> {
let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
let visible: Vec<&A11yNode> = raw
.into_iter()
.filter(|n| is_visible_enough(n, tree))
.collect();
let topmost = topmost_modal_filter(tree, visible);
let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
return vec![];
};
let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
return vec![];
};
let tappable = tappable_subset_filter(after_spatial);
apply_index(tappable, selector)
}
fn resolve_inner_no_index<'tree>(
tree: &'tree A11yNode,
selector: &Selector,
ctx: &ResolverContext,
) -> Vec<&'tree A11yNode> {
let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
let visible: Vec<&A11yNode> = raw
.into_iter()
.filter(|n| is_visible_enough(n, tree))
.collect();
let topmost = topmost_modal_filter(tree, visible);
let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
return vec![];
};
let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
return vec![];
};
tappable_subset_filter(after_spatial)
}
fn tappable_subset_filter(candidates: Vec<&A11yNode>) -> Vec<&A11yNode> {
if candidates.len() <= 1 {
return candidates;
}
let is_tappable = |n: &A11yNode| -> bool {
matches!(
n.raw_type.as_str(),
"button" | "link" | "cell" | "tab" | "menuItem"
)
};
let has_tappable = candidates.iter().any(|n| is_tappable(n));
let has_non_tappable = candidates.iter().any(|n| !is_tappable(n));
if has_tappable && has_non_tappable {
candidates.into_iter().filter(|n| is_tappable(n)).collect()
} else {
candidates
}
}
fn is_modal_node(n: &A11yNode) -> bool {
matches!(n.raw_type.as_str(), "alert" | "dialog")
|| matches!(n.role, Some(Role::Alert) | Some(Role::Dialog))
}
fn tree_has_modal_role(node: &A11yNode) -> bool {
if is_modal_node(node) {
return true;
}
node.children.iter().any(tree_has_modal_role)
}
fn topmost_modal_filter<'tree>(
tree: &'tree A11yNode,
candidates: Vec<&'tree A11yNode>,
) -> Vec<&'tree A11yNode> {
if candidates.len() <= 1 || !tree_has_modal_role(tree) {
return candidates;
}
let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
for c in &n.children {
parent.insert(c as *const A11yNode, n);
walk(c, parent);
}
}
walk(tree, &mut parent);
let in_modal_subtree = |start: &A11yNode| -> bool {
if is_modal_node(start) {
return true;
}
let mut cur: *const A11yNode = start;
while let Some(p) = parent.get(&cur) {
if is_modal_node(p) {
return true;
}
cur = *p as *const A11yNode;
}
false
};
let in_modal: Vec<&'tree A11yNode> = candidates
.iter()
.copied()
.filter(|n| in_modal_subtree(n))
.collect();
if in_modal.is_empty() {
candidates
} else {
in_modal
}
}
fn dfs_collect<'tree, F>(tree: &'tree A11yNode, pred: F) -> Vec<&'tree A11yNode>
where
F: Fn(&A11yNode) -> bool,
{
let mut out: Vec<&'tree A11yNode> = Vec::new();
fn walk<'tree, F: Fn(&A11yNode) -> bool>(
n: &'tree A11yNode,
pred: &F,
out: &mut Vec<&'tree A11yNode>,
) {
if pred(n) {
out.push(n);
}
for c in &n.children {
walk(c, pred, out);
}
}
walk(tree, &pred, &mut out);
out
}
fn matches_base(node: &A11yNode, selector: &Selector, ctx: &ResolverContext) -> bool {
match selector {
Selector::Anchor { .. } => true,
Selector::Text { text, .. } => match ctx.pattern(text) {
Some(cp) => match_text_compiled(node, cp),
None => false,
},
Selector::Id { id, .. } => {
if id.is_empty() {
return false;
}
node.identifier.as_deref() == Some(id.as_str())
}
Selector::Label { label, .. } => {
if label.is_empty() {
return false;
}
node.label.as_deref() == Some(label.as_str())
}
Selector::Role { role, name, .. } => {
if node.role != Some(*role) {
return false;
}
match name {
None => true,
Some(name_pat) => match ctx.pattern(name_pat) {
Some(cp) => match_text_compiled(node, cp),
None => false,
},
}
}
Selector::Focused { .. } => node.has_focus,
Selector::LocalizedText { .. } => false,
Selector::OcrText { .. } => false,
Selector::AnchorRelative { .. } => false,
Selector::Point { .. } | Selector::Fallback { .. } => false,
}
}
fn apply_ancestor_filter<'tree>(
tree: &'tree A11yNode,
candidates: Vec<&'tree A11yNode>,
selector: &Selector,
) -> Option<Vec<&'tree A11yNode>> {
let ancestor_sel = match selector {
Selector::Text { modifiers, .. }
| Selector::Id { modifiers, .. }
| Selector::Label { modifiers, .. }
| Selector::Role { modifiers, .. }
| Selector::LocalizedText { modifiers, .. }
| Selector::OcrText { modifiers, .. } => modifiers.ancestor.as_deref(),
Selector::Anchor { .. }
| Selector::Focused { .. }
| Selector::AnchorRelative { .. }
| Selector::Point { .. }
| Selector::Fallback { .. } => None,
};
let Some(ancestor_sel) = ancestor_sel else {
return Some(candidates);
};
let anchor = resolve_selector(tree, ancestor_sel)?;
let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
for c in &n.children {
parent.insert(c as *const A11yNode, n);
walk(c, parent);
}
}
walk(tree, &mut parent);
let surviving: Vec<&'tree A11yNode> = candidates
.into_iter()
.filter(|c| {
if std::ptr::eq(*c, anchor) {
return false;
}
let mut cur: *const A11yNode = *c;
while let Some(p) = parent.get(&cur) {
if std::ptr::eq(*p, anchor) {
return true;
}
cur = *p as *const A11yNode;
}
false
})
.collect();
Some(surviving)
}
#[derive(Clone, Copy)]
enum SpatialKey {
Near,
Below,
Above,
LeftOf,
RightOf,
Inside,
}
const SPATIAL_KEYS: [SpatialKey; 6] = [
SpatialKey::Near,
SpatialKey::Below,
SpatialKey::Above,
SpatialKey::LeftOf,
SpatialKey::RightOf,
SpatialKey::Inside,
];
fn get_spatial(selector: &Selector, key: SpatialKey) -> Option<&Selector> {
match selector {
Selector::Anchor { anchor, .. } => match key {
SpatialKey::Near => anchor.near.as_deref(),
SpatialKey::Below => anchor.below.as_deref(),
SpatialKey::Above => anchor.above.as_deref(),
SpatialKey::LeftOf => anchor.left_of.as_deref(),
SpatialKey::RightOf => anchor.right_of.as_deref(),
SpatialKey::Inside => anchor.inside.as_deref(),
},
Selector::Text { modifiers, .. }
| Selector::Id { modifiers, .. }
| Selector::Label { modifiers, .. }
| Selector::Role { modifiers, .. }
| Selector::LocalizedText { modifiers, .. }
| Selector::OcrText { modifiers, .. } => match key {
SpatialKey::Near => modifiers.near.as_deref(),
SpatialKey::Below => modifiers.below.as_deref(),
SpatialKey::Above => modifiers.above.as_deref(),
SpatialKey::LeftOf => modifiers.left_of.as_deref(),
SpatialKey::RightOf => modifiers.right_of.as_deref(),
SpatialKey::Inside => modifiers.inside.as_deref(),
},
Selector::Focused { .. }
| Selector::AnchorRelative { .. }
| Selector::Point { .. }
| Selector::Fallback { .. } => None,
}
}
fn apply_spatial_filters<'tree>(
tree: &'tree A11yNode,
candidates: Vec<&'tree A11yNode>,
selector: &Selector,
_ctx: &ResolverContext,
) -> Option<Vec<&'tree A11yNode>> {
let mut surviving = candidates;
for key in &SPATIAL_KEYS {
let Some(anchor_sel) = get_spatial(selector, *key) else {
continue;
};
let Some(anchor) = resolve_selector(tree, anchor_sel) else {
return None; };
surviving.retain(|c| satisfies(*key, c, anchor));
}
Some(surviving)
}
fn satisfies(key: SpatialKey, c: &A11yNode, a: &A11yNode) -> bool {
if std::ptr::eq(c, a) {
return false;
}
let cc = centroid(c.bounds);
let ac = centroid(a.bounds);
match key {
SpatialKey::Near => dist(cc, ac) <= NEAR_THRESHOLD_PT,
SpatialKey::Below => cc.1 > ac.1,
SpatialKey::Above => cc.1 < ac.1,
SpatialKey::LeftOf => cc.0 < ac.0,
SpatialKey::RightOf => cc.0 > ac.0,
SpatialKey::Inside => contains(a.bounds, c.bounds),
}
}
#[inline]
fn centroid(r: Rect) -> (f64, f64) {
(r.x + r.w / 2.0, r.y + r.h / 2.0)
}
#[inline]
fn dist(p: (f64, f64), q: (f64, f64)) -> f64 {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
(dx * dx + dy * dy).sqrt()
}
#[inline]
fn contains(outer: Rect, inner: Rect) -> bool {
inner.x >= outer.x
&& inner.y >= outer.y
&& inner.x + inner.w <= outer.x + outer.w
&& inner.y + inner.h <= outer.y + outer.h
}
fn apply_index<'tree>(list: Vec<&'tree A11yNode>, selector: &Selector) -> Vec<&'tree A11yNode> {
let (first, last, nth) = match selector {
Selector::Anchor { index, .. } => (index.first, index.last, index.nth),
Selector::Text { modifiers, .. }
| Selector::Id { modifiers, .. }
| Selector::Label { modifiers, .. }
| Selector::Role { modifiers, .. }
| Selector::LocalizedText { modifiers, .. }
| Selector::OcrText { modifiers, .. } => (modifiers.first, modifiers.last, modifiers.nth),
Selector::Focused { .. }
| Selector::AnchorRelative { .. }
| Selector::Point { .. }
| Selector::Fallback { .. } => return list,
};
let has_first = first == Some(true);
let has_last = last == Some(true);
let has_nth = nth.is_some();
if !has_first && !has_last && !has_nth {
return list;
}
let picked: Option<&'tree A11yNode> = if has_nth {
nth.and_then(|i| list.get(i).copied())
} else if has_last {
list.last().copied()
} else {
list.first().copied()
};
match picked {
Some(n) => vec![n],
None => vec![],
}
}