use std::time::{Duration, Instant};
use teksilo_canvas::{Point, Rect};
use teksilo_core::WidgetTree;
use teksilo_core::accesskit;
use teksilo_core::event::{Key, Modifiers, ScrollDelta, WidgetEvent};
use teksilo_core::widget_id::WidgetId;
use teksilo_core::window::WindowOps;
use crate::dto::{
AnnouncementDto, Assertion, AssertionResult, AutomationOp, AutomationReply, NodeBounds,
NodeRef, SemanticNode, SettleSpec, ShortcutInfo, WaitCondition, codes,
};
pub fn execute(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
op: &AutomationOp,
settle: &SettleSpec,
) -> AutomationReply {
match op {
AutomationOp::SnapshotTree { max_depth } => {
let update = tree.sync_accessibility();
AutomationReply::ok(snapshot_json(&update, *max_depth))
}
AutomationOp::ReadNode { node } => {
let update = tree.sync_accessibility();
match find_node(&update, *node) {
Some(sn) => AutomationReply::ok_json(&sn),
None => AutomationReply::err(codes::NOT_FOUND, format!("no node {node}")),
}
}
AutomationOp::LayoutTree {
max_depth,
include_debug,
} => AutomationReply::ok(layout_tree_json(tree, *max_depth, *include_debug)),
AutomationOp::InspectNode { node } => {
let nid = accesskit::NodeId(*node);
if teksilo_core::accessibility::is_synthetic(nid) {
return AutomationReply::err(
codes::NOT_FOUND,
"synthetic node has no backing widget — use read_node for its AT detail",
);
}
let widget = teksilo_core::accessibility::node_id_to_widget_id_maybe(nid)
.filter(|w| tree.widget_type_name(*w).is_some());
match widget {
Some(w) => AutomationReply::ok_json(&layout_node(tree, w, true)),
None => {
AutomationReply::err(codes::NOT_FOUND, format!("no widget for node {node}"))
}
}
}
AutomationOp::FindNode { role, label } => {
let update = tree.sync_accessibility();
let found = find_node_ref(&update, role.as_deref(), label.as_deref());
AutomationReply::ok(serde_json::json!({ "node": found }))
}
AutomationOp::AssertNode { node, assertion } => {
let update = tree.sync_accessibility();
match evaluate_assertion(&update, *node, assertion) {
Ok(result) => AutomationReply::ok_json(&result),
Err(reply) => reply,
}
}
AutomationOp::ListWindows => AutomationReply::err(
codes::HOST_REQUIRED,
"list_windows is served by the host (window manager / headless shim)",
),
AutomationOp::InvokeAction { node, action } => {
let Some(act) = action_from_str(action) else {
return AutomationReply::err(
codes::UNKNOWN_NAME,
format!("unknown action '{action}'"),
);
};
dispatch_action_and_settle(tree, ops, settle, *node, act, None)
}
AutomationOp::FocusNode { node } => {
dispatch_action_and_settle(tree, ops, settle, *node, accesskit::Action::Focus, None)
}
AutomationOp::SetValue { node, value } => dispatch_action_and_settle(
tree,
ops,
settle,
*node,
accesskit::Action::SetValue,
Some(accesskit::ActionData::Value(value.clone().into_boxed_str())),
),
AutomationOp::Expand { node } => {
dispatch_action_and_settle(tree, ops, settle, *node, accesskit::Action::Expand, None)
}
AutomationOp::Collapse { node } => {
dispatch_action_and_settle(tree, ops, settle, *node, accesskit::Action::Collapse, None)
}
AutomationOp::Scroll {
node,
dx,
dy,
ctrl,
shift,
alt,
meta,
command,
} => {
let update = tree.sync_accessibility();
let Some(widget) = resolve_widget(tree, &update, *node) else {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
};
let c = center(tree.bounds(widget));
pointer_move(tree, ops, c);
tree.dispatch_event_with_ops(
WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: *dx, y: *dy },
modifiers: modifiers(*ctrl, *shift, *alt, *meta, *command),
},
ops,
);
finish_settle(tree, ops, settle)
}
AutomationOp::InjectPointer {
x,
y,
action,
button,
ctrl,
shift,
alt,
meta,
command,
} => {
use crate::dto::PointerAction as PA;
let p = Point::new(*x, *y);
let btn = button.to_core();
let m = modifiers(*ctrl, *shift, *alt, *meta, *command);
match action {
PA::Move => pointer_move(tree, ops, p),
PA::Down => pointer_down(tree, ops, p, btn, m),
PA::Up => pointer_up(tree, ops, p, btn, m),
PA::Click => {
pointer_down(tree, ops, p, btn, m);
pointer_up(tree, ops, p, btn, m);
}
PA::DoubleClick => {
pointer_down(tree, ops, p, btn, m);
pointer_up(tree, ops, p, btn, m);
pointer_down(tree, ops, p, btn, m);
pointer_up(tree, ops, p, btn, m);
}
}
finish_settle(tree, ops, settle)
}
AutomationOp::RightClick { node } => {
let update = tree.sync_accessibility();
let Some(p) = node_point(tree, &update, *node) else {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
};
pointer_down(
tree,
ops,
p,
teksilo_core::PointerButton::Secondary,
Modifiers::NONE,
);
pointer_up(
tree,
ops,
p,
teksilo_core::PointerButton::Secondary,
Modifiers::NONE,
);
finish_settle(tree, ops, settle)
}
AutomationOp::InjectKey {
key,
ctrl,
shift,
alt,
meta,
command,
} => {
let Some(k) = key_from_str(key) else {
return AutomationReply::err(codes::UNKNOWN_NAME, format!("unknown key '{key}'"));
};
press_key(
tree,
ops,
k,
modifiers(*ctrl, *shift, *alt, *meta, *command),
);
finish_settle(tree, ops, settle)
}
AutomationOp::TypeText { node, text } => {
let update = tree.sync_accessibility();
let Some(widget) = resolve_widget(tree, &update, *node) else {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
};
tree.focus_ops(widget, ops);
type_text(tree, ops, text);
finish_settle(tree, ops, settle)
}
AutomationOp::TypeIme {
node,
preedit,
commit,
} => {
let update = tree.sync_accessibility();
let Some(widget) = resolve_widget(tree, &update, *node) else {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
};
tree.focus_ops(widget, ops);
if let Some(text) = preedit {
tree.dispatch_event_with_ops(
WidgetEvent::ImeComposition {
text: text.clone(),
cursor: None,
},
ops,
);
}
if let Some(text) = commit {
tree.dispatch_event_with_ops(WidgetEvent::ImeCommit { text: text.clone() }, ops);
}
finish_settle(tree, ops, settle)
}
AutomationOp::DragNode {
node,
to_node,
to_x,
to_y,
} => {
let update = tree.sync_accessibility();
let Some(from) = node_point(tree, &update, *node) else {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
};
let to = if let Some(tn) = to_node {
match node_point(tree, &update, *tn) {
Some(p) => p,
None => {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {tn}"));
}
}
} else if let (Some(x), Some(y)) = (to_x, to_y) {
Point::new(*x, *y)
} else {
return AutomationReply::err(
codes::BAD_ARGUMENT,
"drag_node needs to_node or (to_x, to_y)",
);
};
drag(tree, ops, from, to);
finish_settle(tree, ops, settle)
}
AutomationOp::GetOverlays => {
let overlays = tree.active_overlays();
let ids: Vec<String> = overlays.iter().map(|o| format!("{o:?}")).collect();
AutomationReply::ok(serde_json::json!({ "count": overlays.len(), "ids": ids }))
}
AutomationOp::GetShortcuts => {
let list: Vec<ShortcutInfo> = tree
.shortcut_registry()
.iter_effective()
.map(|eff| ShortcutInfo {
id: eff.shortcut.id.to_string(),
name: Some(eff.shortcut.name.get()).filter(|n| !n.is_empty()),
primary: eff.primary.map(format_keystroke),
secondary: eff.secondary.map(format_keystroke),
enabled: eff.enabled,
})
.collect();
AutomationReply::ok_json(&list)
}
AutomationOp::ListLiveRegions => {
let update = tree.sync_accessibility();
let focus = update.focus;
let regions: Vec<SemanticNode> = update
.nodes
.iter()
.filter(|(_, n)| {
matches!(
n.live(),
Some(accesskit::Live::Polite) | Some(accesskit::Live::Assertive)
)
})
.map(|(id, n)| semantic_node(*id, n, focus))
.collect();
AutomationReply::ok_json(®ions)
}
AutomationOp::PullAnnouncements { since_seq } => {
tree.sync_accessibility();
let list: Vec<AnnouncementDto> = tree
.announcements_since(*since_seq)
.into_iter()
.map(AnnouncementDto::from)
.collect();
AutomationReply::ok_json(&list)
}
AutomationOp::AdvanceClock { millis } => {
tree.advance_time(Duration::from_millis(*millis));
tree.sync_accessibility();
AutomationReply::ok_unit()
}
AutomationOp::Settle => finish_settle(tree, ops, settle),
AutomationOp::WaitForCondition { condition } => {
wait_for_condition(tree, ops, settle, condition)
}
AutomationOp::Screenshot { .. } => AutomationReply::err(
codes::HOST_REQUIRED,
"screenshot pixels are produced by the host (offscreen renderer / platform window)",
),
}
}
fn pointer_move(tree: &mut WidgetTree, ops: &mut dyn WindowOps, position: Point) {
tree.dispatch_event_with_ops(WidgetEvent::PointerMove { position }, ops);
}
fn pointer_down(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
position: Point,
button: teksilo_core::PointerButton,
modifiers: Modifiers,
) {
tree.dispatch_event_with_ops(
WidgetEvent::PointerDown {
position,
button,
modifiers,
},
ops,
);
}
fn pointer_up(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
position: Point,
button: teksilo_core::PointerButton,
modifiers: Modifiers,
) {
tree.dispatch_event_with_ops(
WidgetEvent::PointerUp {
position,
button,
modifiers,
},
ops,
);
}
fn press_key(tree: &mut WidgetTree, ops: &mut dyn WindowOps, key: Key, modifiers: Modifiers) {
tree.dispatch_event_with_ops(
WidgetEvent::KeyDown {
key,
modifiers,
text: key.to_text().map(str::to_string),
},
ops,
);
tree.dispatch_event_with_ops(WidgetEvent::KeyUp { key, modifiers }, ops);
}
fn type_text(tree: &mut WidgetTree, ops: &mut dyn WindowOps, text: &str) {
for ch in text.chars() {
tree.dispatch_event_with_ops(
WidgetEvent::KeyDown {
key: Key::Character(ch),
modifiers: Modifiers::NONE,
text: Some(ch.to_string()),
},
ops,
);
}
}
fn drag(tree: &mut WidgetTree, ops: &mut dyn WindowOps, from: Point, to: Point) {
pointer_down(
tree,
ops,
from,
teksilo_core::PointerButton::Primary,
Modifiers::NONE,
);
pointer_move(tree, ops, to);
pointer_up(
tree,
ops,
to,
teksilo_core::PointerButton::Primary,
Modifiers::NONE,
);
}
pub fn run_settle(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
settle: &SettleSpec,
) -> Option<&'static str> {
let deadline = Instant::now() + Duration::from_millis(settle.settle_timeout_ms.max(1));
if settle.clock_millis > 0 {
tree.advance_time(Duration::from_millis(settle.clock_millis));
}
let mut frames = 0u32;
let mut timed_out = false;
while tree.has_active_animations() && frames < settle.max_anim_frames {
tree.tick_animations(Duration::from_millis(16));
frames += 1;
if Instant::now() >= deadline {
timed_out = true;
break;
}
}
if settle.layout_after {
let proposal = tree.last_proposal();
tree.layout_with_ops(proposal, ops);
}
tree.sync_accessibility();
timed_out.then_some(codes::SETTLE_TIMEOUT)
}
fn finish_settle(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
settle: &SettleSpec,
) -> AutomationReply {
match run_settle(tree, ops, settle) {
Some(code) => AutomationReply::err(code, "settle exceeded its time budget"),
None => AutomationReply::ok_unit(),
}
}
fn dispatch_action_and_settle(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
settle: &SettleSpec,
node: NodeRef,
action: accesskit::Action,
data: Option<accesskit::ActionData>,
) -> AutomationReply {
let update = tree.sync_accessibility();
if !node_present(&update, node) {
return AutomationReply::err(codes::NOT_FOUND, format!("no node {node}"));
}
let handled = tree.dispatch_access_action(accesskit::NodeId(node), action, data, ops);
let reply = finish_settle(tree, ops, settle);
if handled || !reply.is_ok() {
return reply;
}
let advertised = find_node(&update, node)
.map(|sn| sn.actions.join(", "))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "none".to_string());
AutomationReply::err(
codes::UNHANDLED_ACTION,
format!(
"node {node} did not handle '{}'; it advertises: {advertised}",
action_name(action).unwrap_or("?")
),
)
}
const WAIT_FRAME: Duration = Duration::from_millis(16);
fn wait_for_condition(
tree: &mut WidgetTree,
ops: &mut dyn WindowOps,
settle: &SettleSpec,
condition: &WaitCondition,
) -> AutomationReply {
let budget_ms = settle.settle_timeout_ms.max(1);
let frame_ms = WAIT_FRAME.as_millis() as u64;
let max_frames = budget_ms.div_ceil(frame_ms);
let backstop =
Instant::now() + Duration::from_millis(budget_ms).max(Duration::from_millis(250));
for _ in 0..=max_frames {
let update = tree.sync_accessibility();
if condition_met(tree, &update, condition) {
return AutomationReply::ok_unit();
}
if Instant::now() >= backstop {
break;
}
tree.advance_time(WAIT_FRAME);
tree.tick_animations(WAIT_FRAME);
let proposal = tree.last_proposal();
tree.layout_with_ops(proposal, ops);
}
AutomationReply::err(
codes::WAIT_TIMEOUT,
"wait_for_condition timed out before the predicate held",
)
}
fn condition_met(
tree: &WidgetTree,
update: &accesskit::TreeUpdate,
condition: &WaitCondition,
) -> bool {
match condition {
WaitCondition::NodeExists { role, label } => {
find_node_ref(update, role.as_deref(), label.as_deref()).is_some()
}
WaitCondition::NodeValue { node, expected } => update
.nodes
.iter()
.find(|(id, _)| id.0 == *node)
.map(|(_, n)| n.value() == Some(expected.as_str()))
.unwrap_or(false),
WaitCondition::NodeGone { node } => !update.nodes.iter().any(|(id, _)| id.0 == *node),
WaitCondition::AtVersionAtLeast { version } => tree.at_version().get() >= *version,
}
}
fn node_present(update: &accesskit::TreeUpdate, node: NodeRef) -> bool {
update.nodes.iter().any(|(id, _)| id.0 == node)
}
fn resolve_widget(
tree: &WidgetTree,
update: &accesskit::TreeUpdate,
node: NodeRef,
) -> Option<WidgetId> {
if !node_present(update, node) {
return None;
}
let nid = accesskit::NodeId(node);
teksilo_core::accessibility::node_id_to_widget_id_maybe(nid)
.or_else(|| tree.widget_for_synthetic(nid))
}
fn center(r: Rect) -> Point {
Point::new(r.x + r.width * 0.5, r.y + r.height * 0.5)
}
fn node_point(tree: &WidgetTree, update: &accesskit::TreeUpdate, node: NodeRef) -> Option<Point> {
if let Some((_, n)) = update.nodes.iter().find(|(id, _)| id.0 == node)
&& let Some(r) = n.bounds()
{
return Some(Point::new(
((r.x0 + r.x1) * 0.5) as f32,
((r.y0 + r.y1) * 0.5) as f32,
));
}
let widget = resolve_widget(tree, update, node)?;
Some(center(tree.bounds(widget)))
}
fn semantic_node(
id: accesskit::NodeId,
node: &accesskit::Node,
focus: accesskit::NodeId,
) -> SemanticNode {
let toggled = node.toggled().map(|t| {
match t {
accesskit::Toggled::True => "true",
accesskit::Toggled::False => "false",
accesskit::Toggled::Mixed => "mixed",
}
.to_string()
});
let live = match node.live() {
Some(accesskit::Live::Polite) => Some("polite".to_string()),
Some(accesskit::Live::Assertive) => Some("assertive".to_string()),
_ => None,
};
let bounds = node.bounds().map(|r| NodeBounds {
x: r.x0,
y: r.y0,
width: r.x1 - r.x0,
height: r.y1 - r.y0,
});
let actions = ADVERTISABLE_ACTIONS
.iter()
.filter(|(a, _)| node.supports_action(*a))
.map(|(_, name)| name.to_string())
.collect();
SemanticNode {
id: id.0,
role: format!("{:?}", node.role()),
label: node.label().map(|s| s.to_string()),
value: node.value().map(|s| s.to_string()),
description: node.description().map(|s| s.to_string()),
toggled,
expanded: node.is_expanded(),
selected: node.is_selected(),
level: node.level(),
disabled: node.is_disabled(),
focused: id == focus,
live,
numeric_value: node.numeric_value(),
bounds,
actions,
children: node.children().iter().map(|c| c.0).collect(),
}
}
fn layout_node(tree: &WidgetTree, id: WidgetId, include_debug: bool) -> crate::dto::LayoutNode {
let to_ref = |w: WidgetId| teksilo_core::accessibility::widget_id_to_node_id(w).0;
let b = tree.bounds(id);
crate::dto::LayoutNode {
id: to_ref(id),
type_name: tree
.widget_type_name(id)
.map(|s| s.to_string())
.unwrap_or_else(|| "?".to_string()),
bounds: NodeBounds {
x: b.x as f64,
y: b.y as f64,
width: b.width as f64,
height: b.height as f64,
},
active: tree.is_active(id),
clips_children: tree.widget_clips_children(id),
parent: tree.parent(id).map(to_ref),
children: tree.children(id).into_iter().map(to_ref).collect(),
debug: if include_debug {
tree.widget_debug_string(id)
} else {
None
},
}
}
fn layout_tree_json(
tree: &WidgetTree,
max_depth: Option<usize>,
include_debug: bool,
) -> serde_json::Value {
use std::collections::{HashSet, VecDeque};
let to_ref = |w: WidgetId| teksilo_core::accessibility::widget_id_to_node_id(w).0;
let roots = tree.roots();
let mut out: Vec<crate::dto::LayoutNode> = Vec::new();
let mut seen: HashSet<WidgetId> = HashSet::new();
let mut queue: VecDeque<(WidgetId, usize)> = roots.iter().map(|r| (*r, 0usize)).collect();
while let Some((id, depth)) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
let descend = max_depth.map(|d| depth < d).unwrap_or(true);
let mut node = layout_node(tree, id, include_debug);
if !descend {
node.children.clear();
}
out.push(node);
if descend {
for c in tree.children(id) {
queue.push_back((c, depth + 1));
}
}
}
serde_json::json!({
"roots": roots.into_iter().map(to_ref).collect::<Vec<_>>(),
"nodes": out,
})
}
fn find_node(update: &accesskit::TreeUpdate, node: NodeRef) -> Option<SemanticNode> {
let focus = update.focus;
update
.nodes
.iter()
.find(|(id, _)| id.0 == node)
.map(|(id, n)| semantic_node(*id, n, focus))
}
fn find_node_ref(
update: &accesskit::TreeUpdate,
role: Option<&str>,
label: Option<&str>,
) -> Option<NodeRef> {
update
.nodes
.iter()
.find(|(_, n)| {
let role_ok = role
.map(|r| format!("{:?}", n.role()).eq_ignore_ascii_case(r))
.unwrap_or(true);
let label_ok = label.map(|l| n.label() == Some(l)).unwrap_or(true);
role_ok && label_ok
})
.map(|(id, _)| id.0)
}
fn snapshot_json(update: &accesskit::TreeUpdate, max_depth: Option<usize>) -> serde_json::Value {
use std::collections::{HashMap, HashSet, VecDeque};
let focus = update.focus;
let map: HashMap<accesskit::NodeId, &accesskit::Node> =
update.nodes.iter().map(|(id, n)| (*id, n)).collect();
let root = update.tree.as_ref().map(|t| t.root);
let mut out: Vec<SemanticNode> = Vec::new();
match root {
Some(root) => {
let mut seen: HashSet<accesskit::NodeId> = HashSet::new();
let mut queue: VecDeque<(accesskit::NodeId, usize)> = VecDeque::new();
queue.push_back((root, 0));
while let Some((nid, depth)) = queue.pop_front() {
if !seen.insert(nid) {
continue;
}
if let Some(node) = map.get(&nid) {
let descend = max_depth.map(|d| depth < d).unwrap_or(true);
let mut sn = semantic_node(nid, node, focus);
if !descend {
sn.children.clear();
}
out.push(sn);
if descend {
for c in node.children() {
queue.push_back((*c, depth + 1));
}
}
}
}
}
None => {
for (id, n) in &update.nodes {
out.push(semantic_node(*id, n, focus));
}
}
}
serde_json::json!({
"root": root.map(|r| r.0),
"focus": focus.0,
"nodes": out,
})
}
fn evaluate_assertion(
update: &accesskit::TreeUpdate,
node: NodeRef,
assertion: &Assertion,
) -> Result<AssertionResult, AutomationReply> {
let found = update.nodes.iter().find(|(id, _)| id.0 == node);
let pass = |passed: bool, detail: Option<String>| AssertionResult { passed, detail };
let Some((id, n)) = found else {
return Err(if matches!(assertion, Assertion::Exists) {
AutomationReply::err(
codes::ASSERTION_FAILED,
format!("assertion 'exists' failed: node {node} is not in the tree"),
)
} else {
AutomationReply::err(
codes::NOT_FOUND,
format!("node {node} is not in the tree, so nothing could be asserted about it"),
)
});
};
let result = match assertion {
Assertion::Exists => pass(true, None),
Assertion::Focused => {
let ok = id.0 == update.focus.0;
pass(ok, (!ok).then(|| "node is not focused".to_string()))
}
Assertion::RoleEquals { value } => {
let actual = format!("{:?}", n.role());
let ok = actual.eq_ignore_ascii_case(value);
pass(
ok,
(!ok).then(|| format!("role is '{actual}', expected '{value}'")),
)
}
Assertion::LabelEquals { value } => {
let actual = n.label();
let ok = actual == Some(value.as_str());
pass(
ok,
(!ok).then(|| format!("label is {actual:?}, expected '{value}'")),
)
}
Assertion::LabelContains { value } => {
let actual = n.label().unwrap_or("");
let ok = actual.contains(value.as_str());
pass(
ok,
(!ok).then(|| format!("label '{actual}' does not contain '{value}'")),
)
}
Assertion::ValueEquals { value } => {
let actual = n.value();
let ok = actual == Some(value.as_str());
pass(
ok,
(!ok).then(|| format!("value is {actual:?}, expected '{value}'")),
)
}
Assertion::Toggled { value } => {
let state = n.toggled();
let ok = matches!(
(value, state),
(true, Some(accesskit::Toggled::True)) | (false, Some(accesskit::Toggled::False))
);
let actual = match state {
Some(accesskit::Toggled::True) => "true",
Some(accesskit::Toggled::False) => "false",
Some(accesskit::Toggled::Mixed) => "mixed",
None => "none",
};
pass(
ok,
(!ok).then(|| format!("toggled is {actual}, expected {value}")),
)
}
Assertion::Expanded { value } => {
let actual = n.is_expanded().unwrap_or(false);
let ok = actual == *value;
pass(
ok,
(!ok).then(|| format!("expanded is {actual}, expected {value}")),
)
}
Assertion::Selected { value } => {
let actual = n.is_selected().unwrap_or(false);
let ok = actual == *value;
pass(
ok,
(!ok).then(|| format!("selected is {actual}, expected {value}")),
)
}
Assertion::Disabled { value } => {
let actual = n.is_disabled();
let ok = actual == *value;
pass(
ok,
(!ok).then(|| format!("disabled is {actual}, expected {value}")),
)
}
};
if result.passed {
Ok(result)
} else {
Err(AutomationReply::err(
codes::ASSERTION_FAILED,
match result.detail {
Some(detail) => format!("assertion failed on node {node}: {detail}"),
None => format!("assertion failed on node {node}"),
},
))
}
}
const ADVERTISABLE_ACTIONS: &[(accesskit::Action, &str)] = &[
(accesskit::Action::Click, "click"),
(accesskit::Action::Focus, "focus"),
(accesskit::Action::Increment, "increment"),
(accesskit::Action::Decrement, "decrement"),
(accesskit::Action::Expand, "expand"),
(accesskit::Action::Collapse, "collapse"),
(accesskit::Action::SetValue, "set_value"),
(accesskit::Action::ShowContextMenu, "show_context_menu"),
(accesskit::Action::ScrollIntoView, "scroll_into_view"),
(accesskit::Action::ScrollUp, "scroll_up"),
(accesskit::Action::ScrollDown, "scroll_down"),
(accesskit::Action::ScrollLeft, "scroll_left"),
(accesskit::Action::ScrollRight, "scroll_right"),
];
fn action_name(action: accesskit::Action) -> Option<&'static str> {
ADVERTISABLE_ACTIONS
.iter()
.find(|(a, _)| *a == action)
.map(|(_, name)| *name)
}
fn action_from_str(s: &str) -> Option<accesskit::Action> {
use accesskit::Action as A;
let lower = s.to_ascii_lowercase();
Some(match lower.as_str() {
"click" | "default" | "press" | "activate" => A::Click,
"focus" => A::Focus,
"blur" => A::Blur,
"increment" => A::Increment,
"decrement" => A::Decrement,
"expand" => A::Expand,
"collapse" => A::Collapse,
"set_value" => A::SetValue,
"show_context_menu" | "context_menu" => A::ShowContextMenu,
"scroll_into_view" => A::ScrollIntoView,
"scroll_up" => A::ScrollUp,
"scroll_down" => A::ScrollDown,
"scroll_left" => A::ScrollLeft,
"scroll_right" => A::ScrollRight,
"show_tooltip" => A::ShowTooltip,
"hide_tooltip" => A::HideTooltip,
_ => return None,
})
}
fn modifiers(ctrl: bool, shift: bool, alt: bool, meta: bool, command: bool) -> Modifiers {
let mut m = Modifiers::NONE;
if ctrl {
m = m | Modifiers::CTRL;
}
if command {
m = m | Modifiers::COMMAND;
}
if shift {
m = m | Modifiers::SHIFT;
}
if alt {
m = m | Modifiers::ALT;
}
if meta {
m = m | Modifiers::SUPER;
}
m
}
fn format_keystroke(ks: teksilo_core::shortcut::KeyStroke) -> String {
format!("{}{}", ks.modifiers, ks.key)
}
fn key_from_str(s: &str) -> Option<Key> {
let lower = s.to_ascii_lowercase();
let named = match lower.as_str() {
"space" | " " => Some(Key::Space),
"enter" | "return" => Some(Key::Enter),
"escape" | "esc" => Some(Key::Escape),
"tab" => Some(Key::Tab),
"backspace" => Some(Key::Backspace),
"delete" | "del" => Some(Key::Delete),
"up" | "arrowup" => Some(Key::ArrowUp),
"down" | "arrowdown" => Some(Key::ArrowDown),
"left" | "arrowleft" => Some(Key::ArrowLeft),
"right" | "arrowright" => Some(Key::ArrowRight),
"home" => Some(Key::Home),
"end" => Some(Key::End),
"pageup" => Some(Key::PageUp),
"pagedown" => Some(Key::PageDown),
"capslock" => Some(Key::CapsLock),
"f1" => Some(Key::F1),
"f2" => Some(Key::F2),
"f3" => Some(Key::F3),
"f4" => Some(Key::F4),
"f5" => Some(Key::F5),
"f6" => Some(Key::F6),
"f7" => Some(Key::F7),
"f8" => Some(Key::F8),
"f9" => Some(Key::F9),
"f10" => Some(Key::F10),
"f11" => Some(Key::F11),
"f12" => Some(Key::F12),
_ => None,
};
if named.is_some() {
return named;
}
let mut chars = s.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) => {
if ch.is_ascii_alphabetic() {
Some(letter_key(ch.to_ascii_uppercase()))
} else {
Some(Key::Character(ch))
}
}
_ => None,
}
}
fn letter_key(upper: char) -> Key {
match upper {
'A' => Key::A,
'B' => Key::B,
'C' => Key::C,
'D' => Key::D,
'E' => Key::E,
'F' => Key::F,
'G' => Key::G,
'H' => Key::H,
'I' => Key::I,
'J' => Key::J,
'K' => Key::K,
'L' => Key::L,
'M' => Key::M,
'N' => Key::N,
'O' => Key::O,
'P' => Key::P,
'Q' => Key::Q,
'R' => Key::R,
'S' => Key::S,
'T' => Key::T,
'U' => Key::U,
'V' => Key::V,
'W' => Key::W,
'X' => Key::X,
'Y' => Key::Y,
_ => Key::Z,
}
}