app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
rust: Windows window-control layer (enumerate, process, messages, menu, focus, capture, UIA), all cross-process calls h…
3ecdded
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
//! Posted and time-boxed messages to one hwnd: keys, clicks, text, WM_CLOSE, BM_CLICK,
//! plus the idle clock and the GUI-thread focus probe.
//! Python: kicad_ui.py `_post_key`, `_post_click`; close_windows.py `_win_click_button`,
//! `_win_dismiss_dialog`, `_win_close_and_wait`; navigate_symbol.ps1 (WM_SETTEXT);
//! win_focus.py `_seconds_since_user_input`; open_symbol_editor.py `_gui_thread_focus`.
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::System::SystemInformation::GetTickCount;
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
use windows::Win32::UI::WindowsAndMessaging::{
GetGUIThreadInfo, PostMessageW, BM_CLICK, GUITHREADINFO, WM_CHAR, WM_CLOSE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDBLCLK,
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_SETTEXT,
};
use super::enumerate::{class_name, descendants, text_timeout, thread_of, title_nohang, visible};
use super::{hwnd_u64, pack_point, send_timeout, strip_amp, wide, Sent};
/// MK_LBUTTON: the left button is down while the message is delivered.
const MK_LBUTTON: usize = 0x0001;
/// PostMessageW to one window's queue: no focus, no shared input queue, never blocks.
pub fn post(h: HWND, msg: u32, wparam: usize, lparam: isize) -> Result<(), String> {
// SAFETY: PostMessageW copies its scalar arguments; nothing is borrowed after the call.
unsafe { PostMessageW(Some(h), msg, WPARAM(wparam), LPARAM(lparam)) }.map_err(|e| format!("PostMessageW(0x{:x}) to {}: {e}", msg, hwnd_u64(h)))
}
pub fn post_close(h: HWND) -> Result<(), String> {
post(h, WM_CLOSE, 0, 0)
}
/// WM_KEYDOWN, optional WM_CHAR, WM_KEYUP. Chords are never attempted this way (the menu
/// walk resolves them to WM_COMMAND instead).
pub fn post_key(h: HWND, vk: u16, ch: Option<char>) -> Result<(), String> {
post(h, WM_KEYDOWN, vk as usize, 0)?;
if let Some(c) = ch {
post(h, WM_CHAR, c as usize, 0)?;
}
post(h, WM_KEYUP, vk as usize, 0)
}
/// A left click at client coordinates, posted, so the user's physical pointer never moves.
/// `double` appends the WM_LBUTTONDBLCLK pair the tree rows in the editors respond to.
pub fn post_click(h: HWND, x: i32, y: i32, double: bool) -> Result<(), String> {
let lp = pack_point(x, y);
post(h, WM_MOUSEMOVE, 0, lp)?;
post(h, WM_LBUTTONDOWN, MK_LBUTTON, lp)?;
post(h, WM_LBUTTONUP, 0, lp)?;
if double {
post(h, WM_LBUTTONDBLCLK, MK_LBUTTON, lp)?;
post(h, WM_LBUTTONUP, 0, lp)?;
}
Ok(())
}
/// WM_SETTEXT, time-boxed. The edit control's EN_CHANGE fires on the target thread, which
/// is how the library tree filter reacts without any keystroke.
pub fn set_text(h: HWND, text: &str) -> Result<(), String> {
let buf = wide(text);
match send_timeout(h, WM_SETTEXT, 0, buf.as_ptr() as isize, 1500) {
Sent::Answered(_) => Ok(()),
Sent::TimedOut => Err(format!("timeout: WM_SETTEXT to {} was delivered but the control did not return within 1500 ms (the text may still have landed; verify by reading it back)", hwnd_u64(h))),
Sent::Failed => Err(format!("not_delivered: WM_SETTEXT to {} (window hung or gone)", hwnd_u64(h))),
}
}
/// Text of a control with a deadline: WM_GETTEXT first (the live contents of an Edit),
/// the window's own text store when the control does not answer in time.
pub fn window_text(h: HWND, timeout_ms: u32) -> Result<String, String> {
if let Some(t) = text_timeout(h, timeout_ms) {
return Ok(t);
}
Ok(title_nohang(h))
}
/// Find a visible `Button` child whose caption (mnemonic stripped, trimmed, lower case)
/// is one of `labels` (compared lower case).
pub fn find_button(dialog: HWND, labels: &[&str]) -> Option<(HWND, String)> {
let wanted: Vec<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
descendants(dialog).into_iter().find_map(|c| {
if class_name(c) != "Button" || !visible(c) {
return None;
}
let caption = title_nohang(c);
let key = strip_amp(&caption).trim().to_lowercase();
wanted.contains(&key).then_some((c, caption))
})
}
/// BM_CLICK through SendMessageTimeoutW (1500 ms, SMTO_ABORTIFHUNG). NEVER a plain
/// SendMessage (#83, winvm 2026-09-07): the first-run wizard's Cancel opens its "Are you
/// sure?" Confirmation MODALLY inside the button handler, so a synchronous click does not
/// return until that box is answered, and the only thing that would answer it is the
/// sweep that is blocked. Time-boxed, the click is delivered, we get control back, and the
/// next pass answers the follow-up dialog. Returns the method string for the verb's record.
pub fn bm_click(dialog: HWND, labels: &[&str]) -> Option<String> {
let (btn, caption) = find_button(dialog, labels)?;
Some(match send_timeout(btn, BM_CLICK, 0, 0, 1500) {
Sent::Answered(_) => format!("bm_click:{caption}"),
Sent::TimedOut => format!("bm_click_timeout:{caption}"),
Sent::Failed => format!("bm_click_not_delivered:{caption}"),
})
}
/// Seconds since the user's last keyboard or mouse input, system wide (GetLastInputInfo).
/// Tick arithmetic wraps every 49.7 days, hence the wrapping subtraction.
pub fn seconds_since_input() -> Result<f64, String> {
let mut lii = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };
// SAFETY: cbSize is set; `lii` outlives the call.
if !unsafe { GetLastInputInfo(&mut lii) }.as_bool() {
return Err("GetLastInputInfo failed".into());
}
// SAFETY: no arguments.
let now = unsafe { GetTickCount() };
Ok(now.wrapping_sub(lii.dwTime) as f64 / 1000.0)
}
/// The hwnd holding keyboard focus inside the thread that owns `frame` (GetGUIThreadInfo;
/// cross-process, no focus change). Ready for the verb layer's "did the filter box take
/// the text" check; not on the trait yet.
#[allow(dead_code)]
pub fn gui_thread_focus(frame: HWND) -> Option<u64> {
let tid = thread_of(frame);
if tid == 0 {
return None;
}
let mut gti = GUITHREADINFO { cbSize: std::mem::size_of::<GUITHREADINFO>() as u32, ..Default::default() };
// SAFETY: cbSize is set; `gti` outlives the call.
unsafe { GetGUIThreadInfo(tid, &mut gti) }.ok()?;
(!gti.hwndFocus.is_invalid()).then(|| hwnd_u64(gti.hwndFocus))
}