Skip to main content

droidrun_core/ui/
search.rs

1/// Composable element search filters for accessibility trees.
2///
3/// Works with raw a11y tree data (serde_json::Value) from Portal.
4use regex::Regex;
5use serde_json::Value;
6
7/// A filter function that takes a list of tree nodes and returns matching ones.
8pub type ElementFilter = Box<dyn Fn(&[Value]) -> Vec<Value> + Send + Sync>;
9
10/// Flatten a tree node into a list of all descendant nodes.
11pub fn flatten_tree(root: &Value) -> Vec<Value> {
12    let mut results = vec![root.clone()];
13    if let Some(children) = root.get("children").and_then(|c| c.as_array()) {
14        for child in children {
15            results.extend(flatten_tree(child));
16        }
17    }
18    results
19}
20
21/// Get center coordinates from boundsInScreen.
22pub fn get_element_center(node: &Value) -> (i32, i32) {
23    let bounds = node.get("boundsInScreen").cloned().unwrap_or_default();
24    let left = bounds.get("left").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
25    let top = bounds.get("top").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
26    let right = bounds.get("right").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
27    let bottom = bounds.get("bottom").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
28    ((left + right) / 2, (top + bottom) / 2)
29}
30
31// ── Filter constructors ─────────────────────────────────────────
32
33/// Match elements by text content (text, contentDescription, or hint).
34pub fn text_matches(pattern: &str) -> ElementFilter {
35    let regex = Regex::new(&regex::escape(pattern)).unwrap();
36    let pattern_owned = pattern.to_string();
37
38    Box::new(move |nodes: &[Value]| {
39        let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
40
41        all.into_iter()
42            .filter(|node| {
43                for field in &["text", "contentDescription", "hint"] {
44                    if let Some(val) = node.get(field).and_then(|v| v.as_str()) {
45                        if val == pattern_owned || regex.is_match(val) {
46                            return true;
47                        }
48                        let normalized = val.replace('\n', " ");
49                        if normalized == pattern_owned || regex.is_match(&normalized) {
50                            return true;
51                        }
52                    }
53                }
54                false
55            })
56            .collect()
57    })
58}
59
60/// Match elements by resource ID.
61pub fn id_matches(pattern: &str) -> ElementFilter {
62    let regex = Regex::new(&regex::escape(pattern)).unwrap();
63    let pattern_owned = pattern.to_string();
64
65    Box::new(move |nodes: &[Value]| {
66        let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
67
68        all.into_iter()
69            .filter(|node| {
70                if let Some(id) = node.get("resourceId").and_then(|v| v.as_str()) {
71                    if id == pattern_owned || regex.is_match(id) {
72                        return true;
73                    }
74                    // Short ID (after /)
75                    if let Some(short) = id.rsplit('/').next() {
76                        if short == pattern_owned || regex.is_match(short) {
77                            return true;
78                        }
79                    }
80                }
81                false
82            })
83            .collect()
84    })
85}
86
87/// Match clickable elements.
88pub fn clickable() -> ElementFilter {
89    Box::new(|nodes: &[Value]| {
90        let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
91        all.into_iter()
92            .filter(|n| n.get("isClickable").and_then(|v| v.as_bool()).unwrap_or(false))
93            .collect()
94    })
95}
96
97/// Match elements that have non-empty text.
98pub fn has_text() -> ElementFilter {
99    Box::new(|nodes: &[Value]| {
100        let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
101        all.into_iter()
102            .filter(|n| {
103                n.get("text")
104                    .and_then(|v| v.as_str())
105                    .map(|s| !s.is_empty())
106                    .unwrap_or(false)
107                    || n.get("contentDescription")
108                        .and_then(|v| v.as_str())
109                        .map(|s| !s.is_empty())
110                        .unwrap_or(false)
111            })
112            .collect()
113    })
114}
115
116/// Find elements positioned below an anchor.
117pub fn below(anchor_filter: ElementFilter) -> ElementFilter {
118    Box::new(move |nodes: &[Value]| {
119        let anchor_results = anchor_filter(nodes);
120        let Some(anchor) = anchor_results.first() else {
121            return vec![];
122        };
123
124        let (ax, ay) = get_element_center(anchor);
125        let anchor_bottom = anchor
126            .get("boundsInScreen")
127            .and_then(|b| b.get("bottom"))
128            .and_then(|v| v.as_i64())
129            .unwrap_or(0) as i32;
130
131        let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
132        let mut candidates: Vec<(f64, Value)> = all
133            .into_iter()
134            .filter(|n| n != anchor)
135            .filter_map(|n| {
136                let top = n
137                    .get("boundsInScreen")
138                    .and_then(|b| b.get("top"))
139                    .and_then(|v| v.as_i64())
140                    .unwrap_or(0) as i32;
141                if top > anchor_bottom {
142                    let (nx, ny) = get_element_center(&n);
143                    let dist = (((nx - ax).pow(2) + (ny - ay).pow(2)) as f64).sqrt();
144                    Some((dist, n))
145                } else {
146                    None
147                }
148            })
149            .collect();
150
151        candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
152        candidates.into_iter().map(|(_, n)| n).collect()
153    })
154}
155
156/// Compose filters sequentially (pipeline).
157pub fn compose(filters: Vec<ElementFilter>) -> ElementFilter {
158    Box::new(move |nodes: &[Value]| {
159        let mut result: Vec<Value> = nodes.to_vec();
160        for f in &filters {
161            result = f(&result);
162            if result.is_empty() {
163                break;
164            }
165        }
166        result
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use serde_json::json;
174
175    fn sample_tree() -> Vec<Value> {
176        vec![json!({
177            "text": "Hello World",
178            "className": "android.widget.TextView",
179            "resourceId": "com.example:id/title",
180            "isClickable": false,
181            "boundsInScreen": {"left": 0, "top": 0, "right": 500, "bottom": 100},
182            "children": [
183                {
184                    "text": "OK",
185                    "className": "android.widget.Button",
186                    "resourceId": "com.example:id/btn_ok",
187                    "isClickable": true,
188                    "boundsInScreen": {"left": 100, "top": 200, "right": 300, "bottom": 300},
189                    "children": []
190                },
191                {
192                    "text": "Cancel",
193                    "className": "android.widget.Button",
194                    "resourceId": "com.example:id/btn_cancel",
195                    "isClickable": true,
196                    "boundsInScreen": {"left": 400, "top": 200, "right": 600, "bottom": 300},
197                    "children": []
198                }
199            ]
200        })]
201    }
202
203    #[test]
204    fn test_text_matches() {
205        let filter = text_matches("OK");
206        let results = filter(&sample_tree());
207        assert_eq!(results.len(), 1);
208        assert_eq!(results[0].get("text").unwrap().as_str().unwrap(), "OK");
209    }
210
211    #[test]
212    fn test_id_matches_short() {
213        let filter = id_matches("btn_ok");
214        let results = filter(&sample_tree());
215        assert_eq!(results.len(), 1);
216    }
217
218    #[test]
219    fn test_clickable() {
220        let results = clickable()(&sample_tree());
221        assert_eq!(results.len(), 2);
222    }
223
224    #[test]
225    fn test_has_text() {
226        let results = has_text()(&sample_tree());
227        assert_eq!(results.len(), 3); // Hello World, OK, Cancel
228    }
229
230    #[test]
231    fn test_flatten_tree() {
232        let flat = flatten_tree(&sample_tree()[0]);
233        assert_eq!(flat.len(), 3);
234    }
235}