diff --git a/catser/window_manager.py b/catser/window_manager.py new file mode 100644 index 0000000..d99bd6a --- /dev/null +++ b/catser/window_manager.py @@ -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)