use actl_core::target::Target;
use actl_core::{CtlError, ErrorCode};
use uiautomation::types::ControlType;
use uiautomation::{UIAutomation, UIElement, UITreeWalker};
use crate::window::{MAX_DEPTH, ensure_window_responsive, find_window, top_level_of_focused};
use crate::{internal, timing};
pub struct Located {
pub window_title: String,
pub role: String,
pub name: Option<String>,
pub automation_id: Option<String>,
pub resolved_by: &'static str,
pub(crate) element: UIElement,
}
pub fn locate(app: Option<&str>, target: &Target, near: Option<&str>) -> Result<Located, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let root = {
let _t = actl_core::trace::scope("locate.window");
match app {
Some(pattern) => find_window(&auto, &walker, pattern)?,
None => {
if matches!(target, Target::Ref(_)) {
return Err(CtlError::protocol(format!(
"{} requires --app: refs are scoped to the window the snapshot came from; \
pass --app <title-substr> or re-snapshot",
target.describe()
)));
}
top_level_of_focused(&auto, &walker)?
}
}
};
let window_title = root.get_name().unwrap_or_default();
let _wake_ms = {
let _t = actl_core::trace::scope("locate.wake");
ensure_window_responsive(&root)
};
if let Target::Ref(n) = target {
let mut counter = 0u32;
let _t = actl_core::trace::scope("locate.ref-replay");
return match collect_until_ref(&walker, &root, 0, *n, &mut counter) {
Some(hit) => Ok(located_from(hit, window_title, "ref-replay")),
None => Err(CtlError::new(
ErrorCode::StaleRef,
format!(
"{} does not resolve — the tree changed since the snapshot",
target.describe()
),
)),
};
}
let mut counter = 0u32;
let mut matches = Vec::new();
if let Target::Id(_) = target {
let hits = {
let _t = actl_core::trace::scope("locate.fastpath");
fast_exact_ids(&auto, &root, target)
};
match hits {
Some(hits) if hits.len() == 1 => {
return Ok(located_from(hits[0].clone(), window_title, "primary"));
}
Some(hits) if hits.is_empty() => {
let _t = actl_core::trace::scope("locate.l4");
if let Some(hit) = locate_virtualized(&walker, &root, target) {
return Ok(located_from(hit, window_title, "item-container"));
}
return Err(CtlError::new(
ErrorCode::NotFound,
format!(
"no element matches {} (exact and virtualized-container levels)",
target.describe()
),
));
}
_ => {} }
}
{
let _t = actl_core::trace::scope("locate.dfs");
collect_matches(&walker, &root, 0, target, false, &mut counter, &mut matches);
}
if matches.is_empty() {
if let Target::Name(_) = target {
let mut fuzzy = Vec::new();
collect_matches(&walker, &root, 0, target, true, &mut counter, &mut fuzzy);
if !fuzzy.is_empty() {
fuzzy.sort_by_key(|c: &Candidate| c.ref_no);
return select(
fuzzy,
target,
near,
&walker,
&root,
window_title,
"fuzzy-name",
);
}
}
let l4 = {
let _t = actl_core::trace::scope("locate.l4");
locate_virtualized(&walker, &root, target)
};
if let Some(hit) = l4 {
return Ok(located_from(hit, window_title, "item-container"));
}
return Err(CtlError::new(
ErrorCode::NotFound,
format!(
"no element matches {} (exact, fuzzy and virtualized-container levels)",
target.describe()
),
));
}
matches.sort_by_key(|c: &Candidate| c.ref_no);
select(
matches,
target,
near,
&walker,
&root,
window_title,
"primary",
)
}
fn select(
matches: Vec<Candidate>,
target: &Target,
near: Option<&str>,
walker: &UITreeWalker,
root: &UIElement,
window_title: String,
level: &'static str,
) -> Result<Located, CtlError> {
if let Target::RoleAt(_, n) = target {
return match matches.get((*n as usize).saturating_sub(1)) {
Some(c) => Ok(located_from(
c.element.clone(),
window_title,
"role-ordinal",
)),
None => Err(CtlError::new(
ErrorCode::NotFound,
format!(
"{}: only {} match(es) in the window",
target.describe(),
matches.len()
),
)),
};
}
if matches.len() == 1 {
return Ok(located_from(
matches[0].element.clone(),
window_title,
level,
));
}
if let Some(anchor_pat) = near {
let mut counter = 0u32;
let mut anchors = Vec::new();
let anchor_target =
actl_core::parse_target(anchor_pat).unwrap_or(Target::Name(anchor_pat.to_string()));
collect_matches(
walker,
root,
0,
&anchor_target,
false,
&mut counter,
&mut anchors,
);
if anchors.len() != 1 {
return Err(CtlError::new(
ErrorCode::NotFound,
format!(
"--near anchor {anchor_pat:?} must match exactly one element, got {}",
anchors.len()
),
));
}
if let Ok(ar) = anchors[0].element.get_bounding_rectangle() {
let (ax, ay) = center(&ar);
let mut best: Option<(f64, usize)> = None;
for (i, c) in matches.iter().enumerate() {
if let Ok(r) = c.element.get_bounding_rectangle() {
let (cx, cy) = center(&r);
let dx = (cx - ax) as f64;
let dy = (cy - ay) as f64;
let d = dx * dx + dy * dy;
if best.map(|(bd, _)| d < bd).unwrap_or(true) {
best = Some((d, i));
}
}
}
if let Some((_, i)) = best {
return Ok(located_from(
matches[i].element.clone(),
window_title,
"anchor",
));
}
}
}
let evidence: Vec<serde_json::Value> = matches
.iter()
.take(8)
.map(|c| {
serde_json::json!({
"ref": format!("@e{}", c.ref_no),
"role": c.role,
"name": c.name,
"automation_id": c.automation_id,
})
})
.collect();
Err(CtlError::with_evidence(
ErrorCode::Ambiguous,
format!(
"{} matches {} elements in the window; pick one by @eN or role ordinal (--near anchor also applies)",
target.describe(),
matches.len()
),
serde_json::json!({ "candidates": evidence }),
))
}
fn center(r: &uiautomation::types::Rect) -> (i32, i32) {
(
(r.get_left() + r.get_right()) / 2,
(r.get_top() + r.get_bottom()) / 2,
)
}
fn located_from(elem: UIElement, window_title: String, resolved_by: &'static str) -> Located {
Located {
window_title,
role: format!(
"{:?}",
elem.get_control_type().unwrap_or(ControlType::Custom)
),
name: elem.get_name().ok().filter(|n| !n.is_empty()),
automation_id: elem.get_automation_id().ok().filter(|a| !a.is_empty()),
resolved_by,
element: elem,
}
}
struct Candidate {
element: UIElement,
ref_no: u32,
role: String,
name: Option<String>,
automation_id: Option<String>,
}
fn collect_until_ref(
walker: &UITreeWalker,
elem: &UIElement,
depth: u32,
want: u32,
counter: &mut u32,
) -> Option<UIElement> {
if depth > MAX_DEPTH {
return None;
}
let role = role_of(elem);
if actl_core::is_interactive_role(&role) {
*counter += 1;
if *counter == want {
return Some(elem.clone());
}
}
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
if let Some(hit) = collect_until_ref(walker, &c, depth + 1, want, counter) {
return Some(hit);
}
child = walker.get_next_sibling(&c).ok();
}
None
}
fn collect_matches(
walker: &UITreeWalker,
elem: &UIElement,
depth: u32,
target: &Target,
fuzzy: bool,
counter: &mut u32,
out: &mut Vec<Candidate>,
) {
if depth > MAX_DEPTH {
return;
}
let role = role_of(elem);
let ref_no = if actl_core::is_interactive_role(&role) {
*counter += 1;
*counter
} else {
0 };
let (name, id) = match target {
Target::Name(s) => {
let name = elem.get_name().ok().filter(|n| !n.is_empty());
let hit = match (&name, fuzzy) {
(Some(n), true) => actl_core::target::fuzzy_contains(n, s),
(Some(n), false) => n.contains(s.as_str()),
(None, _) => false,
};
if hit {
(
name,
elem.get_automation_id().ok().filter(|a| !a.is_empty()),
)
} else {
return dfs_children(walker, elem, depth, target, fuzzy, counter, out);
}
}
Target::Id(s) => {
let id = elem.get_automation_id().ok().filter(|a| !a.is_empty());
if id.as_deref() != Some(s.as_str()) {
return dfs_children(walker, elem, depth, target, fuzzy, counter, out);
}
(elem.get_name().ok().filter(|n| !n.is_empty()), id)
}
Target::Role(s) | Target::RoleAt(s, _) => {
if role != *s {
return dfs_children(walker, elem, depth, target, fuzzy, counter, out);
}
(
elem.get_name().ok().filter(|n| !n.is_empty()),
elem.get_automation_id().ok().filter(|a| !a.is_empty()),
)
}
Target::Ref(_) => (None, None),
};
let hit = match target {
Target::Ref(_) => false,
Target::Name(s) => match (&name, fuzzy) {
(Some(n), true) => actl_core::target::fuzzy_contains(n, s),
(Some(n), false) => n.contains(s.as_str()),
(None, _) => false,
},
Target::Id(s) => id.as_deref() == Some(s.as_str()),
Target::Role(s) | Target::RoleAt(s, _) => role == *s,
};
if hit {
out.push(Candidate {
element: elem.clone(),
ref_no,
role: role.clone(),
name: name.clone(),
automation_id: id.clone(),
});
}
dfs_children(walker, elem, depth, target, fuzzy, counter, out);
}
fn dfs_children(
walker: &UITreeWalker,
elem: &UIElement,
depth: u32,
target: &Target,
fuzzy: bool,
counter: &mut u32,
out: &mut Vec<Candidate>,
) {
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
collect_matches(walker, &c, depth + 1, target, fuzzy, counter, out);
child = walker.get_next_sibling(&c).ok();
}
}
pub(crate) fn role_of(elem: &UIElement) -> String {
format!(
"{:?}",
elem.get_control_type().unwrap_or(ControlType::Custom)
)
}
fn fast_exact_ids(
auto: &UIAutomation,
root: &UIElement,
target: &Target,
) -> Option<Vec<UIElement>> {
use uiautomation::types::{TreeScope, UIProperty};
use uiautomation::variants::Variant;
let Target::Id(s) = target else { return None };
let cond = auto
.create_property_condition(UIProperty::AutomationId, Variant::from(s.clone()), None)
.ok()?;
root.find_all(TreeScope::Subtree, &cond).ok()
}
fn container_query_for(
target: &Target,
) -> Option<(windows::Win32::UI::Accessibility::UIA_PROPERTY_ID, String)> {
use windows::Win32::UI::Accessibility::{UIA_AutomationIdPropertyId, UIA_NamePropertyId};
match target {
Target::Name(s) => Some((UIA_NamePropertyId, s.clone())),
Target::Id(s) => Some((UIA_AutomationIdPropertyId, s.clone())),
_ => None,
}
}
fn locate_virtualized(
walker: &UITreeWalker,
root: &UIElement,
target: &Target,
) -> Option<UIElement> {
use uiautomation::variants::Variant;
use windows::Win32::UI::Accessibility::{
IUIAutomationItemContainerPattern, UIA_ItemContainerPatternId,
};
let (prop, value) = container_query_for(target)?;
let mut containers = Vec::new();
collect_containers(walker, root, 0, &mut containers);
let value = Variant::from(value);
for c in &containers {
let pattern: Option<IUIAutomationItemContainerPattern> = (|| unsafe {
use windows::core::Interface;
let raw = c.as_ref();
raw.GetCurrentPattern(UIA_ItemContainerPatternId)
.ok()?
.cast()
.ok()
})();
let Some(pattern) = pattern else { continue };
if let Ok(elem) = unsafe { pattern.FindItemByProperty(None, prop, value.as_ref()) } {
return Some(UIElement::from(elem));
}
}
None
}
fn collect_containers(
walker: &UITreeWalker,
elem: &UIElement,
depth: u32,
out: &mut Vec<UIElement>,
) {
use uiautomation::patterns::UIItemContainerPattern;
if depth > MAX_DEPTH || out.len() >= 32 {
return;
}
if elem.get_pattern::<UIItemContainerPattern>().is_ok() {
out.push(elem.clone());
}
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
collect_containers(walker, &c, depth + 1, out);
child = walker.get_next_sibling(&c).ok();
}
}
pub fn wait_element(app: Option<&str>, target: &Target, timeout_ms: u64) -> Result<(), CtlError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
if locate(app, target, None).is_ok() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(CtlError::new(
ErrorCode::Timeout,
format!(
"element {} did not resolve within {timeout_ms}ms",
target.describe()
),
));
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
}
}
pub fn wait_gone(app: Option<&str>, target: &Target, timeout_ms: u64) -> Result<(), CtlError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
if locate(app, target, None).is_err() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(CtlError::new(
ErrorCode::Timeout,
format!(
"{} still resolves after {timeout_ms}ms (expected it to disappear)",
target.describe()
),
));
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
}
}
#[cfg(test)]
mod tests {
use super::container_query_for;
use actl_core::target::Target;
use windows::Win32::UI::Accessibility::{UIA_AutomationIdPropertyId, UIA_NamePropertyId};
#[test]
fn l4_maps_name_and_id_to_exact_property_queries() {
let (prop, v) = container_query_for(&Target::Name("report.xlsx".into())).expect("name");
assert_eq!(prop, UIA_NamePropertyId);
assert_eq!(v, "report.xlsx");
let (prop, v) = container_query_for(&Target::Id("SystemListItem".into())).expect("id");
assert_eq!(prop, UIA_AutomationIdPropertyId);
assert_eq!(v, "SystemListItem");
}
#[test]
fn l4_excludes_role_and_ref_targets() {
assert!(container_query_for(&Target::Role("ListItem".into())).is_none());
assert!(container_query_for(&Target::RoleAt("ListItem".into(), 2)).is_none());
assert!(container_query_for(&Target::Ref(7)).is_none());
}
}
pub fn wait_property(
app: Option<&str>,
target: &Target,
predicate: &WaitPredicate,
timeout_ms: u64,
) -> Result<String, CtlError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
let mut last: Option<String> = None;
loop {
if let Ok(loc) = locate(app, target, None) {
let actual = predicate.read(&loc.element);
if predicate.satisfied(actual.as_deref()) {
return Ok(actual.unwrap_or_default());
}
last = actual;
}
if std::time::Instant::now() >= deadline {
return Err(CtlError::with_evidence(
ErrorCode::Timeout,
format!(
"{} did not satisfy {} within {timeout_ms}ms",
target.describe(),
predicate.describe()
),
serde_json::json!({ "last_actual": last }),
));
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
}
}
pub struct WaitPredicate {
pub prop: WaitProp,
pub expected: String,
pub contains: bool,
}
pub enum WaitProp {
Value,
Name,
Checked,
}
impl WaitPredicate {
pub fn parse(raw: &str) -> Result<Self, CtlError> {
let Some((prop, expected)) = raw.split_once('=') else {
return Err(CtlError::protocol(format!(
"invalid predicate {raw:?}: expected <prop>=<expected> with prop in value|name|checked"
)));
};
let prop = match prop.trim() {
"value" => WaitProp::Value,
"name" => WaitProp::Name,
"checked" => WaitProp::Checked,
other => {
return Err(CtlError::protocol(format!(
"unknown predicate property {other:?}: expected value|name|checked"
)));
}
};
let expected = expected.trim().to_string();
if expected.is_empty() {
return Err(CtlError::protocol(format!(
"predicate {raw:?} has an empty expectation"
)));
}
if matches!(prop, WaitProp::Checked) && !matches!(expected.as_str(), "on" | "off") {
return Err(CtlError::protocol(
"checked predicate expects on|off (e.g. checked=on)",
));
}
Ok(Self {
prop,
expected,
contains: false,
})
}
pub fn describe(&self) -> String {
let p = match self.prop {
WaitProp::Value => "value",
WaitProp::Name => "name",
WaitProp::Checked => "checked",
};
format!("{p}={}", self.expected)
}
pub fn read(&self, elem: &uiautomation::UIElement) -> Option<String> {
match self.prop {
WaitProp::Value => crate::read::read_value(elem),
WaitProp::Name => elem.get_name().ok().filter(|n| !n.is_empty()),
WaitProp::Checked => elem
.get_pattern::<uiautomation::patterns::UITogglePattern>()
.ok()
.and_then(|t| t.get_toggle_state().ok())
.map(|s| if s.to_string() == "On" { "on" } else { "off" }.to_string()),
}
}
pub fn satisfied(&self, actual: Option<&str>) -> bool {
let Some(a) = actual else { return false };
match (&self.prop, self.contains) {
(WaitProp::Checked, _) | (WaitProp::Value, false) | (WaitProp::Name, false) => {
a == self.expected
}
(WaitProp::Value, true) | (WaitProp::Name, true) => a.contains(&self.expected),
}
}
}
#[cfg(test)]
mod wait_tests {
use super::*;
use actl_core::CtlError;
#[test]
fn predicate_parses_and_rejects() {
let p = WaitPredicate::parse("value=OK").unwrap();
assert!(p.satisfied(Some("OK")));
assert!(!p.satisfied(Some("ok"))); let p = WaitPredicate::parse("checked=on").unwrap();
assert!(p.satisfied(Some("on")));
assert!(WaitPredicate::parse("checked=maybe").is_err());
assert!(WaitPredicate::parse("bogus=1").is_err());
assert!(WaitPredicate::parse("noequalsign").is_err());
assert!(WaitPredicate::parse("value=").is_err());
}
#[test]
fn contains_semantics_apply_to_value_and_name_only() {
let mut p = WaitPredicate::parse("value=56,877").unwrap();
p.contains = true;
assert!(p.satisfied(Some("显示为 56,877")));
let mut p = WaitPredicate::parse("name=保存").unwrap();
p.contains = true;
assert!(p.satisfied(Some("另存为 - 保存")));
}
#[test]
fn missing_actual_is_unsatisfied() {
let p = WaitPredicate::parse("name=x").unwrap();
assert!(!p.satisfied(None));
let _ = CtlError::protocol(""); }
}