Skip to main content

droidrun_core/ui/
formatter.rs

1/// Tree formatting — converts filtered a11y tree to indexed elements + text.
2use serde_json::Value;
3
4use crate::ui::coord::bounds_to_normalized;
5use crate::ui::state::{Element, PhoneState};
6
7/// Trait for formatting filtered trees.
8pub trait TreeFormatter: Send + Sync {
9    /// Format filtered tree to standard output format.
10    ///
11    /// Returns (formatted_text, focused_text, elements, phone_state).
12    fn format(
13        &self,
14        filtered_tree: Option<&Value>,
15        phone_state: &Value,
16        screen_width: i32,
17        screen_height: i32,
18        use_normalized: bool,
19    ) -> (String, String, Vec<Element>, PhoneState);
20}
21
22/// Standard DroidRun indexed formatter.
23pub struct IndexedFormatter;
24
25impl TreeFormatter for IndexedFormatter {
26    fn format(
27        &self,
28        filtered_tree: Option<&Value>,
29        phone_state: &Value,
30        screen_width: i32,
31        screen_height: i32,
32        use_normalized: bool,
33    ) -> (String, String, Vec<Element>, PhoneState) {
34        let focused_text = get_focused_text(phone_state);
35        let parsed_phone_state = parse_phone_state(phone_state);
36
37        let elements = match filtered_tree {
38            Some(tree) => {
39                let mut counter = 1usize;
40                flatten_with_index(tree, &mut counter, screen_width, screen_height, use_normalized)
41            }
42            None => vec![],
43        };
44
45        let phone_state_text = format_phone_state(&parsed_phone_state);
46        let ui_elements_text =
47            format_ui_elements_text(&elements, use_normalized);
48
49        let formatted = format!("{phone_state_text}\n\n{ui_elements_text}");
50        (formatted, focused_text, elements, parsed_phone_state)
51    }
52}
53
54// ── Internal functions ──────────────────────────────────────────
55
56fn get_focused_text(phone_state: &Value) -> String {
57    phone_state
58        .get("focusedElement")
59        .and_then(|fe| fe.get("text"))
60        .and_then(|t| t.as_str())
61        .unwrap_or("")
62        .to_string()
63}
64
65fn parse_phone_state(raw: &Value) -> PhoneState {
66    serde_json::from_value(raw.clone()).unwrap_or_default()
67}
68
69fn format_phone_state(ps: &PhoneState) -> String {
70    let focused_desc = ps
71        .focused_element
72        .as_ref()
73        .and_then(|fe| fe.get("text"))
74        .and_then(|t| t.as_str())
75        .map(|t| format!("'{t}'"))
76        .unwrap_or_else(|| "''".into());
77
78    let keyboard = if ps.is_editable {
79        "Visible"
80    } else {
81        "Hidden"
82    };
83
84    format!(
85        "**Current Phone State:**\n\
86         • **App:** {} ({})\n\
87         • **Keyboard:** {}\n\
88         • **Focused Element:** {}",
89        ps.current_app, ps.package_name, keyboard, focused_desc
90    )
91}
92
93fn format_ui_elements_text(elements: &[Element], use_normalized: bool) -> String {
94    let coord_note = if use_normalized {
95        " (normalized [0-1000])"
96    } else {
97        ""
98    };
99    let schema = "'index. className: resourceId; checkedState, text - bounds(x1,y1,x2,y2)'";
100
101    if elements.is_empty() {
102        return format!(
103            "Current Clickable UI elements{coord_note}:\n{schema}:\nNo UI elements found"
104        );
105    }
106
107    let formatted = format_elements(elements, 0);
108    format!("Current Clickable UI elements{coord_note}:\n{schema}:\n{formatted}")
109}
110
111fn format_elements(elements: &[Element], level: usize) -> String {
112    let indent = "  ".repeat(level);
113    let mut lines = Vec::new();
114
115    for el in elements {
116        let mut parts = Vec::new();
117
118        parts.push(format!("{}.", el.index));
119
120        if !el.class_name.is_empty() {
121            parts.push(format!("{}:", el.class_name));
122        }
123
124        let mut details = Vec::new();
125        if !el.resource_id.is_empty() {
126            details.push(format!("\"{}\"", el.resource_id));
127        }
128        if !el.text.is_empty() {
129            details.push(format!("\"{}\"", el.text));
130        }
131        if !details.is_empty() {
132            parts.push(details.join(", "));
133        }
134
135        if !el.checked_state.is_empty() {
136            parts.push(format!("; {}", el.checked_state));
137        }
138
139        if !el.bounds.is_empty() {
140            parts.push(format!("- ({})", el.bounds));
141        }
142
143        lines.push(format!("{indent}{}", parts.join(" ")));
144
145        if !el.children.is_empty() {
146            lines.push(format_elements(&el.children, level + 1));
147        }
148    }
149
150    lines.join("\n")
151}
152
153fn flatten_with_index(
154    node: &Value,
155    counter: &mut usize,
156    screen_width: i32,
157    screen_height: i32,
158    use_normalized: bool,
159) -> Vec<Element> {
160    let mut results = Vec::new();
161
162    let element = format_node(node, *counter, screen_width, screen_height, use_normalized);
163    *counter += 1;
164    results.push(element);
165
166    if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
167        for child in children {
168            results.extend(flatten_with_index(
169                child,
170                counter,
171                screen_width,
172                screen_height,
173                use_normalized,
174            ));
175        }
176    }
177
178    results
179}
180
181fn format_node(
182    node: &Value,
183    index: usize,
184    screen_width: i32,
185    screen_height: i32,
186    use_normalized: bool,
187) -> Element {
188    let bounds = node.get("boundsInScreen").cloned().unwrap_or_default();
189    let left = bounds.get("left").and_then(|v| v.as_i64()).unwrap_or(0);
190    let top = bounds.get("top").and_then(|v| v.as_i64()).unwrap_or(0);
191    let right = bounds.get("right").and_then(|v| v.as_i64()).unwrap_or(0);
192    let bottom = bounds.get("bottom").and_then(|v| v.as_i64()).unwrap_or(0);
193
194    let bounds_str = format!("{left},{top},{right},{bottom}");
195    let bounds_str = if use_normalized && screen_width > 0 && screen_height > 0 {
196        bounds_to_normalized(&bounds_str, screen_width, screen_height).unwrap_or(bounds_str)
197    } else {
198        bounds_str
199    };
200
201    let text = node
202        .get("text")
203        .or_else(|| node.get("contentDescription"))
204        .or_else(|| node.get("resourceId"))
205        .or_else(|| node.get("className"))
206        .and_then(|v| v.as_str())
207        .unwrap_or("")
208        .to_string();
209
210    let class_name = node
211        .get("className")
212        .and_then(|v| v.as_str())
213        .unwrap_or("");
214    let short_class = class_name.rsplit('.').next().unwrap_or(class_name);
215
216    let checked_state = if node
217        .get("isCheckable")
218        .and_then(|v| v.as_bool())
219        .unwrap_or(false)
220    {
221        if node
222            .get("isChecked")
223            .and_then(|v| v.as_bool())
224            .unwrap_or(false)
225        {
226            "isChecked=True".to_string()
227        } else {
228            "isChecked=False".to_string()
229        }
230    } else {
231        String::new()
232    };
233
234    Element {
235        index,
236        resource_id: node
237            .get("resourceId")
238            .and_then(|v| v.as_str())
239            .unwrap_or("")
240            .to_string(),
241        class_name: short_class.to_string(),
242        checked_state,
243        text,
244        bounds: bounds_str,
245        children: vec![],
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde_json::json;
253
254    #[test]
255    fn test_format_phone_state() {
256        let ps = PhoneState {
257            current_app: "Settings".into(),
258            package_name: "com.android.settings".into(),
259            is_editable: false,
260            focused_element: None,
261        };
262        let text = format_phone_state(&ps);
263        assert!(text.contains("Settings"));
264        assert!(text.contains("Hidden"));
265    }
266
267    #[test]
268    fn test_format_phone_state_with_keyboard() {
269        let ps = PhoneState {
270            current_app: "Chrome".into(),
271            package_name: "com.android.chrome".into(),
272            is_editable: true,
273            focused_element: Some(json!({"text": "Search"})),
274        };
275        let text = format_phone_state(&ps);
276        assert!(text.contains("Visible"));
277        assert!(text.contains("'Search'"));
278    }
279
280    #[test]
281    fn test_flatten_with_index() {
282        let tree = json!({
283            "className": "android.widget.FrameLayout",
284            "boundsInScreen": {"left": 0, "top": 0, "right": 1080, "bottom": 2400},
285            "children": [
286                {
287                    "className": "android.widget.Button",
288                    "text": "OK",
289                    "boundsInScreen": {"left": 100, "top": 200, "right": 300, "bottom": 400},
290                    "children": []
291                }
292            ]
293        });
294
295        let mut counter = 1;
296        let elements = flatten_with_index(&tree, &mut counter, 1080, 2400, false);
297        assert_eq!(elements.len(), 2);
298        assert_eq!(elements[0].index, 1);
299        assert_eq!(elements[0].class_name, "FrameLayout");
300        assert_eq!(elements[1].index, 2);
301        assert_eq!(elements[1].text, "OK");
302    }
303
304    #[test]
305    fn test_format_node_normalized() {
306        let node = json!({
307            "className": "android.widget.Button",
308            "text": "Submit",
309            "boundsInScreen": {"left": 0, "top": 0, "right": 1080, "bottom": 2400}
310        });
311        let el = format_node(&node, 1, 1080, 2400, true);
312        assert_eq!(el.bounds, "0,0,1000,1000");
313    }
314}