use actl_core::keys::KeySpec;
use actl_core::target::Target;
use actl_core::{CtlError, ErrorCode};
use uiautomation::patterns::{
UIExpandCollapsePattern, UIInvokePattern, UISelectionItemPattern, UITogglePattern,
UIValuePattern,
};
use uiautomation::{UIAutomation, UIElement, UITreeWalker};
use crate::locate::{Located, locate};
use crate::read::set_clipboard_text;
use crate::window::find_window;
use crate::{input, internal, kbd, timing};
pub struct InputResult {
pub window_title: String,
pub role: String,
pub name: Option<String>,
}
impl From<&Located> for InputResult {
fn from(loc: &Located) -> Self {
Self {
window_title: loc.window_title.clone(),
role: loc.role.clone(),
name: loc.name.clone(),
}
}
}
pub fn click(
app: Option<&str>,
target: &Target,
near: Option<&str>,
) -> Result<(Located, &'static str), CtlError> {
let loc = locate(app, target, near)?;
let _t = actl_core::trace::scope("click.pattern");
if let Ok(invoke) = loc.element.get_pattern::<UIInvokePattern>() {
invoke.invoke().map_err(internal)?;
return Ok((loc, "invoke"));
}
if let Ok(sel) = loc.element.get_pattern::<UISelectionItemPattern>() {
sel.select().map_err(internal)?;
return Ok((loc, "selection-item"));
}
if let Ok(toggle) = loc.element.get_pattern::<UITogglePattern>() {
toggle.toggle().map_err(internal)?;
return Ok((loc, "toggle"));
}
if let Ok(exp) = loc.element.get_pattern::<UIExpandCollapsePattern>() {
exp.expand().map_err(internal)?;
return Ok((loc, "expand-collapse"));
}
Err(CtlError::new(
ErrorCode::NotActionable,
format!(
"{} ({}) exposes none of Invoke/SelectionItem/Toggle/ExpandCollapse; \
scroll/focus first, try `set-value`, or `--physical` for a real click",
target.describe(),
loc.role
),
))
}
pub fn type_text(
app: Option<&str>,
target: &Target,
text: &str,
paste: bool,
near: Option<&str>,
) -> Result<TypeOutcome, CtlError> {
let loc = locate(app, target, near)?;
loc.element.set_focus().map_err(|_| {
CtlError::new(
ErrorCode::NotActionable,
format!(
"{} ({}) cannot take keyboard focus",
target.describe(),
loc.role
),
)
})?;
if let Some(pattern) = app {
std::thread::sleep(std::time::Duration::from_millis(timing().type_focus_ms));
let fg = kbd::foreground_title().unwrap_or_default();
if !fg.contains(pattern) {
return Err(CtlError::new(
ErrorCode::PermDenied,
format!(
"text injection ABORTED by verify-then-inject guard: \
physical foreground is {fg:?}, expected a window matching {pattern:?}; \
no text was sent"
),
));
}
}
let _lock = input::InputLock::acquire(timing().lock_wait_ms)?;
let total = text.chars().count();
let outcome = |delivered: usize, stopped_early: bool| TypeOutcome {
located: (&loc).into(),
delivered,
total,
stopped_early,
};
if paste {
set_clipboard_text(text)?;
kbd::send_key_spec(&KeySpec {
modifiers: vec!["ctrl".into()],
keys: vec![actl_core::keys::Key::Char('v')],
})?;
Ok(outcome(total, false))
} else {
let chars: Vec<char> = text.chars().collect();
let pattern = app.map(str::to_string);
let delivered = input::send_in_segments(
&chars,
timing().segment_chars,
|seg| {
kbd::send_key_spec(&KeySpec {
modifiers: Vec::new(),
keys: seg.iter().map(|c| actl_core::keys::Key::Char(*c)).collect(),
})
},
|| {
pattern
.as_ref()
.map(|p| {
kbd::foreground_title()
.map(|t| t.contains(p.as_str()))
.unwrap_or(false)
})
.unwrap_or(false)
},
)?;
Ok(outcome(delivered, delivered < total))
}
}
pub struct TypeOutcome {
pub located: InputResult,
pub delivered: usize,
pub total: usize,
pub stopped_early: bool,
}
pub fn press_keys(spec: &KeySpec, app: Option<&str>) -> Result<Option<String>, CtlError> {
let focused = prepare_keyboard(app)?;
let _lock = input::InputLock::acquire(timing().lock_wait_ms)?;
kbd::send_key_spec(spec)?;
Ok(focused)
}
pub fn press_half(spec: &KeySpec, app: Option<&str>) -> Result<Option<String>, CtlError> {
let focused = prepare_keyboard(app)?;
let _lock = input::InputLock::acquire(timing().lock_wait_ms)?;
kbd::send_key_partial(spec, true)?;
Ok(focused)
}
fn prepare_keyboard(app: Option<&str>) -> Result<Option<String>, CtlError> {
Ok(match app {
Some(pattern) => {
let title = crate::window::focus_window(pattern)?;
std::thread::sleep(std::time::Duration::from_millis(timing().press_focus_ms));
ensure_element_focus(pattern)?;
let fg = kbd::foreground_title().unwrap_or_default();
if !fg.contains(pattern) {
return Err(CtlError::new(
ErrorCode::PermDenied,
format!(
"keyboard injection ABORTED by verify-then-inject guard: \
physical foreground is {fg:?}, expected a window matching {pattern:?}; \
no keys were sent"
),
));
}
Some(title)
}
None => None,
})
}
fn ensure_element_focus(pattern: &str) -> Result<(), CtlError> {
for _ in 0..timing().focus_retries {
if let Some(edit) = find_editable_element(pattern) {
let _ = edit.set_focus();
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
if kbd::focused_root_title()
.map(|t| t.contains(pattern))
.unwrap_or(false)
{
return Ok(());
}
}
Ok(())
}
fn find_editable_element(pattern: &str) -> Option<UIElement> {
let auto = UIAutomation::new().ok()?;
let walker = auto.create_tree_walker().ok()?;
let win = find_window(&auto, &walker, pattern).ok()?;
fn dfs(walker: &UITreeWalker, elem: &UIElement, depth: u32) -> Option<UIElement> {
if depth > 12 {
return None;
}
let role = format!("{:?}", elem.get_control_type().ok()?);
if role == "Document" || role == "Edit" {
return Some(elem.clone());
}
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
if let Some(hit) = dfs(walker, &c, depth + 1) {
return Some(hit);
}
child = walker.get_next_sibling(&c).ok();
}
None
}
dfs(&walker, &win, 0)
}
pub fn set_value(
app: Option<&str>,
target: &Target,
value: &str,
near: Option<&str>,
) -> Result<InputResult, CtlError> {
let loc = locate(app, target, near)?;
let pattern = loc.element.get_pattern::<UIValuePattern>().map_err(|_| {
CtlError::new(
ErrorCode::NotActionable,
format!(
"{} ({}) exposes no Value pattern; try `type` (keyboard path) instead",
target.describe(),
loc.role
),
)
})?;
pattern.set_value(value).map_err(internal)?;
Ok((&loc).into())
}