Files
Catser/catser/window_manager.py
T

453 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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