master
John Lauer rust: Windows window-control layer (enumerate, process, messages, menu, focus, capture, UIA), all cross-process calls h… 3ecdded 2mo ago
//! 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))
}