agent_tui/core/
element.rs

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/// Types of interactive UI elements detected by the Visual Object Model (VOM).
14///
15/// Some variants are not yet detectable by VOM but are reserved for:
16/// - Legacy ref support (e.g., `@rb1` for Radio, `@sel1` for Select)
17/// - Future VOM detection capabilities
18/// - External element type mapping
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum ElementType {
21    Button,
22    Input,
23    Checkbox,
24    /// Reserved for future VOM radio button detection. Currently mapped via legacy refs.
25    Radio,
26    /// Reserved for future VOM select/dropdown detection. Currently mapped via legacy refs.
27    Select,
28    MenuItem,
29    ListItem,
30    /// Reserved for future VOM spinner/loading indicator detection.
31    Spinner,
32    /// Reserved for future VOM progress bar detection.
33    Progress,
34    /// Clickable link (URL or file path).
35    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}