#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![doc(html_root_url = "https://docs.smix.dev/smix-selector")]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use smix_screen::{A11yNode, Role};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Pattern {
Text(String),
Regex {
regex: String,
#[serde(default = "default_regex_flags")]
flags: String,
},
}
fn default_regex_flags() -> String {
"i".to_string()
}
impl Pattern {
pub fn text<S: Into<String>>(s: S) -> Self {
Pattern::Text(s.into())
}
pub fn regex<P: Into<String>>(pattern: P) -> Self {
Pattern::Regex {
regex: pattern.into(),
flags: "i".to_string(),
}
}
pub fn regex_with_flags<P: Into<String>, F: Into<String>>(pattern: P, flags: F) -> Self {
Pattern::Regex {
regex: pattern.into(),
flags: flags.into(),
}
}
pub fn compile(&self) -> Result<CompiledPattern, regex::Error> {
match self {
Pattern::Text(s) => Ok(CompiledPattern::Text(s.clone())),
Pattern::Regex { regex, flags: _ } => {
let final_pattern = if regex.starts_with("(?i)") {
regex.clone()
} else {
format!("(?i){}", regex)
};
regex::Regex::new(&final_pattern).map(CompiledPattern::Regex)
}
}
}
}
#[derive(Clone, Debug)]
pub enum CompiledPattern {
Text(String),
Regex(regex::Regex),
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Modifiers {
#[serde(skip_serializing_if = "Option::is_none")]
pub near: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub below: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub above: Option<Box<Selector>>,
#[serde(rename = "leftOf", skip_serializing_if = "Option::is_none")]
pub left_of: Option<Box<Selector>>,
#[serde(rename = "rightOf", skip_serializing_if = "Option::is_none")]
pub right_of: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inside: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ancestor: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AnchorBox {
#[serde(skip_serializing_if = "Option::is_none")]
pub near: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub below: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub above: Option<Box<Selector>>,
#[serde(rename = "leftOf", skip_serializing_if = "Option::is_none")]
pub left_of: Option<Box<Selector>>,
#[serde(rename = "rightOf", skip_serializing_if = "Option::is_none")]
pub right_of: Option<Box<Selector>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inside: Option<Box<Selector>>,
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct IndexModifiers {
#[serde(skip_serializing_if = "Option::is_none")]
pub nth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Selector {
Text {
text: Pattern,
#[serde(flatten)]
modifiers: Modifiers,
},
Id {
id: String,
#[serde(flatten)]
modifiers: Modifiers,
},
Label {
label: String,
#[serde(flatten)]
modifiers: Modifiers,
},
Role {
role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<Pattern>,
#[serde(flatten)]
modifiers: Modifiers,
},
Focused {
focused: True,
},
Anchor {
anchor: AnchorBox,
#[serde(flatten)]
index: IndexModifiers,
},
LocalizedText {
localized_text: BTreeMap<String, String>,
#[serde(flatten)]
modifiers: Modifiers,
},
OcrText {
ocr_text: String,
#[serde(default)]
locales: Vec<String>,
#[serde(flatten)]
modifiers: Modifiers,
},
AnchorRelative {
anchor: Box<Selector>,
dx: f64,
dy: f64,
},
Point {
nx: f64,
ny: f64,
},
Fallback {
fallback: Vec<Selector>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct True(pub bool);
impl Default for True {
fn default() -> Self {
True(true)
}
}
#[must_use]
pub fn match_text_compiled(node: &A11yNode, pattern: &CompiledPattern) -> bool {
let candidates: [Option<&str>; 6] = [
node.label.as_deref(),
node.title.as_deref(),
node.value.as_deref(),
node.placeholder_value.as_deref(),
node.identifier.as_deref(),
node.text.as_deref(),
];
match pattern {
CompiledPattern::Text(p) => {
if p.is_empty() {
return false;
}
for c in candidates.iter().flatten() {
if c.eq_ignore_ascii_case(p) {
return true;
}
}
false
}
CompiledPattern::Regex(re) => {
for c in candidates.iter().flatten() {
if re.is_match(c) {
return true;
}
}
false
}
}
}
#[must_use]
pub fn describe_selector(s: &Selector) -> String {
let mut parts: Vec<String> = Vec::new();
match s {
Selector::Anchor { anchor, index } => {
if let Some(v) = anchor.near.as_deref() {
parts.push(format!("anchor.near=({})", describe_selector(v)));
}
if let Some(v) = anchor.below.as_deref() {
parts.push(format!("anchor.below=({})", describe_selector(v)));
}
if let Some(v) = anchor.above.as_deref() {
parts.push(format!("anchor.above=({})", describe_selector(v)));
}
if let Some(v) = anchor.left_of.as_deref() {
parts.push(format!("anchor.leftOf=({})", describe_selector(v)));
}
if let Some(v) = anchor.right_of.as_deref() {
parts.push(format!("anchor.rightOf=({})", describe_selector(v)));
}
if let Some(v) = anchor.inside.as_deref() {
parts.push(format!("anchor.inside=({})", describe_selector(v)));
}
if parts.is_empty() {
parts.push("anchor.empty".into());
}
if let Some(nth) = index.nth {
parts.push(format!("nth={}", nth));
}
if index.first == Some(true) {
parts.push("first".into());
}
if index.last == Some(true) {
parts.push("last".into());
}
return format!("{{ {} }}", parts.join(", "));
}
Selector::Focused { .. } => return "{ focused }".into(),
Selector::Text { text, modifiers } => {
parts.push(format!("text={}", format_pattern(text)));
append_modifiers(modifiers, &mut parts);
}
Selector::Id { id, modifiers } => {
parts.push(format!("id=\"{}\"", id));
append_modifiers(modifiers, &mut parts);
}
Selector::Label { label, modifiers } => {
parts.push(format!("label=\"{}\"", label));
append_modifiers(modifiers, &mut parts);
}
Selector::Role {
role,
name,
modifiers,
} => {
match name {
Some(n) => parts.push(format!(
"role={}, name={}",
role.as_str(),
format_pattern(n)
)),
None => parts.push(format!("role={}", role.as_str())),
}
append_modifiers(modifiers, &mut parts);
}
Selector::LocalizedText {
localized_text,
modifiers,
} => {
let entries: Vec<String> = localized_text
.iter()
.map(|(k, v)| format!("{}=\"{}\"", k, v))
.collect();
parts.push(format!("localized_text={{{}}}", entries.join(", ")));
append_modifiers(modifiers, &mut parts);
}
Selector::OcrText {
ocr_text,
locales,
modifiers,
} => {
if locales.is_empty() {
parts.push(format!("ocr_text=\"{}\"", ocr_text));
} else {
parts.push(format!(
"ocr_text=\"{}\", locales=[{}]",
ocr_text,
locales.join(", ")
));
}
append_modifiers(modifiers, &mut parts);
}
Selector::AnchorRelative { anchor, dx, dy } => {
parts.push(format!(
"anchored.anchor=({}), dx={}, dy={}",
describe_selector(anchor),
dx,
dy
));
}
Selector::Point { nx, ny } => {
parts.push(format!("point=({}, {})", nx, ny));
}
Selector::Fallback { fallback } => {
let chain: Vec<String> = fallback.iter().map(describe_selector).collect();
parts.push(format!("fallback=[{}]", chain.join(", ")));
}
}
format!("{{ {} }}", parts.join(", "))
}
fn append_modifiers(m: &Modifiers, parts: &mut Vec<String>) {
if let Some(v) = m.near.as_deref() {
parts.push(format!("near=({})", describe_selector(v)));
}
if let Some(v) = m.below.as_deref() {
parts.push(format!("below=({})", describe_selector(v)));
}
if let Some(v) = m.above.as_deref() {
parts.push(format!("above=({})", describe_selector(v)));
}
if let Some(v) = m.left_of.as_deref() {
parts.push(format!("leftOf=({})", describe_selector(v)));
}
if let Some(v) = m.right_of.as_deref() {
parts.push(format!("rightOf=({})", describe_selector(v)));
}
if let Some(v) = m.inside.as_deref() {
parts.push(format!("inside=({})", describe_selector(v)));
}
if let Some(v) = m.ancestor.as_deref() {
parts.push(format!("ancestor=({})", describe_selector(v)));
}
if let Some(nth) = m.nth {
parts.push(format!("nth={}", nth));
}
if m.first == Some(true) {
parts.push("first".into());
}
if m.last == Some(true) {
parts.push("last".into());
}
}
fn format_pattern(p: &Pattern) -> String {
match p {
Pattern::Text(s) => format!("{:?}", s),
Pattern::Regex { regex, flags } => format!("/{}/{}", regex, flags),
}
}
#[must_use]
pub fn match_text(node: &A11yNode, pattern: &Pattern) -> bool {
let candidates: [Option<&str>; 6] = [
node.label.as_deref(),
node.title.as_deref(),
node.value.as_deref(),
node.placeholder_value.as_deref(),
node.identifier.as_deref(),
node.text.as_deref(),
];
match pattern {
Pattern::Text(p) => {
if p.is_empty() {
return false;
}
for c in candidates.iter().flatten() {
if c.eq_ignore_ascii_case(p) {
return true;
}
}
false
}
Pattern::Regex { regex, flags: _ } => {
let final_pattern = if regex.starts_with("(?i)") {
regex.clone()
} else {
format!("(?i){}", regex)
};
let Ok(re) = regex::Regex::new(&final_pattern) else {
return false;
};
for c in candidates.iter().flatten() {
if re.is_match(c) {
return true;
}
}
false
}
}
}