master
John Lauer 1.0.30: NATIVE (Rust) build. 1.0.30: kicad_taskbar_repair fixes the white taskbar icon on standalone KiCad editors by w… 46293d1 1mo ago
//! Windows window-control primitives (phase 3 of docs/rust-port-plan.md), ported from the
//! Python bridge's handlers/win_focus.py, close_windows.py, win_menu.py, kicad_ui.py,
//! uia.py and navigate_symbol.ps1. Each module is one family of primitives; windows.rs
//! wires them into the `Platform` trait.
//!
//! House rules, enforced here and not left to callers:
//! - Every blocking cross-process message goes through `SendMessageTimeoutW` with
//!   `SMTO_ABORTIFHUNG` and a deadline (`send_timeout`). Nothing in this layer may hang the
//!   bridge on a stuck KiCad (0.9.160 stranded a parked window for hours, 0.9.165 took the
//!   whole bridge down on the boot path, #83 deadlocked on a modal inside a button handler).
//! - Calls that Windows itself turns into synchronous messages (SetWindowPos, ShowWindow,
//!   SetWindowPlacement, BringWindowToTop, PrintWindow, UIA) are gated on `responsive()`
//!   first and refused on a hung window, for the same reason.
//! - No SendInput, keybd_event, mouse_event, SetCursorPos, SetWindowsHookEx, no minimize,
//!   no off-screen parking, no WS_EX_NOACTIVATE. `push_to_background` is the only z-order
//!   demotion and `bring_to_front` the only sanctioned activation. The two exceptions are
//!   explicit: `tour_input` is the one SendInput site (phase 5, refused unless the target
//!   is the measured foreground), and `etiquette` is the one module that may write a
//!   window style (to CLEAR a stale WS_EX_NOACTIVATE, probe-gated), and the one
//!   WinEventHook (a notification hook, not an input hook).
//! - Handles cross the trait as u64 and become `HWND` here; wide strings are built with
//!   `wide()` and read back with `from_wide()`.

pub mod capture;
pub mod enumerate;
pub mod etiquette;
pub mod focus;
pub mod install;
pub mod menu;
pub mod messages;
pub mod process;
pub mod shortcuts;
pub mod tour_input;
pub mod uia;

use std::ffi::c_void;

use windows::Win32::Foundation::{GetLastError, ERROR_TIMEOUT, HWND, LPARAM, WPARAM};
use windows::Win32::UI::WindowsAndMessaging::{IsWindow, SendMessageTimeoutW, SMTO_ABORTIFHUNG};

/// Executable base names (lower case) that count as KiCad. Mirrors `_KICAD_EXES` in
/// win_focus.py plus gerbview, which close_windows.py's loose scan included.
pub const KICAD_EXES: &[&str] = &[
    "kicad.exe",
    "eeschema.exe",
    "pcbnew.exe",
    "kicad-cli.exe",
    "pl_editor.exe",
    "bitmap2component.exe",
    "pcb_calculator.exe",
    "gerbview.exe",
];

pub fn is_kicad_exe(exe: &str) -> bool {
    KICAD_EXES.iter().any(|k| k.eq_ignore_ascii_case(exe))
}

/// u64 (trait side) to HWND (this side). A window handle is a 32-bit value zero-extended
/// into a pointer-sized field, so the round trip is exact.
pub fn hwnd(h: u64) -> HWND {
    HWND(h as usize as *mut c_void)
}

pub fn hwnd_u64(h: HWND) -> u64 {
    h.0 as usize as u64
}

/// Is this a live window handle? Cheap (no cross-thread message).
pub fn is_window(h: HWND) -> bool {
    // SAFETY: IsWindow accepts any value and only reads user32's handle table.
    unsafe { IsWindow(Some(h)).as_bool() }
}

/// Validate a trait-side handle before touching it.
pub fn live(h: u64) -> Result<HWND, String> {
    let w = hwnd(h);
    if h == 0 || !is_window(w) {
        return Err(format!("hwnd {h} is not a window"));
    }
    Ok(w)
}

/// NUL-terminated UTF-16.
pub fn wide(s: &str) -> Vec<u16> {
    s.encode_utf16().chain(std::iter::once(0)).collect()
}

/// UTF-16 buffer to String, stopping at the first NUL (or the whole slice).
pub fn from_wide(buf: &[u16]) -> String {
    let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
    String::from_utf16_lossy(&buf[..end])
}

/// Outcome of a time-boxed cross-process send.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Sent {
    /// The window procedure returned; the value is the LRESULT written by the target.
    Answered(usize),
    /// Delivered, but the target did not return within the deadline (a modal loop inside
    /// the handler, #83). The message is still being processed on the target's thread.
    TimedOut,
    /// Not delivered: the target thread is hung (SMTO_ABORTIFHUNG) or the handle is bad.
    Failed,
}

/// The one blocking primitive in this layer: `SendMessageTimeoutW` with
/// `SMTO_ABORTIFHUNG` and a deadline. Never a plain SendMessageW.
pub fn send_timeout(h: HWND, msg: u32, wparam: usize, lparam: isize, timeout_ms: u32) -> Sent {
    let mut result: usize = 0;
    // SAFETY: `result` outlives the call; lparam is either a scalar or a pointer the caller
    // keeps alive for the duration of this synchronous call.
    let ret = unsafe {
        SendMessageTimeoutW(h, msg, WPARAM(wparam), LPARAM(lparam), SMTO_ABORTIFHUNG, timeout_ms, Some(&mut result))
    };
    if ret.0 != 0 {
        return Sent::Answered(result);
    }
    // SAFETY: plain thread-local error read.
    if unsafe { GetLastError() } == ERROR_TIMEOUT {
        Sent::TimedOut
    } else {
        Sent::Failed
    }
}

/// Does the window's thread pump messages? `SendMessageTimeoutW(WM_NULL)` with
/// `SMTO_ABORTIFHUNG` (win_focus.py `_window_responsive`). The gate in front of every call
/// Windows would otherwise turn into a synchronous cross-process message.
pub fn responsive(h: HWND, timeout_ms: u32) -> bool {
    use windows::Win32::UI::WindowsAndMessaging::WM_NULL;
    matches!(send_timeout(h, WM_NULL, 0, 0, timeout_ms), Sent::Answered(_))
}

/// Refuse an operation on a window that failed the probe, with a message that says why.
pub fn require_responsive(h: HWND, what: &str, timeout_ms: u32) -> Result<(), String> {
    if responsive(h, timeout_ms) {
        Ok(())
    } else {
        Err(format!("{what}: window {} did not answer WM_NULL within {timeout_ms} ms (hung or busy); refusing so the bridge cannot block on it", hwnd_u64(h)))
    }
}

/// Client-coordinate pair packed the way mouse messages carry it: y in the high word,
/// x in the low word (kicad_ui.py `_post_click`).
pub fn pack_point(x: i32, y: i32) -> isize {
    (((y & 0xFFFF) << 16) | (x & 0xFFFF)) as isize
}

/// Menu and button captions carry '&' mnemonics; comparisons strip them.
pub fn strip_amp(s: &str) -> String {
    s.replace('&', "")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wide_round_trip() {
        let w = wide("KiCad 10.0");
        assert_eq!(w.last(), Some(&0));
        assert_eq!(from_wide(&w), "KiCad 10.0");
        assert_eq!(from_wide(&[0x41, 0x42, 0, 0x43]), "AB");
    }

    #[test]
    fn point_packing_matches_python() {
        // (cy << 16) | (cx & 0xFFFF)
        assert_eq!(pack_point(10, 58), (58 << 16) | 10);
        assert_eq!(pack_point(0x1234, 0x5678), 0x5678_1234);
    }

    #[test]
    fn hwnd_round_trip() {
        assert_eq!(hwnd_u64(hwnd(0x0001_0ABC)), 0x0001_0ABC);
        assert!(hwnd(0).is_invalid());
    }

    #[test]
    fn kicad_exe_set() {
        assert!(is_kicad_exe("PCBNEW.EXE"));
        assert!(!is_kicad_exe("chrome.exe"));
    }
}