use core::ffi::c_void;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use accesskit::{
Action, ActionRequest, Node, NodeId, Role, TextPosition, TextSelection, TreeId, TreeInfo,
TreeUpdate,
};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
};
use windows::Win32::System::Variant::VARIANT;
use windows::Win32::UI::Accessibility::{
CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationInvokePattern,
IUIAutomationTextPattern, IUIAutomationValuePattern, TextPatternRangeEndpoint_End,
TextPatternRangeEndpoint_Start, TreeScope_Descendants, UIA_InvokePatternId, UIA_NamePropertyId,
UIA_TextPatternId, UIA_ValuePatternId,
};
use super::Win32Shell;
use super::desktop::{DesktopShell, shell};
use super::{hwnd_of, window};
use crate::{Shell, ShellCaps, ShellEvent, WindowId};
const ROOT: NodeId = NodeId(1);
const BUTTON: NodeId = NodeId(2);
const FIELD: NodeId = NodeId(3);
const FIELD_RUN: NodeId = NodeId(4);
const WINDOW_LABEL: &str = "crcbl accessibility";
const BUTTON_LABEL: &str = "Press me";
const FIELD_LABEL: &str = "Query";
const FIELD_TEXT: &str = "héllo";
const BEFORE_CARET: &str = "hé";
const PATIENCE: Duration = Duration::from_secs(20);
const TICK: Duration = Duration::from_millis(10);
fn tree() -> TreeUpdate {
let mut root = Node::new(Role::Window);
root.set_label(WINDOW_LABEL);
root.set_children(vec![BUTTON, FIELD]);
let mut button = Node::new(Role::Button);
button.set_label(BUTTON_LABEL);
button.add_action(Action::Click);
let mut field = Node::new(Role::TextInput);
field.set_label(FIELD_LABEL);
field.set_value(FIELD_TEXT);
field.set_children(vec![FIELD_RUN]);
let caret = TextPosition {
node: FIELD_RUN,
character_index: BEFORE_CARET.chars().count(),
};
field.set_text_selection(TextSelection {
anchor: caret,
focus: caret,
});
let mut run = Node::new(Role::TextRun);
run.set_value(FIELD_TEXT);
let lengths: Vec<u8> = FIELD_TEXT
.chars()
.map(|c| u8::try_from(c.len_utf8()).expect("at most four bytes"))
.collect();
run.set_character_lengths(lengths);
TreeUpdate {
nodes: vec![
(ROOT, root),
(BUTTON, button),
(FIELD, field),
(FIELD_RUN, run),
],
tree: Some(TreeInfo::new(ROOT)),
tree_id: TreeId::ROOT,
focus: ROOT,
}
}
fn client<T: Send + 'static>(
hwnd: isize,
deadline: Instant,
ask: fn(&IUIAutomation, isize, Instant) -> Result<T, String>,
) -> JoinHandle<Result<T, String>> {
thread::spawn(move || {
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }
.ok()
.map_err(|error| format!("CoInitializeEx refused the client thread: {error}"))?;
let answer = unsafe { CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) }
.map_err(|error| format!("no CUIAutomation: {error}"))
.and_then(|automation: IUIAutomation| ask(&automation, hwnd, deadline));
unsafe { CoUninitialize() };
answer
})
}
fn find_by_name(
automation: &IUIAutomation,
hwnd: isize,
name: &str,
deadline: Instant,
) -> Result<IUIAutomationElement, String> {
let wanted = VARIANT::from(name);
let condition = unsafe { automation.CreatePropertyCondition(UIA_NamePropertyId, &wanted) }
.map_err(|error| format!("no name condition: {error}"))?;
loop {
let found =
unsafe { automation.ElementFromHandle(HWND(hwnd as *mut c_void)) }.and_then(|root| {
unsafe { root.FindFirst(TreeScope_Descendants, &condition) }
});
match found {
Ok(element) => return Ok(element),
Err(error) if Instant::now() >= deadline => {
return Err(format!(
"no element named {name:?} under the window within {PATIENCE:?}; the last \
lookup said {error}"
));
}
Err(_) => thread::sleep(TICK),
}
}
}
fn press_the_button(
automation: &IUIAutomation,
hwnd: isize,
deadline: Instant,
) -> Result<(), String> {
let button = find_by_name(automation, hwnd, BUTTON_LABEL, deadline)?;
let invoke: IUIAutomationInvokePattern =
unsafe { button.GetCurrentPatternAs(UIA_InvokePatternId) }
.map_err(|error| format!("the button has no Invoke pattern: {error}"))?;
unsafe { invoke.Invoke() }.map_err(|error| format!("Invoke failed: {error}"))
}
fn read_the_field(
automation: &IUIAutomation,
hwnd: isize,
deadline: Instant,
) -> Result<(String, String), String> {
let field = find_by_name(automation, hwnd, FIELD_LABEL, deadline)?;
unsafe {
let value: IUIAutomationValuePattern = field
.GetCurrentPatternAs(UIA_ValuePatternId)
.map_err(|error| format!("the field has no Value pattern: {error}"))?;
let value = value
.CurrentValue()
.map_err(|error| format!("the field's value did not read: {error}"))?
.to_string();
let text: IUIAutomationTextPattern = field
.GetCurrentPatternAs(UIA_TextPatternId)
.map_err(|error| format!("the field has no Text pattern: {error}"))?;
let selection = text
.GetSelection()
.map_err(|error| format!("the field's selection did not read: {error}"))?;
let caret = selection
.GetElement(0)
.map_err(|error| format!("the field reports no selection: {error}"))?;
let before = text
.DocumentRange()
.map_err(|error| format!("the field has no document range: {error}"))?;
before
.MoveEndpointByRange(
TextPatternRangeEndpoint_End,
&caret,
TextPatternRangeEndpoint_Start,
)
.map_err(|error| format!("the range did not cut at the caret: {error}"))?;
let before = before
.GetText(-1)
.map_err(|error| format!("the text before the caret did not read: {error}"))?
.to_string();
Ok((value, before))
}
}
struct Exchange<T> {
answer: Result<T, String>,
seen: Vec<&'static str>,
activations: usize,
press: Option<ActionRequest>,
}
fn exchange<T>(
shell: &mut Win32Shell,
window: WindowId,
asking: JoinHandle<Result<T, String>>,
deadline: Instant,
until_press: bool,
) -> Exchange<T> {
let mut asking = Some(asking);
let mut answer = None;
let mut seen = Vec::new();
let mut activations = 0;
let mut press = None;
loop {
shell.wait_events(Some(TICK));
let mut events = Vec::new();
shell.pump(&mut |event| events.push(event));
for event in events {
seen.push(event.name());
match event {
ShellEvent::AccessibilityActivated { window: read } if read == window => {
activations += 1;
shell
.update_accessibility(window, tree())
.expect("the window is live");
}
ShellEvent::AccessibilityAction {
window: target,
request,
} if target == window => press = Some(request),
_ => {}
}
}
if let Some(done) = asking.take_if(|handle| handle.is_finished()) {
answer = Some(done.join().expect("the client thread does not panic"));
}
let now = Instant::now();
if asking.is_none() && (!until_press || press.is_some() || now >= deadline) {
break;
}
assert!(
now < deadline + PATIENCE,
"the client is still running {PATIENCE:?} past its own deadline; the shell raised \
{seen:?}"
);
}
Exchange {
answer: answer.expect("the loop ends only once the client has answered"),
seen,
activations,
press,
}
}
fn open() -> (DesktopShell, WindowId, isize) {
let mut shell = shell();
assert!(
shell.caps().contains(ShellCaps::ACCESSIBILITY),
"the accesskit build claims the cap: {:?}",
shell.caps()
);
let window = window(&mut shell);
shell.pump(&mut |_| {});
let hwnd = hwnd_of(&shell, window) as isize;
(shell, window, hwnd)
}
#[test]
#[ignore = "opens windows on the real desktop: run-win32-e2e.ps1 runs it"]
fn a_reader_finds_the_button_in_the_tree_and_presses_it() {
let (mut shell, window, hwnd) = open();
let deadline = Instant::now() + PATIENCE;
let asking = client(hwnd, deadline, press_the_button);
let done = exchange(&mut shell, window, asking, deadline, true);
let seen = &done.seen;
if let Err(why) = &done.answer {
panic!("the client gave up: {why}; the shell raised {seen:?}");
}
assert_eq!(
done.activations, 1,
"one activation however many times the client asks; the shell raised {seen:?}"
);
let press = done.press.unwrap_or_else(|| {
panic!("no AccessibilityAction within {PATIENCE:?}; the shell raised {seen:?}")
});
assert_eq!(
(press.action, press.target_tree, press.target_node),
(Action::Click, TreeId::ROOT, BUTTON),
"the invoke is a click on the button: {press:?}"
);
shell.destroy_window(window).expect("destroy");
}
#[test]
#[ignore = "opens windows on the real desktop: run-win32-e2e.ps1 runs it"]
fn a_reader_reads_the_fields_value_and_caret() {
let (mut shell, window, hwnd) = open();
let deadline = Instant::now() + PATIENCE;
let asking = client(hwnd, deadline, read_the_field);
let done = exchange(&mut shell, window, asking, deadline, false);
let seen = &done.seen;
let (value, before) = done
.answer
.unwrap_or_else(|why| panic!("the client gave up: {why}; the shell raised {seen:?}"));
assert_eq!(value, FIELD_TEXT, "the field's value");
assert_eq!(
before, BEFORE_CARET,
"the caret sits after {BEFORE_CARET:?}, two characters and three bytes in"
);
shell.destroy_window(window).expect("destroy");
}