""" 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)