feat(windows): add Win32 window manager for HWND search, coords, and closing

This commit is contained in:
2026-09-08 23:04:23 +02:00
parent 880e772668
commit 3dfec1896e
+254
View File
@@ -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)