use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Bounds {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ElementState {
pub focused: bool,
pub enabled: bool,
pub visible: bool,
pub selected: bool,
pub expanded: Option<bool>,
pub checked: Option<bool>,
}
impl Default for ElementState {
fn default() -> Self {
Self {
focused: false,
enabled: true,
visible: true,
selected: false,
expanded: None,
checked: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConnectionEvent {
#[serde(default)]
pub timestamp_ms: u64,
#[serde(default = "default_protocol")]
pub protocol: String,
#[serde(default)]
pub local_addr: String,
#[serde(default)]
pub local_port: u16,
#[serde(default)]
pub remote_addr: String,
#[serde(default)]
pub remote_port: u16,
#[serde(default)]
pub state: String,
pub service: Option<String>,
pub process_name: Option<String>,
pub pid: Option<u32>,
}
fn default_protocol() -> String {
"tcp".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HttpEvent {
pub timestamp_ms: u64,
#[serde(default)]
pub method: String,
pub url: String,
#[serde(alias = "status")]
pub status_code: Option<u16>,
pub content_type: Option<String>,
pub duration_ms: Option<f64>,
pub size_bytes: Option<u64>,
#[serde(default)]
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClipboardState {
pub text: Option<String>,
pub has_image: bool,
pub has_files: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WindowState {
pub app_name: String,
pub title: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub layer: i32,
pub is_on_screen: bool,
pub pid: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AudioState {
pub volume: f32,
pub is_muted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PowerState {
pub battery_level: Option<f32>,
pub is_charging: bool,
pub is_plugged_in: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RunningApp {
pub name: String,
pub is_frontmost: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RecentFile {
pub name: String,
pub directory: String,
pub age_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ContextSource {
AccessibilityTree,
NativeApi,
Vision,
Cdp,
Ocr,
External,
Merged,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ContentRole {
#[default]
Interactive,
Content,
Decorative,
System,
}
pub fn classify_content_role(
element_type: &str,
actions: &[String],
state: &ElementState,
) -> ContentRole {
match element_type {
"button" | "link" | "input" | "textfield" | "textarea" | "combobox" | "select"
| "checkbox" | "radio" | "slider" | "switch" | "toggle" | "tab" | "tab_item"
| "menuitem" | "menu_item" | "menubar" | "menu" | "toolbar" | "searchfield"
| "tree_item" => ContentRole::Interactive,
"scrollbar" | "splitter" | "statusbar" | "status_bar" | "progressbar" | "indicator"
| "dialog" | "window" => ContentRole::System,
"separator" | "image" | "icon" | "spacer" => ContentRole::Decorative,
"text" | "statictext" | "paragraph" | "heading" | "label" | "cell" | "table"
| "table_row" | "table_cell" | "list" | "listitem" | "list_item" | "article"
| "blockquote" => ContentRole::Content,
_ => {
if !actions.is_empty() || state.focused {
ContentRole::Interactive
} else {
ContentRole::Content
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextElement {
pub id: String,
pub label: Option<String>,
pub description: Option<String>,
pub element_type: String,
pub value: Option<String>,
pub bounds: Option<Bounds>,
#[serde(default)]
pub state: ElementState,
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<String>,
pub confidence: f64,
pub source: ContextSource,
#[serde(default)]
pub content_role: ContentRole,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
deserialize_with = "flexible_properties"
)]
pub properties: HashMap<String, String>,
}
fn flexible_properties<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let mut map = HashMap::new();
if let serde_json::Value::Object(obj) = value {
for (key, val) in obj {
let string_value = match val {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
map.insert(key, string_value);
}
}
Ok(map)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundsRegion {
pub quadrant: String,
pub relative_x: f64,
pub relative_y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextReference {
pub element_type: String,
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ancestor_path: Vec<String>,
pub bounds_region: Option<BoundsRegion>,
pub value_pattern: Option<String>,
}
impl ContextElement {
pub(crate) fn build_ancestor_path(&self, all_elements: &[ContextElement]) -> Vec<String> {
let mut path = Vec::new();
let mut current_id = self.parent_id.as_deref();
let mut depth = 0;
while let Some(pid) = current_id {
if depth > 15 {
break;
}
if let Some(parent) = all_elements.iter().find(|e| e.id == pid) {
path.push(parent.element_type.clone());
current_id = parent.parent_id.as_deref();
} else {
break;
}
depth += 1;
}
path.reverse();
path
}
pub fn to_reference(&self, screen_width: u32, screen_height: u32) -> ContextReference {
let bounds_region = self.bounds.as_ref().and_then(|b| {
if screen_width == 0 || screen_height == 0 {
return None;
}
let cx = b.x as f64 + b.width as f64 / 2.0;
let cy = b.y as f64 + b.height as f64 / 2.0;
let rx = cx / screen_width as f64;
let ry = cy / screen_height as f64;
let col = if rx < 0.33 {
"left"
} else if rx < 0.66 {
"center"
} else {
"right"
};
let row = if ry < 0.33 {
"top"
} else if ry < 0.66 {
"center"
} else {
"bottom"
};
let quadrant = if row == "center" && col == "center" {
"center".to_string()
} else {
format!("{}-{}", row, col)
};
Some(BoundsRegion {
quadrant,
relative_x: rx.clamp(0.0, 1.0),
relative_y: ry.clamp(0.0, 1.0),
})
});
ContextReference {
element_type: self.element_type.clone(),
label: self.label.clone(),
ancestor_path: Vec::new(),
bounds_region,
value_pattern: self.value.clone(),
}
}
pub fn to_reference_in_context(
&self,
screen_width: u32,
screen_height: u32,
all_elements: &[ContextElement],
) -> ContextReference {
let mut reference = self.to_reference(screen_width, screen_height);
reference.ancestor_path = self.build_ancestor_path(all_elements);
reference
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEntry {
pub text: String,
pub start_ms: u64,
pub end_ms: u64,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextSnapshot {
pub app: String,
pub window: String,
pub elements: Vec<ContextElement>,
#[serde(default)]
pub network_events: Vec<ConnectionEvent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub http_events: Vec<HttpEvent>,
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clipboard: Option<ClipboardState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub window_list: Vec<WindowState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio: Option<AudioState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub power: Option<PowerState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub running_apps: Vec<RunningApp>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recent_files: Vec<RecentFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transcripts: Vec<TranscriptEntry>,
}
#[deprecated(note = "Use ContextSnapshot")]
pub type ScreenContext = ContextSnapshot;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocusedContext {
pub element: ContextElement,
pub subtree: Vec<ContextElement>,
pub ancestor_path: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn default_state() -> ElementState {
ElementState::default()
}
#[test]
fn test_classify_interactive_elements() {
let types = [
"button",
"link",
"input",
"textfield",
"textarea",
"combobox",
"select",
"checkbox",
"radio",
"slider",
"switch",
"toggle",
"tab",
"menuitem",
];
for t in types {
let role = classify_content_role(t, &[], &default_state());
assert_eq!(
role,
ContentRole::Interactive,
"Expected Interactive for '{}'",
t
);
}
}
#[test]
fn test_classify_content_elements() {
let types = [
"text",
"statictext",
"paragraph",
"heading",
"label",
"cell",
"table",
"table_row",
"table_cell",
"list",
"listitem",
"list_item",
];
for t in types {
let role = classify_content_role(t, &[], &default_state());
assert_eq!(role, ContentRole::Content, "Expected Content for '{}'", t);
}
}
#[test]
fn test_classify_system_elements() {
let types = [
"scrollbar",
"splitter",
"statusbar",
"status_bar",
"progressbar",
"dialog",
"window",
];
for t in types {
let role = classify_content_role(t, &[], &default_state());
assert_eq!(role, ContentRole::System, "Expected System for '{}'", t);
}
}
#[test]
fn test_classify_decorative_elements() {
let types = ["separator", "image", "icon", "spacer"];
for t in types {
let role = classify_content_role(t, &[], &default_state());
assert_eq!(
role,
ContentRole::Decorative,
"Expected Decorative for '{}'",
t
);
}
}
#[test]
fn test_unknown_with_actions_is_interactive() {
let role = classify_content_role("custom_widget", &["click".into()], &default_state());
assert_eq!(role, ContentRole::Interactive);
}
#[test]
fn test_unknown_without_actions_is_content() {
let role = classify_content_role("custom_widget", &[], &default_state());
assert_eq!(role, ContentRole::Content);
}
#[test]
fn test_unknown_focused_is_interactive() {
let mut state = default_state();
state.focused = true;
let role = classify_content_role("unknown", &[], &state);
assert_eq!(role, ContentRole::Interactive);
}
#[test]
fn test_content_role_default_is_interactive() {
assert_eq!(ContentRole::default(), ContentRole::Interactive);
}
#[test]
fn test_content_role_serialization() {
assert_eq!(
serde_json::to_string(&ContentRole::Interactive).unwrap(),
"\"interactive\""
);
assert_eq!(
serde_json::to_string(&ContentRole::Content).unwrap(),
"\"content\""
);
}
}