1use std::sync::OnceLock;
2
3use regex::Regex;
4
5use super::vom::Component;
6use super::vom::Role;
7
8fn legacy_ref_regex() -> &'static Regex {
9 static RE: OnceLock<Regex> = OnceLock::new();
10 RE.get_or_init(|| Regex::new(r"^@([a-z]+)(\d+)$").unwrap())
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum ElementType {
21 Button,
22 Input,
23 Checkbox,
24 Radio,
26 Select,
28 MenuItem,
29 ListItem,
30 Spinner,
32 Progress,
34 Link,
36}
37
38impl ElementType {
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 ElementType::Button => "button",
42 ElementType::Input => "input",
43 ElementType::Checkbox => "checkbox",
44 ElementType::Radio => "radio",
45 ElementType::Select => "select",
46 ElementType::MenuItem => "menuitem",
47 ElementType::ListItem => "listitem",
48 ElementType::Spinner => "spinner",
49 ElementType::Progress => "progress",
50 ElementType::Link => "link",
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
56pub struct Position {
57 pub row: u16,
58 pub col: u16,
59 pub width: Option<u16>,
60 pub height: Option<u16>,
61}
62
63#[derive(Debug, Clone)]
64pub struct Element {
65 pub element_ref: String,
66 pub element_type: ElementType,
67 pub label: Option<String>,
68 pub value: Option<String>,
69 pub position: Position,
70 pub focused: bool,
71 pub selected: bool,
72 pub checked: Option<bool>,
73 pub disabled: Option<bool>,
74 pub hint: Option<String>,
75}
76
77pub fn role_to_element_type(role: Role) -> ElementType {
78 match role {
79 Role::Button => ElementType::Button,
80 Role::Tab => ElementType::Button,
81 Role::Input => ElementType::Input,
82 Role::Checkbox => ElementType::Checkbox,
83 Role::MenuItem => ElementType::MenuItem,
84 Role::StaticText => ElementType::ListItem,
85 Role::Panel => ElementType::ListItem,
86 Role::Status => ElementType::Spinner,
87 Role::ToolBlock => ElementType::ListItem,
88 Role::PromptMarker => ElementType::Input,
89 Role::ProgressBar => ElementType::Progress,
90 Role::Link => ElementType::Link,
91 Role::ErrorMessage => ElementType::ListItem,
92 Role::DiffLine => ElementType::ListItem,
93 Role::CodeBlock => ElementType::ListItem,
94 }
95}
96
97pub fn detect_checkbox_state(text: &str) -> Option<bool> {
98 let text = text.to_lowercase();
99
100 if text.contains("[x]") || text.contains("(x)") || text.contains("☑") || text.contains("✓")
101 {
102 Some(true)
103 } else if text.contains("[ ]") || text.contains("( )") || text.contains("☐") {
104 Some(false)
105 } else {
106 None
107 }
108}
109
110pub fn component_to_element(
111 comp: &Component,
112 index: usize,
113 cursor_row: u16,
114 cursor_col: u16,
115) -> Element {
116 let focused = comp.bounds.contains(cursor_col, cursor_row);
117
118 let checked = if comp.role == Role::Checkbox {
119 detect_checkbox_state(&comp.text_content)
120 } else {
121 None
122 };
123
124 Element {
125 element_ref: format!("@e{}", index + 1),
126 element_type: role_to_element_type(comp.role),
127 label: Some(comp.text_content.trim().to_string()),
128 value: None,
129 position: Position {
130 row: comp.bounds.y,
131 col: comp.bounds.x,
132 width: Some(comp.bounds.width),
133 height: Some(comp.bounds.height),
134 },
135 focused,
136 selected: false,
137 checked,
138 disabled: None,
139 hint: None,
140 }
141}
142
143pub fn find_element_by_ref<'a>(elements: &'a [Element], ref_str: &str) -> Option<&'a Element> {
144 let normalized = if ref_str.starts_with('@') {
145 ref_str.to_string()
146 } else {
147 format!("@{}", ref_str)
148 };
149
150 if let Some(el) = elements.iter().find(|e| e.element_ref == normalized) {
151 return Some(el);
152 }
153
154 if let Some(caps) = legacy_ref_regex().captures(&normalized) {
155 let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
156 let index: usize = caps
157 .get(2)
158 .and_then(|m| m.as_str().parse().ok())
159 .unwrap_or(0);
160
161 if index > 0 && prefix != "e" {
162 let target_type = match prefix {
163 "btn" => Some("button"),
164 "inp" => Some("input"),
165 "cb" => Some("checkbox"),
166 "rb" => Some("radio"),
167 "sel" => Some("select"),
168 "mi" => Some("menuitem"),
169 "li" => Some("listitem"),
170 "lnk" => Some("link"),
171 _ => None,
172 };
173
174 if let Some(type_str) = target_type {
175 let matching: Vec<_> = elements
176 .iter()
177 .filter(|e| e.element_type.as_str() == type_str)
178 .collect();
179
180 if index <= matching.len() {
181 return Some(matching[index - 1]);
182 }
183 }
184 }
185 }
186
187 None
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::core::vom::Rect;
194 use uuid::Uuid;
195
196 fn make_component(role: Role, text: &str, x: u16, y: u16, width: u16) -> Component {
197 Component {
198 id: Uuid::new_v4(),
199 role,
200 bounds: Rect::new(x, y, width, 1),
201 text_content: text.to_string(),
202 visual_hash: 0,
203 selected: false,
204 }
205 }
206
207 fn make_element(ref_str: &str, element_type: ElementType) -> Element {
208 Element {
209 element_ref: ref_str.to_string(),
210 element_type,
211 label: Some("test".to_string()),
212 value: None,
213 position: Position {
214 row: 0,
215 col: 0,
216 width: Some(10),
217 height: Some(1),
218 },
219 focused: false,
220 selected: false,
221 checked: None,
222 disabled: None,
223 hint: None,
224 }
225 }
226
227 #[test]
228 fn test_find_element_by_ref_sequential() {
229 let elements = vec![
230 make_element("@e1", ElementType::Button),
231 make_element("@e2", ElementType::Input),
232 make_element("@e3", ElementType::Checkbox),
233 ];
234
235 assert_eq!(
236 find_element_by_ref(&elements, "@e1").map(|e| &e.element_ref),
237 Some(&"@e1".to_string())
238 );
239 assert_eq!(
240 find_element_by_ref(&elements, "@e2").map(|e| &e.element_ref),
241 Some(&"@e2".to_string())
242 );
243 assert_eq!(
244 find_element_by_ref(&elements, "e3").map(|e| &e.element_ref),
245 Some(&"@e3".to_string())
246 );
247 assert!(find_element_by_ref(&elements, "@e4").is_none());
248 }
249
250 #[test]
251 fn test_find_element_by_ref_legacy_prefix() {
252 let elements = vec![
253 make_element("@e1", ElementType::Button),
254 make_element("@e2", ElementType::Button),
255 make_element("@e3", ElementType::Input),
256 make_element("@e4", ElementType::Checkbox),
257 ];
258
259 assert_eq!(
260 find_element_by_ref(&elements, "@btn1").map(|e| &e.element_ref),
261 Some(&"@e1".to_string())
262 );
263
264 assert_eq!(
265 find_element_by_ref(&elements, "@btn2").map(|e| &e.element_ref),
266 Some(&"@e2".to_string())
267 );
268
269 assert_eq!(
270 find_element_by_ref(&elements, "@inp1").map(|e| &e.element_ref),
271 Some(&"@e3".to_string())
272 );
273
274 assert_eq!(
275 find_element_by_ref(&elements, "@cb1").map(|e| &e.element_ref),
276 Some(&"@e4".to_string())
277 );
278
279 assert!(find_element_by_ref(&elements, "@btn3").is_none());
280 }
281
282 #[test]
283 fn test_component_to_element_basic() {
284 let comp = make_component(Role::Button, "Click me", 5, 10, 8);
285 let element = component_to_element(&comp, 0, 0, 0);
286
287 assert_eq!(element.element_ref, "@e1");
288 assert_eq!(element.element_type, ElementType::Button);
289 assert_eq!(element.label, Some("Click me".to_string()));
290 assert_eq!(element.position.row, 10);
291 assert_eq!(element.position.col, 5);
292 assert_eq!(element.position.width, Some(8));
293 assert!(!element.focused);
294 }
295
296 #[test]
297 fn test_component_to_element_checkbox_checked() {
298 let comp = make_component(Role::Checkbox, "[x] Enabled", 0, 0, 11);
299 let element = component_to_element(&comp, 0, 0, 0);
300
301 assert_eq!(element.element_type, ElementType::Checkbox);
302 assert_eq!(element.checked, Some(true));
303 }
304
305 #[test]
306 fn test_component_to_element_checkbox_unchecked() {
307 let comp = make_component(Role::Checkbox, "[ ] Disabled", 0, 0, 12);
308 let element = component_to_element(&comp, 0, 0, 0);
309
310 assert_eq!(element.element_type, ElementType::Checkbox);
311 assert_eq!(element.checked, Some(false));
312 }
313
314 #[test]
315 fn test_role_to_element_type_mapping() {
316 assert_eq!(role_to_element_type(Role::Button), ElementType::Button);
317 assert_eq!(role_to_element_type(Role::Tab), ElementType::Button);
318 assert_eq!(role_to_element_type(Role::Input), ElementType::Input);
319 assert_eq!(role_to_element_type(Role::Checkbox), ElementType::Checkbox);
320 assert_eq!(role_to_element_type(Role::MenuItem), ElementType::MenuItem);
321 assert_eq!(
322 role_to_element_type(Role::StaticText),
323 ElementType::ListItem
324 );
325 assert_eq!(role_to_element_type(Role::Panel), ElementType::ListItem);
326 }
327}