Skip to main content

cosh_tools/computer/
touch.rs

1//! Semantic (accessibility-action) component — validation and dispatch of
2//! element-action steps.
3//!
4//! This is a COMPONENT, not a tool: it knows how to validate and execute
5//! ONE semantic action (`press`, `toggle`, `set_value`, …) on the element
6//! matched by `selector` in a target app, auto-waiting for the element to
7//! become visible and enabled, and nothing else. The pipeline tools
8//! (`computer_act`) compose it with the keyboard ([`super::keyboard`]) and
9//! wait engines; a future tool that needs semantic element actions can
10//! reuse it without carrying the pipelines along.
11//!
12//! Exception kept from the former `computer_touch`: `scroll_into_view`
13//! waits only for the element to EXIST — its purpose is to bring a
14//! not-yet-visible element into view, so visibility would deadlock it.
15use std::time::Duration;
16
17use xa11y::{App, AppExt};
18
19use super::snapshot::DEFAULT_TIMEOUT_MS;
20use super::types::{ActAction, ComputerAct};
21
22/// Semantic-step validation: an element target (`selector` + exactly one
23/// app scope) and the action's payload.
24///
25/// `tool` names the calling pipeline in the error messages, so a future
26/// consumer reports its own name instead of a hardcoded one.
27///
28/// # Errors
29///
30/// Returns `Err` for a missing/blank `selector`, `name` and `pid`
31/// together, no app scope at all, a 0-based `nth`, or a missing payload —
32/// `set_value`/`type_text`/`perform_action` need `value`,
33/// `set_numeric_value` needs `numeric_value`, `select_text` needs a
34/// well-formed `range` (exactly two numbers, start <= end).
35pub fn validate(step: &ComputerAct, tool: &str) -> Result<(), String> {
36    if step
37        .selector
38        .as_deref()
39        .map(str::trim)
40        .filter(|s| !s.is_empty())
41        .is_none()
42    {
43        return Err(format!(
44            "{tool}: a semantic action requires `selector` (the element to act on); \
45             use `key`/`text` for typing, or `wait` to pause"
46        ));
47    }
48    super::surface::validate_act(step)?;
49    if step.nth == Some(0) {
50        return Err(format!(
51            "{tool}: `nth` is 1-based; use 1 for the first match"
52        ));
53    }
54    if step.held.is_some() {
55        return Err(format!(
56            "{tool}: `held` applies to keyboard and pointer steps — a semantic action \
57             carries no chord; chain a keyboard step via `then` if you need one"
58        ));
59    }
60
61    // Payload validation BEFORE dispatching, so a malformed call fails fast
62    // instead of auto-waiting for an element that was never the problem.
63    match step.action.unwrap_or_default() {
64        ActAction::SetValue | ActAction::TypeText | ActAction::PerformAction
65            if step.value.is_none() =>
66        {
67            return Err(format!(
68                "{tool}: action `{}` requires `value`",
69                action_name(step.action.unwrap_or_default())
70            ));
71        }
72        ActAction::SetNumericValue if step.numeric_value.is_none() => {
73            return Err(format!(
74                "{tool}: action `set_numeric_value` requires `numeric_value`"
75            ));
76        }
77        ActAction::SelectText => {
78            let Some(range) = &step.range else {
79                return Err(format!(
80                    "{tool}: action `select_text` requires `range` [start, end]"
81                ));
82            };
83            if range.len() != 2 {
84                return Err(format!(
85                    "{tool}: `range` takes exactly 2 numbers [start, end], got {}",
86                    range.len()
87                ));
88            }
89            if range[0] > range[1] {
90                return Err(format!(
91                    "{tool}: `range` start ({}) must be <= end ({})",
92                    range[0], range[1]
93                ));
94            }
95        }
96        _ => {}
97    }
98    Ok(())
99}
100
101/// Execute ONE validated semantic action: resolve the target (app or shell
102/// surface), match the selector (auto-waiting for visible + enabled),
103/// invoke the action. Returns the step's report fragment.
104pub fn run_step(step: &ComputerAct, tool: &str) -> Result<String, String> {
105    let timeout = Duration::from_millis(step.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
106    let selector = step
107        .selector
108        .as_deref()
109        .expect("validated: semantic step carries a selector");
110    let nth = step.nth.unwrap_or(1);
111    let action = step.action.unwrap_or_default();
112
113    // Shell-surface targeting: same selector flow, rooted at the surface
114    // (validated: exactly one of name/pid/surface).
115    if let Some(surface_kind) = step.surface {
116        let surface =
117            super::surface::resolve(surface_kind, timeout).map_err(|e| format!("{tool}: {e}"))?;
118        return run_step_on_surface(step, surface, timeout, tool);
119    }
120    let name = step
121        .name
122        .as_deref()
123        .map(str::trim)
124        .filter(|s| !s.is_empty());
125    let app = match (name, step.pid) {
126        (Some(name), None) => App::by_name(name, timeout),
127        (None, Some(pid)) => App::by_pid(pid, timeout),
128        _ => unreachable!("validated: semantic step carries exactly one app scope"),
129    }
130    .map_err(|e| super::errors::render_app_miss(tool, &e))?;
131    let locator = app.locator(selector.trim()).nth(nth).with_timeout(timeout);
132    dispatch_action(&locator, action, step, app.name.clone(), tool)
133}
134
135/// Run a validated semantic step against an ALREADY-RESOLVED surface —
136/// shared by the production path (singleton provider) and the tests (mock
137/// provider handle built via `ShellSurface::by_kind_with`).
138pub(crate) fn run_step_on_surface(
139    step: &ComputerAct,
140    surface: xa11y::ShellSurface,
141    timeout: Duration,
142    tool: &str,
143) -> Result<String, String> {
144    let selector = step
145        .selector
146        .as_deref()
147        .expect("validated: semantic step carries a selector");
148    let nth = step.nth.unwrap_or(1);
149    let action = step.action.unwrap_or_default();
150    let locator = surface
151        .locator(selector.trim())
152        .nth(nth)
153        .with_timeout(timeout);
154    dispatch_action(&locator, action, step, surface.name.clone(), tool)
155}
156
157/// Invoke the action on an already-built locator and format the report
158/// fragment — shared by the app and surface paths.
159fn dispatch_action(
160    locator: &xa11y::Locator,
161    action: ActAction,
162    step: &ComputerAct,
163    target_name: String,
164    tool: &str,
165) -> Result<String, String> {
166    let selector = step
167        .selector
168        .as_deref()
169        .expect("validated: semantic step carries a selector");
170    let result = match action {
171        ActAction::Press => locator.press(),
172        ActAction::Focus => locator.focus(),
173        ActAction::Blur => locator.blur(),
174        ActAction::Toggle => locator.toggle(),
175        ActAction::Select => locator.select(),
176        ActAction::Expand => locator.expand(),
177        ActAction::Collapse => locator.collapse(),
178        ActAction::ShowMenu => locator.show_menu(),
179        ActAction::Increment => locator.increment(),
180        ActAction::Decrement => locator.decrement(),
181        ActAction::ScrollIntoView => locator.scroll_into_view(),
182        ActAction::SetValue => {
183            let value = step.value.as_deref().unwrap_or_default();
184            // A slider/spinner rejects text input (no ValuePattern), but the
185            // model's value may still be numeric — retry through the
186            // RangeValue path `set_numeric_value` uses before giving up.
187            match locator.set_value(value) {
188                Err(xa11y::Error::TextValueNotSupported) => match value.trim().parse::<f64>() {
189                    Ok(numeric) => locator.set_numeric_value(numeric),
190                    // Not numeric: keep the original text-input rejection.
191                    Err(_) => Err(xa11y::Error::TextValueNotSupported),
192                },
193                result => result,
194            }
195        }
196        ActAction::SetNumericValue => {
197            locator.set_numeric_value(step.numeric_value.unwrap_or_default())
198        }
199        ActAction::SelectText => {
200            let range = step.range.as_deref().unwrap_or_default();
201            locator.select_text(
202                range.first().copied().unwrap_or(0),
203                range.get(1).copied().unwrap_or(0),
204            )
205        }
206        ActAction::TypeText => locator.type_text(step.value.as_deref().unwrap_or_default()),
207        ActAction::PerformAction => {
208            locator.perform_action(step.value.as_deref().unwrap_or_default())
209        }
210    };
211    result.map_err(|e| super::errors::render(tool, &action_name(action).to_lowercase(), &e))?;
212
213    Ok(format!(
214        "{} `{}` ({})",
215        action_name(action),
216        selector,
217        target_name
218    ))
219}
220
221/// Wire-facing name of an action (matches the `snake_case` serde renaming).
222pub fn action_name(action: ActAction) -> &'static str {
223    match action {
224        ActAction::Press => "press",
225        ActAction::Focus => "focus",
226        ActAction::Blur => "blur",
227        ActAction::Toggle => "toggle",
228        ActAction::Select => "select",
229        ActAction::Expand => "expand",
230        ActAction::Collapse => "collapse",
231        ActAction::ShowMenu => "show_menu",
232        ActAction::Increment => "increment",
233        ActAction::Decrement => "decrement",
234        ActAction::ScrollIntoView => "scroll_into_view",
235        ActAction::SetValue => "set_value",
236        ActAction::SetNumericValue => "set_numeric_value",
237        ActAction::SelectText => "select_text",
238        ActAction::TypeText => "type_text",
239        ActAction::PerformAction => "perform_action",
240    }
241}