1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::models::{Bounds, Viewport};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UiMap {
15 pub id: String,
16 pub timestamp: DateTime<Utc>,
17 pub url: String,
18 pub elements: Vec<UiElement>,
19 pub text_blocks: Vec<TextBlock>,
20 pub page_signals: PageSignals,
21 pub viewport: Viewport,
22 pub content_hash: String,
23 pub screenshot_path: String,
24}
25
26impl UiMap {
27 pub fn new(
28 url: String,
29 elements: Vec<UiElement>,
30 text_blocks: Vec<TextBlock>,
31 page_signals: PageSignals,
32 viewport: Viewport,
33 screenshot_path: String,
34 ) -> Self {
35 let id = uuid::Uuid::new_v4().to_string();
36 let timestamp = Utc::now();
37 let mut map = Self {
38 id,
39 timestamp,
40 url,
41 elements,
42 text_blocks,
43 page_signals,
44 viewport,
45 content_hash: String::new(),
46 screenshot_path,
47 };
48 map.content_hash = map.compute_content_hash();
49 map
50 }
51
52 pub fn compute_content_hash(&self) -> String {
53 let mut hasher = Sha256::new();
54 for element in &self.elements {
55 hasher.update(element.id.as_bytes());
56 hasher.update(element.role.to_hash_string().as_bytes());
57 if let Some(name) = &element.name {
58 hasher.update(name.as_bytes());
59 }
60 hasher.update(element.states.to_hash_string().as_bytes());
61 }
62 for block in &self.text_blocks {
63 hasher.update(block.text.as_bytes());
64 }
65 hasher.update(self.page_signals.to_hash_string().as_bytes());
66 hex::encode(hasher.finalize())
67 }
68
69 pub fn get_element(&self, element_id: &str) -> Option<&UiElement> {
70 self.elements.iter().find(|e| e.id == element_id)
71 }
72
73 pub fn get_elements_by_role(&self, role: UiRole) -> Vec<&UiElement> {
74 self.elements
75 .iter()
76 .filter(|e| std::mem::discriminant(&e.role) == std::mem::discriminant(&role))
77 .collect()
78 }
79
80 pub fn interactive_elements(&self) -> Vec<&UiElement> {
81 self.elements
82 .iter()
83 .filter(|e| e.role.is_interactable() && e.is_interactable())
84 .collect()
85 }
86
87 pub fn estimate_tokens(&self, interactive_only: bool) -> usize {
88 let count = if interactive_only {
89 self.interactive_elements().len()
90 } else {
91 self.elements.len()
92 };
93 count * 20 + 30
94 }
95
96 pub fn average_confidence(&self) -> f32 {
97 if self.elements.is_empty() {
98 return 0.0;
99 }
100 let sum: f32 = self.elements.iter().map(|e| e.confidence).sum();
101 sum / self.elements.len() as f32
102 }
103
104 pub fn format_summary(&self) -> String {
108 use std::fmt::Write;
109 let mut output = String::new();
110
111 if self.page_signals.has_blocking_element() {
113 if self.page_signals.modal_present {
114 let _ = writeln!(output, "⚠ Modal dialog present");
115 }
116 if self.page_signals.cookie_banner {
117 let _ = writeln!(output, "⚠ Cookie banner present");
118 }
119 }
120 if self.page_signals.loading_indicator {
121 let _ = writeln!(output, "⏳ Page loading...");
122 }
123
124 let _ = writeln!(output, "\n## Visible Text");
126 let mut seen_texts: std::collections::HashSet<String> = std::collections::HashSet::new();
127 for el in &self.elements {
128 if let Some(ref name) = el.name {
129 let text = name.trim().to_string();
130 if !text.is_empty() && text.len() > 1 && seen_texts.insert(text.clone()) {
131 let role = el.role.to_hash_string();
132 let truncated = if text.len() > 80 {
133 let end = text.floor_char_boundary(77);
134 format!("{}...", &text[..end])
135 } else {
136 text
137 };
138 let _ = writeln!(output, " ({}) {}", role, truncated);
139 }
140 }
141 }
142
143 let _ = writeln!(output, "\n## Interactive Elements");
145 let interactive = self.interactive_elements();
146 for (i, el) in interactive.iter().enumerate().take(50) {
147 let role_str = el.role.to_hash_string();
148 let name_str = el.display_label(40);
149 let mut state_parts = Vec::new();
150 if element_states_for_summary(&el.states, &mut state_parts) {
151 let _ = writeln!(
152 output,
153 "[{}] {}{} {}",
154 el.id,
155 role_str,
156 name_str,
157 state_parts.join(" ")
158 );
159 } else {
160 let _ = writeln!(output, "[{}] {}{}", el.id, role_str, name_str);
161 }
162 let _ = i; }
164 if interactive.len() > 50 {
165 let _ = writeln!(
166 output,
167 " ... and {} more interactive elements",
168 interactive.len() - 50
169 );
170 }
171
172 output
173 }
174
175 pub fn format_compact(&self) -> String {
179 use std::fmt::Write;
180 let mut output = String::new();
181
182 if self.page_signals.has_blocking_element() {
184 if self.page_signals.modal_present {
185 let _ = writeln!(output, "⚠ Modal dialog present");
186 }
187 if self.page_signals.cookie_banner {
188 let _ = writeln!(output, "⚠ Cookie banner present");
189 }
190 }
191 if self.page_signals.loading_indicator {
192 let _ = writeln!(output, "⏳ Page loading...");
193 }
194
195 let elements: Vec<&UiElement> = if self.elements.len() > 40 {
197 self.interactive_elements()
198 } else {
199 self.elements.iter().collect()
200 };
201
202 for element in &elements {
203 let role_str = element.role.to_hash_string();
204 let name_str = element.display_label(50);
205
206 let (cx, cy) = element.bounds.center();
207 let pos_str = format!(" ({:.0},{:.0})", cx, cy);
208
209 let mut state_parts = Vec::new();
210 if element.states.focused {
211 state_parts.push("focused");
212 }
213 if !element.states.enabled {
214 state_parts.push("disabled");
215 }
216 if element.states.checked == Some(true) {
217 state_parts.push("checked");
218 }
219 if element.states.expanded == Some(true) {
220 state_parts.push("expanded");
221 }
222 let state_str = if state_parts.is_empty() {
223 String::new()
224 } else {
225 format!(" {}", state_parts.join(" "))
226 };
227
228 let _ = writeln!(
229 output,
230 "[{}] {}{}{}{}",
231 element.id, role_str, name_str, pos_str, state_str
232 );
233 }
234
235 output
236 }
237}
238
239fn element_states_for_summary(states: &UiState, parts: &mut Vec<&'static str>) -> bool {
240 if states.focused {
241 parts.push("focused");
242 }
243 if !states.enabled {
244 parts.push("disabled");
245 }
246 if states.checked == Some(true) {
247 parts.push("checked");
248 }
249 if states.expanded == Some(true) {
250 parts.push("expanded");
251 }
252 !parts.is_empty()
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct UiElement {
258 pub id: String,
259 pub role: UiRole,
260 pub name: Option<String>,
261 pub value: Option<String>,
263 pub bounds: Bounds,
264 pub states: UiState,
265 pub confidence: f32,
266 pub source: ElementSource,
267 pub icon_type: Option<IconType>,
268 pub children: Vec<String>,
269 pub ax_ref: Option<String>,
271}
272
273impl UiElement {
274 pub fn is_interactable(&self) -> bool {
275 self.states.enabled && self.bounds.width > 0.0 && self.bounds.height > 0.0
276 }
277
278 pub fn center(&self) -> (f64, f64) {
279 self.bounds.center()
280 }
281
282 pub fn accepts_text(&self) -> bool {
283 matches!(self.role, UiRole::TextInput)
284 }
285
286 pub fn is_clickable(&self) -> bool {
287 matches!(self.role, UiRole::Button | UiRole::Link)
288 }
289
290 pub fn display_label(&self, max_len: usize) -> String {
295 if let Some(name) = self
296 .name
297 .as_deref()
298 .map(str::trim)
299 .filter(|n| !n.is_empty())
300 {
301 if name.chars().count() > max_len {
302 let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect();
303 format!(" \"{truncated}...\"")
304 } else {
305 format!(" \"{name}\"")
306 }
307 } else if let Some(icon) = self.icon_type {
308 format!(" <icon: {}>", icon.label())
309 } else {
310 String::new()
311 }
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum UiRole {
319 Button,
320 Link,
321 TextInput,
322 Checkbox,
323 Radio,
324 Dropdown,
325 Menu,
326 MenuItem,
327 Tab,
328 Dialog,
329 Image,
330 Text,
331 Container,
332 List,
333 ListItem,
334 Table,
335 TableRow,
336 TableCell,
337 Toolbar,
338 Other(String),
339}
340
341impl UiRole {
342 pub fn from_ax_role(ax_role: &str) -> Self {
343 match ax_role.to_lowercase().as_str() {
344 "button" | "pushbutton" => UiRole::Button,
345 "link" | "weblink" => UiRole::Link,
346 "textbox" | "textfield" | "textarea" | "combobox" | "searchfield" => UiRole::TextInput,
347 "checkbox" => UiRole::Checkbox,
348 "radio" | "radiobutton" => UiRole::Radio,
349 "select" | "listbox" | "popupbutton" => UiRole::Dropdown,
350 "menu" | "menubar" => UiRole::Menu,
351 "menuitem" | "menuitemcheckbox" | "menuitemradio" => UiRole::MenuItem,
352 "tab" | "tabitem" => UiRole::Tab,
353 "dialog" | "alertdialog" | "sheet" => UiRole::Dialog,
354 "image" | "img" => UiRole::Image,
355 "statictext" | "label" | "heading" => UiRole::Text,
356 "group" | "generic" | "section" | "div" | "webarea" => UiRole::Container,
357 "list" => UiRole::List,
358 "listitem" => UiRole::ListItem,
359 "table" | "grid" => UiRole::Table,
360 "row" | "tablerow" => UiRole::TableRow,
361 "cell" | "tablecell" | "gridcell" => UiRole::TableCell,
362 "toolbar" => UiRole::Toolbar,
363 other => UiRole::Other(other.to_string()),
364 }
365 }
366
367 pub fn is_interactable(&self) -> bool {
368 matches!(
369 self,
370 UiRole::Button
371 | UiRole::Link
372 | UiRole::TextInput
373 | UiRole::Checkbox
374 | UiRole::Radio
375 | UiRole::Dropdown
376 | UiRole::MenuItem
377 | UiRole::Tab
378 )
379 }
380
381 pub fn to_hash_string(&self) -> String {
382 match self {
383 UiRole::Button => "button".to_string(),
384 UiRole::Link => "link".to_string(),
385 UiRole::TextInput => "text_input".to_string(),
386 UiRole::Checkbox => "checkbox".to_string(),
387 UiRole::Radio => "radio".to_string(),
388 UiRole::Dropdown => "dropdown".to_string(),
389 UiRole::Menu => "menu".to_string(),
390 UiRole::MenuItem => "menu_item".to_string(),
391 UiRole::Tab => "tab".to_string(),
392 UiRole::Dialog => "dialog".to_string(),
393 UiRole::Image => "image".to_string(),
394 UiRole::Text => "text".to_string(),
395 UiRole::Container => "container".to_string(),
396 UiRole::List => "list".to_string(),
397 UiRole::ListItem => "list_item".to_string(),
398 UiRole::Table => "table".to_string(),
399 UiRole::TableRow => "table_row".to_string(),
400 UiRole::TableCell => "table_cell".to_string(),
401 UiRole::Toolbar => "toolbar".to_string(),
402 UiRole::Other(s) => format!("other:{}", s),
403 }
404 }
405}
406
407#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct UiState {
410 pub enabled: bool,
411 pub focused: bool,
412 pub selected: bool,
413 pub checked: Option<bool>,
414 pub expanded: Option<bool>,
415 pub readonly: bool,
416 pub required: bool,
417}
418
419impl UiState {
420 pub fn enabled() -> Self {
421 Self {
422 enabled: true,
423 ..Default::default()
424 }
425 }
426
427 pub fn disabled() -> Self {
428 Self {
429 enabled: false,
430 ..Default::default()
431 }
432 }
433
434 pub fn from_ax_states(
435 disabled: bool,
436 focused: bool,
437 selected: Option<bool>,
438 checked: Option<bool>,
439 expanded: Option<bool>,
440 ) -> Self {
441 Self {
442 enabled: !disabled,
443 focused,
444 selected: selected.unwrap_or(false),
445 checked,
446 expanded,
447 readonly: false,
448 required: false,
449 }
450 }
451
452 pub fn to_hash_string(&self) -> String {
453 fn opt_bool_str(opt: Option<bool>) -> &'static str {
454 match opt {
455 None => "none",
456 Some(true) => "true",
457 Some(false) => "false",
458 }
459 }
460 format!(
461 "en:{},fo:{},se:{},ch:{},ex:{},ro:{},rq:{}",
462 self.enabled,
463 self.focused,
464 self.selected,
465 opt_bool_str(self.checked),
466 opt_bool_str(self.expanded),
467 self.readonly,
468 self.required
469 )
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
474#[serde(rename_all = "snake_case")]
475pub enum ElementSource {
476 AccessibilityTree,
477 VisualDetector,
478 Ocr,
479 Merged { sources: Vec<ElementSource> },
480}
481
482impl ElementSource {
483 pub fn base_confidence(&self) -> f32 {
484 match self {
485 ElementSource::AccessibilityTree => 0.90,
486 ElementSource::VisualDetector => 0.75,
487 ElementSource::Ocr => 0.70,
488 ElementSource::Merged { .. } => 0.98,
489 }
490 }
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum IconType {
496 Close,
497 Menu,
498 Search,
499 Back,
500 Forward,
501 Refresh,
502 Settings,
503 Share,
504 Download,
505 Upload,
506 Edit,
507 Delete,
508 Add,
509 Remove,
510 Expand,
511 Collapse,
512 Play,
513 Pause,
514 Stop,
515 Mute,
516 Unmute,
517 Fullscreen,
518 ExitFullscreen,
519 Info,
520 Help,
521 Warning,
522 Error,
523 Success,
524 Unknown,
525}
526
527impl IconType {
528 pub fn classify(name: Option<&str>) -> Option<IconType> {
536 let raw = name?.trim();
537 if raw.is_empty() {
538 return None;
539 }
540 if let Some(icon) = match raw {
542 "×" | "✕" | "✖" | "⨯" | "╳" => Some(IconType::Close),
543 "☰" | "≡" => Some(IconType::Menu),
544 "🔍" | "⌕" => Some(IconType::Search),
545 "←" | "‹" | "◀" => Some(IconType::Back),
546 "→" | "›" | "▶" => Some(IconType::Forward),
547 "↻" | "⟳" | "⭮" => Some(IconType::Refresh),
548 "⚙" | "⚙️" => Some(IconType::Settings),
549 "⬇" | "↓" => Some(IconType::Download),
550 "⬆" | "↑" => Some(IconType::Upload),
551 "✏" | "✎" => Some(IconType::Edit),
552 "🗑" | "🗑️" => Some(IconType::Delete),
553 "+" | "+" => Some(IconType::Add),
554 "−" | "-" | "–" => Some(IconType::Remove),
555 "▼" | "˅" | "⌄" => Some(IconType::Expand),
556 "▲" | "˄" | "⌃" => Some(IconType::Collapse),
557 "ℹ" | "ℹ️" => Some(IconType::Info),
558 "?" | "?" => Some(IconType::Help),
559 "⚠" | "⚠️" => Some(IconType::Warning),
560 _ => None,
561 } {
562 return Some(icon);
563 }
564 let n = raw.to_lowercase();
569 let tokens: Vec<&str> = n
570 .split(|c: char| !c.is_alphanumeric())
571 .filter(|t| !t.is_empty())
572 .collect();
573 let has = |word: &str| tokens.contains(&word);
574 let icon = if (has("exit") && has("fullscreen"))
576 || (has("exit") && has("full") && has("screen"))
577 {
578 IconType::ExitFullscreen
579 } else if has("fullscreen") || (has("full") && has("screen")) {
580 IconType::Fullscreen
581 } else if has("close") || has("dismiss") {
582 IconType::Close
583 } else if has("hamburger") || has("menu") {
584 IconType::Menu
585 } else if has("search") {
586 IconType::Search
587 } else if has("previous") || has("back") {
588 IconType::Back
589 } else if has("forward") || has("next") {
590 IconType::Forward
591 } else if has("refresh") || has("reload") {
592 IconType::Refresh
593 } else if has("settings") || has("preferences") {
594 IconType::Settings
595 } else if has("share") {
596 IconType::Share
597 } else if has("download") {
598 IconType::Download
599 } else if has("upload") {
600 IconType::Upload
601 } else if has("edit") {
602 IconType::Edit
603 } else if has("delete") || has("trash") {
604 IconType::Delete
605 } else if has("remove") {
606 IconType::Remove
607 } else if has("unmute") {
608 IconType::Unmute
609 } else if has("mute") {
610 IconType::Mute
611 } else if has("pause") {
612 IconType::Pause
613 } else if has("play") {
614 IconType::Play
615 } else if has("expand") {
616 IconType::Expand
617 } else if has("collapse") {
618 IconType::Collapse
619 } else if has("help") {
620 IconType::Help
621 } else if has("info") {
622 IconType::Info
623 } else {
624 return None;
625 };
626 Some(icon)
627 }
628
629 pub fn label(&self) -> &'static str {
631 match self {
632 IconType::Close => "close",
633 IconType::Menu => "menu",
634 IconType::Search => "search",
635 IconType::Back => "back",
636 IconType::Forward => "forward",
637 IconType::Refresh => "refresh",
638 IconType::Settings => "settings",
639 IconType::Share => "share",
640 IconType::Download => "download",
641 IconType::Upload => "upload",
642 IconType::Edit => "edit",
643 IconType::Delete => "delete",
644 IconType::Add => "add",
645 IconType::Remove => "remove",
646 IconType::Expand => "expand",
647 IconType::Collapse => "collapse",
648 IconType::Play => "play",
649 IconType::Pause => "pause",
650 IconType::Stop => "stop",
651 IconType::Mute => "mute",
652 IconType::Unmute => "unmute",
653 IconType::Fullscreen => "fullscreen",
654 IconType::ExitFullscreen => "exit_fullscreen",
655 IconType::Info => "info",
656 IconType::Help => "help",
657 IconType::Warning => "warning",
658 IconType::Error => "error",
659 IconType::Success => "success",
660 IconType::Unknown => "unknown",
661 }
662 }
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct TextBlock {
667 pub text: String,
668 pub bounds: Bounds,
669 pub source: TextSource,
670 pub confidence: f32,
671}
672
673impl TextBlock {
674 pub fn from_ax(text: String, bounds: Bounds) -> Self {
675 Self {
676 text,
677 bounds,
678 source: TextSource::AccessibilityTree,
679 confidence: 1.0,
680 }
681 }
682
683 pub fn from_ocr(text: String, bounds: Bounds, confidence: f32) -> Self {
686 Self {
687 text,
688 bounds,
689 source: TextSource::Ocr,
690 confidence,
691 }
692 }
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum TextSource {
698 AccessibilityTree,
699 Ocr,
700}
701
702#[derive(Debug, Clone, Default, Serialize, Deserialize)]
703pub struct PageSignals {
704 pub modal_present: bool,
705 pub cookie_banner: bool,
706 pub error_banner: bool,
707 pub loading_indicator: bool,
708 pub scroll_position: f32,
709 pub page_type_hint: Option<String>,
710}
711
712impl PageSignals {
713 pub fn has_blocking_element(&self) -> bool {
714 self.modal_present || self.cookie_banner
715 }
716
717 pub fn needs_special_handling(&self) -> bool {
718 matches!(
719 self.page_type_hint.as_deref(),
720 Some("login") | Some("checkout") | Some("payment")
721 )
722 }
723
724 pub fn to_hash_string(&self) -> String {
725 format!(
726 "mo:{},co:{},er:{},lo:{},sc:{:.2},ty:{}",
727 self.modal_present,
728 self.cookie_banner,
729 self.error_banner,
730 self.loading_indicator,
731 self.scroll_position,
732 self.page_type_hint.as_deref().unwrap_or("none")
733 )
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn test_icon_classify_whole_word_not_substring() {
743 assert_eq!(IconType::classify(Some("✕")), Some(IconType::Close));
745 assert_eq!(IconType::classify(Some("Close")), Some(IconType::Close));
746 assert_eq!(IconType::classify(Some("Go back")), Some(IconType::Back));
747 assert_eq!(IconType::classify(Some("Search")), Some(IconType::Search));
748 assert_eq!(IconType::classify(Some("Unmute")), Some(IconType::Unmute));
749 assert_eq!(IconType::classify(Some("Mute")), Some(IconType::Mute));
750 assert_eq!(
751 IconType::classify(Some("Exit fullscreen")),
752 Some(IconType::ExitFullscreen)
753 );
754 assert_eq!(
755 IconType::classify(Some("Full screen")),
756 Some(IconType::Fullscreen)
757 );
758 assert_eq!(
760 IconType::classify(Some("feedback")),
761 None,
762 "'back' substring"
763 );
764 assert_eq!(
765 IconType::classify(Some("display options")),
766 None,
767 "'play' substring"
768 );
769 assert_eq!(IconType::classify(Some("address")), None, "'add' substring");
770 assert_eq!(
771 IconType::classify(Some("credit card")),
772 None,
773 "'edit' substring"
774 );
775 assert_eq!(IconType::classify(Some("")), None);
776 assert_eq!(IconType::classify(None), None);
777 }
778
779 #[test]
780 fn test_ui_role_from_ax() {
781 assert_eq!(UiRole::from_ax_role("button"), UiRole::Button);
782 assert_eq!(UiRole::from_ax_role("textfield"), UiRole::TextInput);
783 assert!(matches!(UiRole::from_ax_role("custom"), UiRole::Other(_)));
784 }
785
786 #[test]
787 fn test_format_compact() {
788 let viewport = Viewport {
789 width: 1280,
790 height: 720,
791 device_pixel_ratio: 2.0,
792 };
793 let map = UiMap::new(
794 "https://example.com".to_string(),
795 vec![UiElement {
796 id: "el_0".to_string(),
797 role: UiRole::Button,
798 name: Some("Submit".to_string()),
799 value: None,
800 bounds: Bounds::new(100.0, 100.0, 80.0, 30.0),
801 states: UiState {
802 focused: true,
803 ..UiState::enabled()
804 },
805 confidence: 0.95,
806 source: ElementSource::AccessibilityTree,
807 icon_type: None,
808 children: vec![],
809 ax_ref: None,
810 }],
811 vec![],
812 PageSignals::default(),
813 viewport,
814 String::new(),
815 );
816 let compact = map.format_compact();
817 assert!(compact.contains("[el_0]"));
818 assert!(compact.contains("button"));
819 assert!(compact.contains("Submit"));
820 assert!(compact.contains("focused"));
821 }
822}