diff --git a/catser/window_manager.py b/catser/window_manager.py index 80afc4d..deb7a0e 100644 --- a/catser/window_manager.py +++ b/catser/window_manager.py @@ -60,6 +60,25 @@ class WindowInfo: 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.""" @@ -294,3 +313,130 @@ class WindowManager: 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_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