use crate::geometry::Rect;
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct ElementStates: u16 {
const FOCUSED = 1 << 0;
const SELECTED = 1 << 1;
const DISABLED = 1 << 2;
const EXPANDED = 1 << 3;
const COLLAPSED = 1 << 4;
const CHECKED = 1 << 5;
const SETTABLE = 1 << 6;
const EDITABLE = 1 << 7;
const OFFSCREEN = 1 << 8;
}
}
impl ElementStates {
#[must_use]
pub fn labels(self) -> Vec<&'static str> {
const LABELS: [(ElementStates, &str); 9] = [
(ElementStates::FOCUSED, "focused"),
(ElementStates::SELECTED, "selected"),
(ElementStates::DISABLED, "disabled"),
(ElementStates::EXPANDED, "expanded"),
(ElementStates::COLLAPSED, "collapsed"),
(ElementStates::CHECKED, "checked"),
(ElementStates::SETTABLE, "settable"),
(ElementStates::EDITABLE, "editable"),
(ElementStates::OFFSCREEN, "offscreen"),
];
LABELS.iter().filter(|(flag, _)| self.contains(*flag)).map(|(_, label)| *label).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Action {
pub name: String,
pub label: String,
pub primary: bool,
}
impl Action {
#[must_use]
pub fn new(name: impl Into<String>, primary: bool) -> Self {
let name = name.into();
let label = humanize_action_name(&name);
Self { name, label, primary }
}
#[must_use]
pub fn matches(&self, query: &str) -> bool {
let q = normalize_action(query);
!q.is_empty() && (normalize_action(&self.name) == q || normalize_action(&self.label) == q)
}
}
fn normalize_action(value: &str) -> String {
let lowered: String = value.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
lowered.strip_prefix("ax").map(str::to_owned).unwrap_or(lowered)
}
fn humanize_action_name(name: &str) -> String {
let trimmed = name.strip_prefix("AX").unwrap_or(name);
let mut out = String::with_capacity(trimmed.len() + 4);
let mut prev_lower = false;
let mut start_word = true;
for c in trimmed.chars() {
if c == '_' || c == '-' || c == ' ' {
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
start_word = true;
prev_lower = false;
continue;
}
if c.is_uppercase() && prev_lower {
out.push(' ');
start_word = true;
}
if start_word {
out.extend(c.to_uppercase());
} else {
out.push(c);
}
start_word = false;
prev_lower = c.is_lowercase() || c.is_ascii_digit();
}
out
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct ElementData {
pub role: String,
pub platform_role: String,
pub title: Option<String>,
pub value: Option<String>,
pub description: Option<String>,
pub help: Option<String>,
pub placeholder: Option<String>,
pub identifier: Option<String>,
pub url: Option<String>,
pub frame: Option<Rect>,
pub states: ElementStates,
pub actions: Vec<Action>,
}
impl ElementData {
#[must_use]
pub fn new(role: impl Into<String>, platform_role: impl Into<String>) -> Self {
Self { role: role.into(), platform_role: platform_role.into(), ..Self::default() }
}
fn is_noise(&self) -> bool {
const CONTAINER_ROLES: [&str; 8] =
["group", "unknown", "generic", "filler", "panel", "pane", "section", "layout table"];
CONTAINER_ROLES.contains(&self.role.as_str())
&& self.title.is_none()
&& self.value.is_none()
&& self.description.is_none()
&& self.identifier.is_none()
&& self.url.is_none()
&& !self.actions.iter().any(|a| !a.primary)
&& !self.states.intersects(ElementStates::FOCUSED | ElementStates::SELECTED)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Element {
pub index: usize,
pub parent: Option<usize>,
pub children: Vec<usize>,
pub depth: usize,
pub role: String,
pub platform_role: String,
pub title: Option<String>,
pub value: Option<String>,
pub description: Option<String>,
pub help: Option<String>,
pub placeholder: Option<String>,
pub identifier: Option<String>,
pub url: Option<String>,
pub frame: Option<Rect>,
pub states: ElementStates,
pub actions: Vec<Action>,
}
impl Element {
#[must_use]
pub fn primary_action(&self) -> Option<&Action> {
self.actions.iter().find(|a| a.primary)
}
#[must_use]
pub fn action(&self, query: &str) -> Option<&Action> {
self.actions.iter().find(|a| a.matches(query))
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.title.as_deref().or(self.description.as_deref()).or(self.value.as_deref())
}
pub(crate) fn text_fields(&self) -> impl Iterator<Item = &str> {
[
self.title.as_deref(),
self.description.as_deref(),
self.value.as_deref(),
self.placeholder.as_deref(),
self.identifier.as_deref(),
self.help.as_deref(),
]
.into_iter()
.flatten()
}
}
#[derive(Debug, Clone)]
pub struct RawNode<H> {
pub handle: H,
pub parent: Option<usize>,
pub data: ElementData,
}
pub(crate) fn build_tree<H>(
nodes: Vec<RawNode<H>>,
text_limit: Option<usize>,
) -> (Vec<Element>, Vec<H>, Vec<Option<usize>>) {
let mut elements: Vec<Element> = Vec::with_capacity(nodes.len());
let mut handles = Vec::with_capacity(nodes.len());
let mut kept_ancestor: Vec<Option<usize>> = Vec::with_capacity(nodes.len());
let mut raw_to_element: Vec<Option<usize>> = Vec::with_capacity(nodes.len());
for (raw_index, node) in nodes.into_iter().enumerate() {
let parent = node.parent.filter(|&p| p < raw_index).and_then(|p| kept_ancestor[p]);
let is_root = node.parent.is_none();
if !is_root && node.data.is_noise() {
kept_ancestor.push(parent);
raw_to_element.push(None);
continue;
}
let index = elements.len();
let depth = parent.map_or(0, |p| elements[p].depth + 1);
if let Some(p) = parent {
elements[p].children.push(index);
}
let d = node.data;
let clip = |s: Option<String>| s.map(|s| sanitize(&s, text_limit)).filter(|s| !s.is_empty());
elements.push(Element {
index,
parent,
children: Vec::new(),
depth,
role: d.role,
platform_role: d.platform_role,
title: clip(d.title),
value: clip(d.value),
description: clip(d.description),
help: clip(d.help),
placeholder: clip(d.placeholder),
identifier: clip(d.identifier),
url: clip(d.url),
frame: d.frame,
states: d.states,
actions: d.actions,
});
handles.push(node.handle);
kept_ancestor.push(Some(index));
raw_to_element.push(Some(index));
}
(elements, handles, raw_to_element)
}
pub(crate) fn sanitize(text: &str, limit: Option<usize>) -> String {
let mut out = String::with_capacity(text.len().min(limit.unwrap_or(usize::MAX)));
let mut count = 0usize;
let mut pending_space = false;
for c in text.chars() {
if c.is_whitespace() || c.is_control() {
pending_space = !out.is_empty();
continue;
}
let needed = usize::from(pending_space) + 1;
if limit.is_some_and(|l| count + needed > l) {
out.push('…');
return out;
}
if pending_space {
out.push(' ');
count += 1;
pending_space = false;
}
out.push(c);
count += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn node(parent: Option<usize>, role: &str, title: Option<&str>) -> RawNode<u32> {
let mut data = ElementData::new(role, role);
data.title = title.map(str::to_owned);
RawNode { handle: 0, parent, data }
}
#[test]
fn humanizes_actions() {
assert_eq!(humanize_action_name("AXShowMenu"), "Show Menu");
assert_eq!(humanize_action_name("show_menu"), "Show Menu");
assert_eq!(humanize_action_name("AXScrollDownByPage"), "Scroll Down By Page");
assert!(Action::new("AXShowMenu", false).matches("show menu"));
assert!(Action::new("AXShowMenu", false).matches("ShowMenu"));
assert!(!Action::new("AXPress", true).matches("show menu"));
}
#[test]
fn prunes_anonymous_groups_and_reparents() {
let nodes = vec![
node(None, "window", Some("Main")),
node(Some(0), "group", None),
node(Some(1), "button", Some("OK")),
node(Some(0), "group", Some("Named")),
node(Some(3), "text", Some("hi")),
];
let (elements, _, map) = build_tree(nodes, None);
let roles: Vec<_> = elements.iter().map(|e| (e.index, e.parent, e.depth, e.role.as_str())).collect();
assert_eq!(
roles,
vec![(0, None, 0, "window"), (1, Some(0), 1, "button"), (2, Some(0), 1, "group"), (3, Some(2), 2, "text"),]
);
assert_eq!(map, vec![Some(0), None, Some(1), Some(2), Some(3)]);
assert_eq!(elements[0].children, vec![1, 2]);
}
#[test]
fn sanitize_collapses_and_truncates() {
assert_eq!(sanitize(" a \n\t b ", None), "a b");
assert_eq!(sanitize("hello world", Some(5)), "hello…");
assert_eq!(sanitize("héllo", Some(10)), "héllo");
}
}