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