1use std::time::Duration;
16
17use xa11y::{
18 Anchor, App, AppExt, ClickOptions, ClickTarget, DragOptions, MouseButton, Point, ScrollDelta,
19};
20
21use super::keyboard;
22use super::snapshot::DEFAULT_TIMEOUT_MS;
23use super::types::{ComputerControl, PointerAction, PointerAnchor};
24
25pub const MIN_DRAG_MS: u64 = 50;
28
29pub const DEFAULT_DRAG_MS: u64 = 150;
31
32pub fn validate(step: &ComputerControl, tool: &str) -> Result<(), String> {
48 let action = step.action.unwrap_or_default();
49 if step.x.is_some() != step.y.is_some() {
52 return Err(format!("{tool}: coordinates require BOTH `x` and `y`"));
53 }
54 let has_point = step.x.is_some() || step.y.is_some();
55 let has_element = step.selector.is_some()
56 || step.app.is_some()
57 || step.pid.is_some()
58 || step.surface.is_some();
59
60 if has_point && has_element {
62 return Err(format!(
63 "{tool}: use coordinates (`x`/`y`) OR the element form \
64 (`app`/`pid`/`surface` + `selector`), not both"
65 ));
66 }
67 if action != PointerAction::Up && !has_point && !has_element {
68 return Err(format!(
69 "{tool}: action `{}` requires a target — coordinates (`x`/`y`) \
70 or the element form (`app`/`pid`/`surface` + `selector`)",
71 action_name(action)
72 ));
73 }
74 if step.selector.is_some() && step.app.is_none() && step.pid.is_none() && step.surface.is_none()
78 {
79 return Err(format!(
80 "{tool}: the element form requires `app`, `pid` or `surface` together with `selector`"
81 ));
82 }
83 if (step.app.is_some() || step.pid.is_some() || step.surface.is_some())
84 && step.selector.is_none()
85 {
86 return Err(format!(
87 "{tool}: `app`/`pid`/`surface` without `selector` would leak the scope — \
88 pass `selector`, or use coordinates (`x`/`y`)"
89 ));
90 }
91 if step.app.is_some() && (step.pid.is_some() || step.surface.is_some()) {
92 return Err(format!(
93 "{tool}: target ONE of `app`/`pid`/`surface` — they cannot share a step"
94 ));
95 }
96 if step.pid.is_some() && step.surface.is_some() {
97 return Err(format!(
98 "{tool}: target ONE of `app`/`pid`/`surface` — they cannot share a step"
99 ));
100 }
101 if step.nth == Some(0) {
102 return Err(format!(
103 "{tool}: `nth` is 1-based; use 1 for the first match"
104 ));
105 }
106 if step.nth.is_some() && step.selector.is_none() {
107 return Err(format!("{tool}: `nth` requires `selector`"));
108 }
109 if step.anchor.is_some() && step.selector.is_none() {
110 return Err(format!(
111 "{tool}: `anchor` applies to the element form and requires `selector`"
112 ));
113 }
114
115 match action {
116 PointerAction::Scroll if step.dx.is_none() && step.dy.is_none() => {
117 return Err(format!("{tool}: action `scroll` requires `dx` and/or `dy`"));
118 }
119 PointerAction::Drag => validate_drag(step, tool)?,
120 PointerAction::Click | PointerAction::DoubleClick | PointerAction::RightClick => {}
122 _ => {
125 if step.count.is_some() {
126 return Err(format!(
127 "{tool}: `count` applies to click actions, not `{}`",
128 action_name(action)
129 ));
130 }
131 if step.held.is_some() {
132 return Err(format!(
133 "{tool}: `held` applies to click and drag actions, not `{}`",
134 action_name(action)
135 ));
136 }
137 }
138 }
139 if step.count == Some(0) {
140 return Err(format!(
141 "{tool}: `count` is 1-based; use 1 for a single click"
142 ));
143 }
144 if let Some(duration) = step.duration_ms {
145 if action != PointerAction::Drag {
146 return Err(format!("{tool}: `duration_ms` applies only to `drag`"));
147 }
148 if duration < MIN_DRAG_MS {
149 return Err(format!(
150 "{tool}: `duration_ms` must be >= {MIN_DRAG_MS} (a shorter \
151 movement degenerates to a jump and drops hover events)"
152 ));
153 }
154 }
155 if action != PointerAction::Drag
158 && (step.x2.is_some() || step.y2.is_some() || step.to_selector.is_some())
159 {
160 return Err(format!(
161 "{tool}: `x2`/`y2`/`to_selector` are drag endpoints and apply \
162 only to `drag`, not `{}`",
163 action_name(action)
164 ));
165 }
166 Ok(())
167}
168
169fn validate_drag(step: &ComputerControl, tool: &str) -> Result<(), String> {
171 let coord_end = step.x2.is_some() || step.y2.is_some();
172 let start_is_coord = step.x.is_some() || step.y.is_some();
173 let start_is_element = step.selector.is_some();
174
175 if coord_end && (step.x2.is_none() || step.y2.is_none()) {
176 return Err(format!(
177 "{tool}: `drag` by coordinates requires both `x2` and `y2`"
178 ));
179 }
180 if coord_end && step.to_selector.is_some() {
181 return Err(format!(
182 "{tool}: `drag` end is either `x2`/`y2` OR `to_selector`, not both"
183 ));
184 }
185 if start_is_element && coord_end {
186 return Err(format!(
187 "{tool}: `drag` endpoints must share a form — an element start pairs \
188 with `to_selector`, a coordinate start with `x2`/`y2`"
189 ));
190 }
191 if !start_is_element && step.to_selector.is_some() {
192 return Err(format!(
193 "{tool}: `to_selector` requires an element start (`app`/`pid`/`surface` + \
194 `selector`)"
195 ));
196 }
197 if start_is_element && step.to_selector.is_none() {
198 return Err(format!(
199 "{tool}: `drag` from an element requires `to_selector` for the end \
200 point (element-to-element); use `x2`/`y2` with a coordinate start"
201 ));
202 }
203 if start_is_coord && !coord_end {
204 return Err(format!(
205 "{tool}: `drag` requires an end point — `x2`/`y2` or `to_selector`"
206 ));
207 }
208 Ok(())
209}
210
211pub fn run_step(
215 sim: &xa11y::InputSim,
216 step: &ComputerControl,
217 tool: &str,
218) -> Result<String, String> {
219 let action = step.action.unwrap_or_default();
220 let button = parse_button(step.button.as_deref(), tool)?;
221 let held = keyboard::parse_keys(step.held.as_deref().unwrap_or_default(), tool)?;
222 let count = step.count.unwrap_or(1);
223
224 let point: Option<Point> = if let Some(selector) = &step.selector {
227 let element = resolve_element(step, selector, step.nth.unwrap_or(1), tool)?;
230 Some(
231 xa11y::point_for(&element, anchor(step.anchor.unwrap_or_default())).map_err(|e| {
232 super::errors::render(tool, &format!("resolve point for `{selector}`"), &e)
233 })?,
234 )
235 } else {
236 match (step.x, step.y) {
237 (Some(x), Some(y)) => Some(Point::new(x, y)),
238 _ => None,
239 }
240 };
241
242 let mut drag_to: Option<Point> = None;
247 let result = match action {
248 PointerAction::Click => sim.mouse().click_with(
249 point_target(point),
250 ClickOptions::new().button(button).count(count).held(held),
251 ),
252 PointerAction::DoubleClick => sim.mouse().click_with(
256 point_target(point),
257 ClickOptions::new()
258 .button(button)
259 .count(step.count.unwrap_or(2))
260 .held(held),
261 ),
262 PointerAction::RightClick => sim.mouse().click_with(
263 point_target(point),
264 ClickOptions::new()
265 .button(MouseButton::Right)
266 .count(count)
267 .held(held),
268 ),
269 PointerAction::Move => sim
270 .mouse()
271 .move_to(point.expect("validated: move requires a target")),
272 PointerAction::Down => {
277 if let Some(p) = point {
278 sim.mouse()
279 .move_to(p)
280 .map_err(|e| super::errors::render(tool, "move pointer", &e))?;
281 }
282 sim.mouse().down(button)
283 }
284 PointerAction::Up => sim.mouse().up(button),
285 PointerAction::Scroll => sim.mouse().scroll(
286 point.expect("validated: scroll requires a target"),
287 ScrollDelta {
288 dx: step.dx.unwrap_or(0),
289 dy: step.dy.unwrap_or(0),
290 },
291 ),
292 PointerAction::Drag => {
293 let from = point.expect("validated: drag requires a start target");
294 let to = drag_end_point(step, tool)?;
295 drag_to = Some(to);
296 sim.mouse().drag_with(
297 from,
298 to,
299 DragOptions::new()
300 .button(button)
301 .held(held)
302 .duration(Duration::from_millis(
303 step.duration_ms.unwrap_or(DEFAULT_DRAG_MS),
304 )),
305 )
306 }
307 };
308 result.map_err(|e| super::errors::render(tool, &action_name(action).to_lowercase(), &e))?;
309
310 Ok(match action {
314 PointerAction::Click | PointerAction::DoubleClick | PointerAction::RightClick => {
315 let verb = match action {
316 PointerAction::Click => "clicked",
317 PointerAction::DoubleClick => "double-clicked",
318 _ => "right-clicked",
319 };
320 let extra = match action {
321 PointerAction::Click if count > 1 => format!(" x{count}"),
322 _ => String::new(),
323 };
324 format!("{verb} {}{extra}", where_at(step, point))
325 }
326 PointerAction::Move => format!("moved to {}", where_at(step, point)),
327 PointerAction::Down => format!("button down {}", where_at(step, point)),
328 PointerAction::Up => format!(
329 "released {}",
330 step.button
331 .as_deref()
332 .map(str::trim)
333 .filter(|s| !s.is_empty())
334 .unwrap_or("left")
335 ),
336 PointerAction::Scroll => format!(
337 "scrolled ({},{}) {}",
338 step.dx.unwrap_or(0),
339 step.dy.unwrap_or(0),
340 where_at(step, point)
341 ),
342 PointerAction::Drag => {
343 let to = drag_to.expect("drag arm resolved its end point before dispatch");
347 format!("dragged {} → ({},{})", where_at(step, point), to.x, to.y)
348 }
349 })
350}
351
352fn where_at(step: &ComputerControl, point: Option<Point>) -> String {
355 match (step.selector.as_deref(), point) {
356 (Some(sel), Some(p)) => format!("`{sel}` @{},{}", p.x, p.y),
357 (Some(sel), None) => format!("`{sel}`"),
358 (None, Some(p)) => format!("@{},{}", p.x, p.y),
359 (None, None) => "at the current position".into(),
360 }
361}
362
363fn resolve_element(
369 step: &ComputerControl,
370 selector: &str,
371 nth: usize,
372 tool: &str,
373) -> Result<xa11y::Element, String> {
374 let timeout = Duration::from_millis(DEFAULT_TIMEOUT_MS);
375 if let Some(surface_kind) = step.surface {
376 super::surface::resolve(surface_kind, timeout)
377 .map_err(|e| format!("{tool}: {e}"))?
378 .locator(selector)
379 .nth(nth)
380 .element()
381 .map_err(|e| super::errors::render(tool, "resolve element", &e))
382 } else {
383 resolve_app(step, tool)?
384 .locator(selector)
385 .nth(nth)
386 .element()
387 .map_err(|e| super::errors::render(tool, "resolve element", &e))
388 }
389}
390
391fn drag_end_point(step: &ComputerControl, tool: &str) -> Result<Point, String> {
395 if let Some(to_selector) = &step.to_selector {
396 let element = resolve_element(step, to_selector, 1, tool)?;
399 xa11y::point_for(&element, anchor(step.anchor.unwrap_or_default()))
400 .map_err(|e| super::errors::render(tool, "resolve drag end point", &e))
401 } else {
402 Ok(Point::new(
403 step.x2.expect("validated: drag end coordinates"),
404 step.y2.expect("validated: drag end coordinates"),
405 ))
406 }
407}
408
409fn point_target(point: Option<Point>) -> ClickTarget<'static> {
412 ClickTarget::Point(point.unwrap_or(Point::new(0, 0)))
413}
414
415fn resolve_app(step: &ComputerControl, tool: &str) -> Result<App, String> {
417 let timeout = Duration::from_millis(DEFAULT_TIMEOUT_MS);
418 let name = step.app.as_deref().map(str::trim).filter(|s| !s.is_empty());
419 match (name, step.pid) {
420 (Some(name), None) => {
421 App::by_name(name, timeout).map_err(|e| super::errors::render_app_miss(tool, &e))
422 }
423 (None, Some(pid)) => {
424 App::by_pid(pid, timeout).map_err(|e| super::errors::render_app_miss(tool, &e))
425 }
426 (None, None) => Err(format!("{tool}: the element form requires `app` or `pid`")),
427 (Some(_), Some(_)) => Err(format!("{tool}: provide `app` or `pid`, not both")),
428 }
429}
430
431pub fn parse_button(name: Option<&str>, tool: &str) -> Result<MouseButton, String> {
433 match name.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
434 None | Some("left") | Some("") => Ok(MouseButton::Left),
435 Some("right") => Ok(MouseButton::Right),
436 Some("middle") => Ok(MouseButton::Middle),
437 Some(other) => Err(format!(
438 "{tool}: unknown button `{other}` (expected left, right or middle)"
439 )),
440 }
441}
442
443pub fn anchor(value: PointerAnchor) -> Anchor {
445 match value {
446 PointerAnchor::Center => Anchor::Center,
447 PointerAnchor::TopLeft => Anchor::TopLeft,
448 PointerAnchor::TopRight => Anchor::TopRight,
449 PointerAnchor::BottomLeft => Anchor::BottomLeft,
450 PointerAnchor::BottomRight => Anchor::BottomRight,
451 }
452}
453
454pub fn action_name(action: PointerAction) -> &'static str {
456 match action {
457 PointerAction::Click => "click",
458 PointerAction::DoubleClick => "double_click",
459 PointerAction::RightClick => "right_click",
460 PointerAction::Move => "move",
461 PointerAction::Down => "down",
462 PointerAction::Up => "up",
463 PointerAction::Scroll => "scroll",
464 PointerAction::Drag => "drag",
465 }
466}