""" 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]) @dataclass class WindowLedge: """Represents a walkable top surface of a desktop window.""" hwnd: int title: str left: float right: float top_y: float @dataclass class MonitorInfo: """Represents a display monitor and its working area (excluding taskbar).""" handle: int is_primary: bool rect: Tuple[int, int, int, int] # (left, top, right, bottom) work_area: Tuple[int, int, int, int] # (left, top, right, bottom) excluding taskbar 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 visible window. Uses the user's interactive input desktop to ensure windows across all monitors are discovered even if the calling thread has a different default desktop. """ 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 # Never target Catser's own overlay window if "catser overlay" in title.lower(): 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 h_input_desk = user32.OpenInputDesktop(0, False, 0x01FF) if h_input_desk: user32.EnumDesktopWindows(h_input_desk, WNDENUMPROC(enum_proc), 0) user32.CloseDesktop(h_input_desk) else: 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. NOTE: This only covers the *primary* monitor's work area. Use get_virtual_desktop_bounds() for full multi-monitor extent. """ 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) @staticmethod def get_virtual_desktop_bounds() -> Tuple[int, int, int, int]: """ Returns the bounding rectangle of the full virtual desktop spanning ALL monitors. On a single-monitor system this equals get_screen_dimensions(). On multi-monitor systems it covers every display, including those with negative coordinates (monitors left of / above the primary). Returns: (left, top, right, bottom) in virtual-desktop pixel coordinates. """ SM_XVIRTUALSCREEN = 76 # Left edge of virtual desktop SM_YVIRTUALSCREEN = 77 # Top edge of virtual desktop SM_CXVIRTUALSCREEN = 78 # Total width of virtual desktop SM_CYVIRTUALSCREEN = 79 # Total height of virtual desktop vx = user32.GetSystemMetrics(SM_XVIRTUALSCREEN) vy = user32.GetSystemMetrics(SM_YVIRTUALSCREEN) vw = user32.GetSystemMetrics(SM_CXVIRTUALSCREEN) vh = user32.GetSystemMetrics(SM_CYVIRTUALSCREEN) if vw == 0 or vh == 0: # GetSystemMetrics failed – fall back to primary screen w, h = WindowManager.get_screen_dimensions() return (0, 0, w, h) return (vx, vy, vx + vw, vy + vh) @classmethod def get_monitors(cls) -> List[MonitorInfo]: """ Enumerates all connected display monitors and returns their full bounding rectangles and work areas (excluding taskbars). """ class _MONITORINFO(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD), ] monitors: List[MonitorInfo] = [] MONITORENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HMONITOR, wintypes.HDC, ctypes.POINTER(wintypes.RECT), wintypes.LPARAM) def enum_mon_proc(h_mon, hdc, lprect, lparam): mi = _MONITORINFO() mi.cbSize = ctypes.sizeof(_MONITORINFO) if user32.GetMonitorInfoW(h_mon, ctypes.byref(mi)): m = mi.rcMonitor w = mi.rcWork is_pri = bool(mi.dwFlags & 1) monitors.append(MonitorInfo( handle=int(h_mon), is_primary=is_pri, rect=(m.left, m.top, m.right, m.bottom), work_area=(w.left, w.top, w.right, w.bottom), )) return True user32.EnumDisplayMonitors(0, None, MONITORENUMPROC(enum_mon_proc), 0) return monitors @classmethod def get_display_topology_signature(cls) -> tuple: """ Returns a hashable signature representing current displays, resolutions, and work areas. Used to detect dynamic monitor connection, disconnection, or resolution adjustments. """ monitors = cls.get_monitors() vd = cls.get_virtual_desktop_bounds() return (vd, tuple((m.handle, m.is_primary, m.rect, m.work_area) for m in monitors)) @classmethod def get_floor_y_at(cls, x: float, cat_height: int) -> float: """ Returns the screen floor Y coordinate for a given horizontal X position, accounting for multi-monitor setups where different monitors have taskbars at different vertical coordinates. """ monitors = cls.get_monitors() for mon in monitors: wa = mon.work_area if wa[0] <= x < wa[2]: return float(wa[3] - cat_height) # Fallback to primary work area wa_pri = cls.get_work_area() return float(wa_pri[3] - cat_height) @classmethod def get_window_ledges(cls, min_width: int = 120) -> List[WindowLedge]: """ Queries all visible, non-minimized top-level windows to extract walkable horizontal platforms (window top titlebar frames). """ ledges: List[WindowLedge] = [] WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def enum_proc(hwnd, lparam): if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd): return True title = cls.get_window_title(hwnd) if not title: return True # Exclude overlays and desktop shell t_lower = title.lower() if "catser overlay" in t_lower or "program manager" in t_lower: return True rect = cls.get_window_rect(hwnd) if not rect: return True left, top, right, bottom = rect width = right - left height = bottom - top # Window must be large enough to serve as a platform if width >= min_width and height >= 60: ledges.append(WindowLedge( hwnd=hwnd, title=title, left=float(left), right=float(right), top_y=float(top), )) return True h_input_desk = user32.OpenInputDesktop(0, False, 0x01FF) if h_input_desk: user32.EnumDesktopWindows(h_input_desk, WNDENUMPROC(enum_proc), 0) user32.CloseDesktop(h_input_desk) else: user32.EnumWindows(WNDENUMPROC(enum_proc), 0) # Sort highest ledge first (lowest top_y) ledges.sort(key=lambda l: l.top_y) return ledges @classmethod def get_surface_beneath(cls, x: float, y: float, cat_width: int, cat_height: int) -> float: """ Finds the highest walking surface directly beneath the cat (either an open window top ledge or the monitor floor). Returns the target cat.y coordinate (i.e. surface_top_y - cat_height). """ cat_center_x = x + cat_width * 0.5 feet_y = y + cat_height floor_y = cls.get_floor_y_at(cat_center_x, cat_height) # Search for window ledges below the cat's current feet candidate_y = floor_y ledges = cls.get_window_ledges(min_width=100) for ledge in ledges: if ledge.left <= cat_center_x <= ledge.right: target_cat_y = ledge.top_y - cat_height # Is this ledge below the cat's feet (with 6px landing tolerance)? if ledge.top_y >= feet_y - 6.0 and target_cat_y < candidate_y: candidate_y = target_cat_y return candidate_y