1use super::ElementType;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::OnceLock;
5
6struct PatternRegexes {
7 button: [Regex; 2],
8 input: [Regex; 2],
9 checkbox: [Regex; 2],
10 radio: [Regex; 1],
11 select: [Regex; 2],
12 menu_item: [Regex; 2],
13 list_item: [Regex; 2],
14 spinner: [Regex; 2],
15 progress: [Regex; 2],
16}
17
18fn get_patterns() -> &'static PatternRegexes {
19 static PATTERNS: OnceLock<PatternRegexes> = OnceLock::new();
20 PATTERNS.get_or_init(|| PatternRegexes {
21 button: [
22 Regex::new(r"\[\s*([^\[\]_]+?)\s*\]").unwrap(),
23 Regex::new(r"<\s*([^<>]+?)\s*>").unwrap(),
24 ],
25 input: [
26 Regex::new(r"([A-Za-z\s]+):\s*\[([^\]]*?)_*\]").unwrap(),
27 Regex::new(r"\[([^\]]*?)_{3,}\]").unwrap(),
28 ],
29 checkbox: [
30 Regex::new(r"\[([xX✓✔\s])\]\s*(.+?)(?:\s{2,}|$)").unwrap(),
31 Regex::new(r"([◉◯●○✓✔])\s+(.+?)(?:\s{2,}|$)").unwrap(),
32 ],
33 radio: [Regex::new(r"\(([•o*\s])\)\s*(.+?)(?:\s{2,}|$)").unwrap()],
34 select: [
35 Regex::new(r"([A-Za-z\s]+):\s*\[?([^\]▼▾\n]+?)\s*[▼▾]\]?").unwrap(),
36 Regex::new(r"([^\s]+)\s+[▼▾]").unwrap(),
37 ],
38 menu_item: [
39 Regex::new(r"^\s*([>❯›▸►●•])\s+(.+?)$").unwrap(),
40 Regex::new(r"^\s*(\d+)\.\s+(.+?)$").unwrap(),
41 ],
42 list_item: [
43 Regex::new(r"^\s*[-*]\s+(.+?)$").unwrap(),
44 Regex::new(r"^\s*•\s+(.+?)$").unwrap(),
45 ],
46 spinner: [
47 Regex::new(r"[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]").unwrap(),
48 Regex::new(r"[\|/\-\\]\s+(.+)").unwrap(),
49 ],
50 progress: [
51 Regex::new(r"\[([█▓▒░=\->#\s]+)\]").unwrap(),
52 Regex::new(r"(\d+)\s*%").unwrap(),
53 ],
54 })
55}
56
57#[derive(Debug, Clone)]
58pub struct PatternMatch {
59 pub element_type: ElementType,
60 pub label: Option<String>,
61 pub value: Option<String>,
62 pub row: u16,
63 pub col: u16,
64 pub width: u16,
65 pub checked: Option<bool>,
66}
67
68pub fn detect_by_pattern(screen_text: &str) -> Vec<PatternMatch> {
69 let mut matches = Vec::new();
70 let lines: Vec<&str> = screen_text.lines().collect();
71
72 let patterns = get_patterns();
73
74 for (row_idx, line) in lines.iter().enumerate() {
75 let row = row_idx as u16;
76
77 for pattern in &patterns.button {
78 for cap in pattern.captures_iter(line) {
79 let full_match = cap.get(0).unwrap();
80 let label = cap.get(1).map(|m| m.as_str().trim().to_string());
81
82 let text = full_match.as_str();
83 if text.contains('_')
84 || text.starts_with("[x]")
85 || text.starts_with("[ ]")
86 || text.starts_with("[X]")
87 || text.starts_with("[✓]")
88 || text.starts_with("[✔]")
89 {
90 continue;
91 }
92
93 if let Some(ref l) = label {
94 if l.len() < 2 {
95 continue;
96 }
97 }
98
99 matches.push(PatternMatch {
100 element_type: ElementType::Button,
101 label,
102 value: None,
103 row,
104 col: full_match.start() as u16,
105 width: full_match.as_str().len() as u16,
106 checked: None,
107 });
108 }
109 }
110
111 for pattern in &patterns.input {
112 for cap in pattern.captures_iter(line) {
113 let full_match = cap.get(0).unwrap();
114 let label = cap.get(1).map(|m| m.as_str().trim().to_string());
115 let value = cap
116 .get(2)
117 .map(|m| m.as_str().trim_end_matches('_').trim().to_string())
118 .filter(|v| !v.is_empty());
119
120 matches.push(PatternMatch {
121 element_type: ElementType::Input,
122 label,
123 value,
124 row,
125 col: full_match.start() as u16,
126 width: full_match.as_str().len() as u16,
127 checked: None,
128 });
129 }
130 }
131
132 for pattern in &patterns.checkbox {
133 for cap in pattern.captures_iter(line) {
134 let full_match = cap.get(0).unwrap();
135 let marker = cap.get(1).map(|m| m.as_str()).unwrap_or("");
136 let label = cap.get(2).map(|m| m.as_str().trim().to_string());
137
138 let is_checked = matches!(marker, "x" | "X" | "✓" | "✔" | "◉" | "●");
139
140 matches.push(PatternMatch {
141 element_type: ElementType::Checkbox,
142 label,
143 value: Some(if is_checked { "checked" } else { "unchecked" }.to_string()),
144 row,
145 col: full_match.start() as u16,
146 width: full_match.as_str().len() as u16,
147 checked: Some(is_checked),
148 });
149 }
150 }
151
152 for pattern in &patterns.radio {
153 for cap in pattern.captures_iter(line) {
154 let full_match = cap.get(0).unwrap();
155 let marker = cap.get(1).map(|m| m.as_str()).unwrap_or("");
156 let label = cap.get(2).map(|m| m.as_str().trim().to_string());
157
158 let is_selected = marker != " ";
159
160 matches.push(PatternMatch {
161 element_type: ElementType::Radio,
162 label,
163 value: Some(
164 if is_selected {
165 "selected"
166 } else {
167 "unselected"
168 }
169 .to_string(),
170 ),
171 row,
172 col: full_match.start() as u16,
173 width: full_match.as_str().len() as u16,
174 checked: Some(is_selected),
175 });
176 }
177 }
178
179 for pattern in &patterns.select {
180 for cap in pattern.captures_iter(line) {
181 let full_match = cap.get(0).unwrap();
182 let label = cap.get(1).map(|m| m.as_str().trim().to_string());
183 let value = cap.get(2).map(|m| m.as_str().trim().to_string());
184
185 matches.push(PatternMatch {
186 element_type: ElementType::Select,
187 label,
188 value,
189 row,
190 col: full_match.start() as u16,
191 width: full_match.as_str().len() as u16,
192 checked: None,
193 });
194 }
195 }
196
197 for pattern in &patterns.menu_item {
198 for cap in pattern.captures_iter(line) {
199 let full_match = cap.get(0).unwrap();
200 let label = cap
201 .get(2)
202 .or_else(|| cap.get(1))
203 .map(|m| m.as_str().trim().to_string());
204
205 matches.push(PatternMatch {
206 element_type: ElementType::MenuItem,
207 label,
208 value: None,
209 row,
210 col: full_match.start() as u16,
211 width: full_match.as_str().len() as u16,
212 checked: None,
213 });
214 }
215 }
216
217 for pattern in &patterns.list_item {
218 for cap in pattern.captures_iter(line) {
219 let full_match = cap.get(0).unwrap();
220 let label = cap.get(1).map(|m| m.as_str().trim().to_string());
221
222 matches.push(PatternMatch {
223 element_type: ElementType::ListItem,
224 label,
225 value: None,
226 row,
227 col: full_match.start() as u16,
228 width: full_match.as_str().len() as u16,
229 checked: None,
230 });
231 }
232 }
233
234 for pattern in &patterns.spinner {
235 for cap in pattern.captures_iter(line) {
236 let full_match = cap.get(0).unwrap();
237
238 matches.push(PatternMatch {
239 element_type: ElementType::Spinner,
240 label: None,
241 value: None,
242 row,
243 col: full_match.start() as u16,
244 width: full_match.as_str().len() as u16,
245 checked: None,
246 });
247 }
248 }
249
250 for pattern in &patterns.progress {
251 for cap in pattern.captures_iter(line) {
252 let full_match = cap.get(0).unwrap();
253 let value = cap.get(1).map(|m| m.as_str().trim().to_string());
254
255 matches.push(PatternMatch {
256 element_type: ElementType::Progress,
257 label: None,
258 value,
259 row,
260 col: full_match.start() as u16,
261 width: full_match.as_str().len() as u16,
262 checked: None,
263 });
264 }
265 }
266 }
267
268 deduplicate_matches(matches)
269}
270
271fn type_priority(t: &ElementType) -> i32 {
272 match t {
273 ElementType::Input => 10,
274 ElementType::Checkbox => 9,
275 ElementType::Radio => 9,
276 ElementType::Select => 8,
277 ElementType::Button => 7,
278 ElementType::MenuItem => 6,
279 ElementType::ListItem => 5,
280 ElementType::Spinner => 4,
281 ElementType::Progress => 3,
282 }
283}
284
285pub fn deduplicate_matches(mut matches: Vec<PatternMatch>) -> Vec<PatternMatch> {
286 matches.sort_by(|a, b| {
287 let priority_cmp = type_priority(&b.element_type).cmp(&type_priority(&a.element_type));
288 if priority_cmp != std::cmp::Ordering::Equal {
289 return priority_cmp;
290 }
291 if a.row != b.row {
292 return a.row.cmp(&b.row);
293 }
294 a.col.cmp(&b.col)
295 });
296
297 let mut result = Vec::new();
298 let mut occupied: HashSet<(u16, u16)> = HashSet::new();
299
300 for m in matches {
301 let mut overlaps = false;
302 for c in m.col..(m.col + m.width) {
303 if occupied.contains(&(m.row, c)) {
304 overlaps = true;
305 break;
306 }
307 }
308
309 if !overlaps {
310 for c in m.col..(m.col + m.width) {
311 occupied.insert((m.row, c));
312 }
313 result.push(m);
314 }
315 }
316
317 result
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_detect_button() {
326 let screen = "[Submit] [Cancel]";
327 let matches = detect_by_pattern(screen);
328
329 assert_eq!(matches.len(), 2);
330 assert!(matches
331 .iter()
332 .all(|m| m.element_type == ElementType::Button));
333 }
334
335 #[test]
336 fn test_detect_checkbox() {
337 let screen = "[x] Accept terms\n[ ] Subscribe to newsletter";
338 let matches = detect_by_pattern(screen);
339
340 let checkboxes: Vec<_> = matches
341 .iter()
342 .filter(|m| m.element_type == ElementType::Checkbox)
343 .collect();
344
345 assert_eq!(checkboxes.len(), 2);
346 assert_eq!(checkboxes[0].checked, Some(true));
347 assert_eq!(checkboxes[1].checked, Some(false));
348 }
349
350 #[test]
351 fn test_detect_input() {
352 let screen = "Name: [John Doe___]";
353 let matches = detect_by_pattern(screen);
354
355 let inputs: Vec<_> = matches
356 .iter()
357 .filter(|m| m.element_type == ElementType::Input)
358 .collect();
359
360 assert_eq!(inputs.len(), 1);
361 assert_eq!(inputs[0].label, Some("Name".to_string()));
362 assert_eq!(inputs[0].value, Some("John Doe".to_string()));
363 }
364
365 #[test]
366 fn test_detect_menu_item() {
367 let screen = " > Option 1\n Option 2\n Option 3";
368 let matches = detect_by_pattern(screen);
369
370 let menu_items: Vec<_> = matches
371 .iter()
372 .filter(|m| m.element_type == ElementType::MenuItem)
373 .collect();
374
375 assert!(!menu_items.is_empty());
376 }
377
378 #[test]
379 fn test_deduplication() {
380 let screen = "[value___]";
381 let matches = detect_by_pattern(screen);
382
383 assert_eq!(matches.len(), 1);
384 assert_eq!(matches[0].element_type, ElementType::Input);
385 }
386}