1use serde_json::Value;
3
4use crate::ui::coord::bounds_to_normalized;
5use crate::ui::state::{Element, PhoneState};
6
7pub trait TreeFormatter: Send + Sync {
9 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
22pub 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
54fn 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}