Files
Catser/catser/window_manager.py
T
me0nline 6204a88792 feat(window_manager): use OpenInputDesktop in find_window_by_match and exclude Catser overlay
Ensures desktop window enumeration accesses the user's interactive desktop station
across multiple monitors and ignores Catser's own overlay window.
2026-09-08 23:37:54 +02:00

297 lines
10 KiB
Python
Raw 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])
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)