use std::time::{Duration, Instant};
use xa11y::{App, AppExt, ElementState, Locator};
use super::types::{ComputerWait, WaitObservation, WaitOutput, WaitState};
pub const DEFAULT_WAIT_MS: u64 = 10_000;
pub const MAX_WAIT_MS: u64 = 60_000;
pub fn validate(input: &ComputerWait) -> Result<(), String> {
if input
.selector
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_none()
{
return Err(
"computer_wait: `selector` is required — the element to watch, \
e.g. progress_bar[name='Exporting…']"
.to_string(),
);
}
let has_name = input
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_some();
if has_name && input.pid.is_some() {
return Err("computer_wait: provide `name` or `pid`, not both".to_string());
}
if !has_name && input.pid.is_none() {
return Err("computer_wait: provide `name` or `pid`".to_string());
}
if input.nth == Some(0) {
return Err("computer_wait: `nth` is 1-based; use 1 for the first match".to_string());
}
if input.timeout_ms.is_some_and(|ms| ms > MAX_WAIT_MS) {
return Err(format!(
"computer_wait: `timeout_ms` is capped at {MAX_WAIT_MS} ms per call — \
chain calls for longer waits"
));
}
Ok(())
}
pub async fn wait(input: &ComputerWait) -> Result<WaitOutput, String> {
let input = input.clone();
tokio::task::spawn_blocking(move || wait_blocking(&input))
.await
.map_err(|e| format!("computer_wait: blocking task failed: {e}"))?
}
fn wait_blocking(input: &ComputerWait) -> Result<WaitOutput, String> {
validate(input)?;
let timeout = Duration::from_millis(input.timeout_ms.unwrap_or(DEFAULT_WAIT_MS));
let started = Instant::now();
let name = input
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let app_timeout = timeout.min(Duration::from_millis(super::snapshot::DEFAULT_TIMEOUT_MS));
let app = match (name, input.pid) {
(Some(name), None) => App::by_name(name, app_timeout),
(None, Some(pid)) => App::by_pid(pid, app_timeout),
_ => unreachable!("validated: computer_wait carries exactly one app scope"),
}
.map_err(|e| super::errors::render_app_miss("computer_wait", &e))?;
let selector = input
.selector
.as_deref()
.expect("validated: computer_wait carries a selector");
let locator = app.locator(selector.trim()).nth(input.nth.unwrap_or(1));
let state = wait_state(input.state.unwrap_or_default());
let remaining = timeout.saturating_sub(started.elapsed());
wait_on_locator(&locator, state, remaining, started)
}
pub(crate) fn wait_state(state: WaitState) -> ElementState {
match state {
WaitState::Attached => ElementState::Attached,
WaitState::Detached => ElementState::Detached,
WaitState::Visible => ElementState::Visible,
WaitState::Hidden => ElementState::Hidden,
WaitState::Enabled => ElementState::Enabled,
WaitState::Disabled => ElementState::Disabled,
WaitState::Focused => ElementState::Focused,
WaitState::Unfocused => ElementState::Unfocused,
}
}
pub(crate) fn wait_on_locator(
locator: &Locator,
state: ElementState,
timeout: Duration,
started: Instant,
) -> Result<WaitOutput, String> {
let elapsed_ms = || u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match locator.wait_for_state(state, timeout) {
Ok(Some(element)) => Ok(WaitOutput {
met: true,
elapsed_ms: elapsed_ms(),
observed: observation(&element.data().states, true),
}),
Ok(None) => Ok(WaitOutput {
met: true,
elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
observed: WaitObservation {
attached: false,
visible: None,
enabled: None,
focused: None,
},
}),
Err(e) => Err(super::errors::render(
"computer_wait",
"wait for element state",
&e,
)),
}
}
fn observation(states: &xa11y::StateSet, attached: bool) -> WaitObservation {
if attached {
WaitObservation {
attached: true,
visible: Some(states.visible),
enabled: Some(states.enabled),
focused: Some(states.focused),
}
} else {
WaitObservation {
attached: false,
visible: None,
enabled: None,
focused: None,
}
}
}