feat(windows): add Win32 window manager for HWND search, coords, and closing
This commit is contained in:
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Win32 Window Manager for Catser.
|
||||||
|
|
||||||
|
Handles native Windows window operations:
|
||||||
|
- Locating windows by handle (HWND), title pattern, or process name.
|
||||||
|
- Querying exact screen coordinates via GetWindowRect (with DPI awareness).
|
||||||
|
- Locating the title bar Close [X] button.
|
||||||
|
- Gracefully closing windows using WM_CLOSE or SC_CLOSE messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional, Tuple, Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger("catser.windows")
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Win32 Constants & Structs
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
WM_CLOSE = 0x0010
|
||||||
|
WM_SYSCOMMAND = 0x0112
|
||||||
|
SC_CLOSE = 0xF060
|
||||||
|
SC_MINIMIZE = 0xF020
|
||||||
|
SW_MINIMIZE = 6
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
|
||||||
|
# Enable per-monitor DPI awareness so pixel coordinates match high-DPI displays
|
||||||
|
try:
|
||||||
|
# PROCESS_PER_MONITOR_DPI_AWARE_V2 = -4
|
||||||
|
ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.SetProcessDPIAware()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WindowInfo:
|
||||||
|
"""Represents metadata and geometry of an active Windows desktop window."""
|
||||||
|
hwnd: int
|
||||||
|
title: str
|
||||||
|
process_name: str
|
||||||
|
rect: Tuple[int, int, int, int] # (left, top, right, bottom)
|
||||||
|
close_button: Tuple[int, int] # (x, y) coordinates of the [X] close button
|
||||||
|
is_visible: bool
|
||||||
|
is_minimized: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return max(0, self.rect[2] - self.rect[0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return max(0, self.rect[3] - self.rect[1])
|
||||||
|
|
||||||
|
|
||||||
|
class WindowManager:
|
||||||
|
"""Win32 window query and manipulation utility."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_window_title(hwnd: int) -> str:
|
||||||
|
"""Retrieves the title text of the given window."""
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
if length == 0:
|
||||||
|
return ""
|
||||||
|
buff = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buff, length + 1)
|
||||||
|
return buff.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_process_name_for_hwnd(hwnd: int) -> str:
|
||||||
|
"""Retrieves the executable process name for the window."""
|
||||||
|
pid = wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
if pid.value == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Query process image name via OpenProcess + QueryFullProcessImageNameW
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
h_process = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
|
||||||
|
if not h_process:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
buf_size = wintypes.DWORD(1024)
|
||||||
|
path_buf = ctypes.create_unicode_buffer(1024)
|
||||||
|
if kernel32.QueryFullProcessImageNameW(h_process, 0, path_buf, ctypes.byref(buf_size)):
|
||||||
|
full_path = path_buf.value
|
||||||
|
return full_path.split("\\")[-1].lower()
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(h_process)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_window_rect(hwnd: int) -> Optional[Tuple[int, int, int, int]]:
|
||||||
|
"""
|
||||||
|
Retrieves the bounding rectangle (left, top, right, bottom) of the window.
|
||||||
|
Returns None if invalid or hidden.
|
||||||
|
"""
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
||||||
|
return (rect.left, rect.top, rect.right, rect.bottom)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_close_button_coords(cls, rect: Tuple[int, int, int, int]) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Estimates the screen coordinate of the [X] close button for a window.
|
||||||
|
Standard Windows 10/11 title bar puts the center of [X] approx 24px from right, 16px from top.
|
||||||
|
"""
|
||||||
|
left, top, right, bottom = rect
|
||||||
|
# Center of standard Windows close button in title bar
|
||||||
|
close_x = right - 24
|
||||||
|
close_y = top + 16
|
||||||
|
|
||||||
|
# Clamp in case of unusually small windows
|
||||||
|
close_x = max(left + 10, min(right - 5, close_x))
|
||||||
|
close_y = max(top + 5, min(bottom - 5, close_y))
|
||||||
|
return (close_x, close_y)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_window_info(cls, hwnd: int) -> Optional[WindowInfo]:
|
||||||
|
"""Gathers full WindowInfo for a window handle."""
|
||||||
|
if not user32.IsWindow(hwnd):
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_visible = bool(user32.IsWindowVisible(hwnd))
|
||||||
|
is_minimized = bool(user32.IsIconic(hwnd))
|
||||||
|
|
||||||
|
# Filter out zero-size or invisible windows
|
||||||
|
rect = cls.get_window_rect(hwnd)
|
||||||
|
if not rect:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ignore tiny offscreen or hidden utility windows
|
||||||
|
width = rect[2] - rect[0]
|
||||||
|
height = rect[3] - rect[1]
|
||||||
|
if width <= 10 or height <= 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
title = cls.get_window_title(hwnd)
|
||||||
|
proc_name = cls.get_process_name_for_hwnd(hwnd)
|
||||||
|
close_pos = cls.get_close_button_coords(rect)
|
||||||
|
|
||||||
|
return WindowInfo(
|
||||||
|
hwnd=hwnd,
|
||||||
|
title=title,
|
||||||
|
process_name=proc_name,
|
||||||
|
rect=rect,
|
||||||
|
close_button=close_pos,
|
||||||
|
is_visible=is_visible,
|
||||||
|
is_minimized=is_minimized,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_foreground_window_info(cls) -> Optional[WindowInfo]:
|
||||||
|
"""Retrieves info for the current foreground (active) window."""
|
||||||
|
hwnd = user32.GetForegroundWindow()
|
||||||
|
if not hwnd:
|
||||||
|
return None
|
||||||
|
return cls.get_window_info(hwnd)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_window_by_match(
|
||||||
|
cls,
|
||||||
|
title_predicate: Optional[Callable[[str], bool]] = None,
|
||||||
|
process_predicate: Optional[Callable[[str], bool]] = None,
|
||||||
|
) -> Optional[WindowInfo]:
|
||||||
|
"""
|
||||||
|
Enumerates top-level desktop windows to find the first matching window.
|
||||||
|
"""
|
||||||
|
found_hwnd = None
|
||||||
|
|
||||||
|
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
|
||||||
|
def enum_proc(hwnd, lparam):
|
||||||
|
nonlocal found_hwnd
|
||||||
|
if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd):
|
||||||
|
return True
|
||||||
|
|
||||||
|
title = cls.get_window_title(hwnd)
|
||||||
|
if not title:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if title_predicate and title_predicate(title):
|
||||||
|
found_hwnd = hwnd
|
||||||
|
return False # Stop enumeration
|
||||||
|
|
||||||
|
if process_predicate:
|
||||||
|
pname = cls.get_process_name_for_hwnd(hwnd)
|
||||||
|
if pname and process_predicate(pname):
|
||||||
|
found_hwnd = hwnd
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.EnumWindows(WNDENUMPROC(enum_proc), 0)
|
||||||
|
|
||||||
|
if found_hwnd:
|
||||||
|
return cls.get_window_info(found_hwnd)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def close_window(cls, hwnd: int) -> bool:
|
||||||
|
"""
|
||||||
|
Sends WM_CLOSE and SC_CLOSE to cleanly request the window to close.
|
||||||
|
Returns True if message was successfully posted.
|
||||||
|
"""
|
||||||
|
if not user32.IsWindow(hwnd):
|
||||||
|
logger.warning(f"Cannot close invalid window handle: {hwnd}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Closing window {hwnd} ('{cls.get_window_title(hwnd)}')")
|
||||||
|
# Send WM_CLOSE
|
||||||
|
res1 = user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)
|
||||||
|
# Also post SC_CLOSE via WM_SYSCOMMAND as a robust secondary signal
|
||||||
|
res2 = user32.PostMessageW(hwnd, WM_SYSCOMMAND, SC_CLOSE, 0)
|
||||||
|
return bool(res1 or res2)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def minimize_window(cls, hwnd: int) -> bool:
|
||||||
|
"""Minimizes the window instead of closing it."""
|
||||||
|
if not user32.IsWindow(hwnd):
|
||||||
|
return False
|
||||||
|
return bool(user32.ShowWindow(hwnd, SW_MINIMIZE))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_screen_dimensions() -> Tuple[int, int]:
|
||||||
|
"""Returns primary screen width and height in pixels."""
|
||||||
|
SM_CXSCREEN = 0
|
||||||
|
SM_CYSCREEN = 1
|
||||||
|
w = user32.GetSystemMetrics(SM_CXSCREEN)
|
||||||
|
h = user32.GetSystemMetrics(SM_CYSCREEN)
|
||||||
|
return (w, h)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_work_area() -> Tuple[int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Returns desktop work area (left, top, right, bottom) excluding the taskbar.
|
||||||
|
"""
|
||||||
|
SPI_GETWORKAREA = 0x0030
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
if user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0):
|
||||||
|
return (rect.left, rect.top, rect.right, rect.bottom)
|
||||||
|
# Fallback to full screen
|
||||||
|
w, h = WindowManager.get_screen_dimensions()
|
||||||
|
return (0, 0, w, h)
|
||||||
Reference in New Issue
Block a user