Files
Catser/catser/overlay.py
T

505 lines
17 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 Transparent Layered Window Overlay for Catser.
Provides a frameless, topmost, 32-bit ARGB per-pixel alpha transparent window.
Implements:
- True alpha blending with smooth antialiased edges and drop shadows via UpdateLayeredWindow.
- Pixel-perfect hit testing so transparent areas let mouse clicks pass through,
while clicking the cat allows dragging or petting.
- Drag-and-drop physics and mouse interaction.
"""
import ctypes
from ctypes import wintypes
import logging
from typing import Optional, Tuple, Callable
from PIL import Image
logger = logging.getLogger("catser.overlay")
# ==============================================================================
# Win32 Definitions & Constants
# ==============================================================================
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
kernel32 = ctypes.windll.kernel32
# Set proper 64-bit argtypes and restype to prevent ctypes integer overflow
user32.DefWindowProcW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
user32.DefWindowProcW.restype = ctypes.c_longlong
user32.GetDC.argtypes = [wintypes.HWND]
user32.GetDC.restype = wintypes.HDC
user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
user32.ReleaseDC.restype = ctypes.c_int
user32.SetWindowPos.argtypes = [wintypes.HWND, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.UINT]
user32.SetWindowPos.restype = wintypes.BOOL
gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
gdi32.CreateCompatibleDC.restype = wintypes.HDC
gdi32.DeleteDC.argtypes = [wintypes.HDC]
gdi32.DeleteDC.restype = wintypes.BOOL
gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
gdi32.SelectObject.restype = wintypes.HGDIOBJ
gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
gdi32.DeleteObject.restype = wintypes.BOOL
user32.SetCursor.argtypes = [wintypes.HCURSOR]
user32.SetCursor.restype = wintypes.HCURSOR
user32.LoadCursorW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p]
user32.LoadCursorW.restype = wintypes.HCURSOR
user32.UnregisterClassW.argtypes = [wintypes.LPCWSTR, wintypes.HINSTANCE]
user32.UnregisterClassW.restype = wintypes.BOOL
# Window styles
WS_POPUP = 0x80000000
WS_VISIBLE = 0x10000000
# Extended window styles
WS_EX_LAYERED = 0x00080000
WS_EX_TOPMOST = 0x00000008
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_NOACTIVATE = 0x08000000
# UpdateLayeredWindow flags
ULW_ALPHA = 0x00000002
AC_SRC_OVER = 0x00
AC_SRC_ALPHA = 0x01
# Window messages
WM_DESTROY = 0x0002
WM_SETCURSOR = 0x0020
WM_PAINT = 0x000F
WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
WM_MOUSEMOVE = 0x0200
WM_NCHITTEST = 0x0084
WM_DISPLAYCHANGE = 0x007E
WM_SETTINGCHANGE = 0x001A
# Cursors
IDC_ARROW = 32512
IDC_HAND = 32649
# Hit test return codes
HTTRANSPARENT = -1
HTCLIENT = 1
# SetWindowPos flags
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOACTIVATE = 0x0010
SWP_SHOWWINDOW = 0x0040
HWND_TOPMOST = -1
# C Structs
class BLENDFUNCTION(ctypes.Structure):
_fields_ = [
("BlendOp", wintypes.BYTE),
("BlendFlags", wintypes.BYTE),
("SourceConstantAlpha", wintypes.BYTE),
("AlphaFormat", wintypes.BYTE),
]
class POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
class SIZE(ctypes.Structure):
_fields_ = [("cx", wintypes.LONG), ("cy", wintypes.LONG)]
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", wintypes.LONG),
("biHeight", wintypes.LONG),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", wintypes.LONG),
("biYPelsPerMeter", wintypes.LONG),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class BITMAPINFO(ctypes.Structure):
_fields_ = [
("bmiHeader", BITMAPINFOHEADER),
("bmiColors", wintypes.DWORD * 3),
]
WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_longlong, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM)
class WNDCLASSEXW(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.UINT),
("style", wintypes.UINT),
("lpfnWndProc", WNDPROC),
("cbClsExtra", ctypes.c_int),
("cbWndExtra", ctypes.c_int),
("hInstance", wintypes.HINSTANCE),
("hIcon", wintypes.HICON),
("hCursor", wintypes.HICON),
("hbrBackground", wintypes.HBRUSH),
("lpszMenuName", wintypes.LPCWSTR),
("lpszClassName", wintypes.LPCWSTR),
("hIconSm", wintypes.HICON),
]
gdi32.CreateDIBSection.argtypes = [
wintypes.HDC,
ctypes.POINTER(BITMAPINFO),
wintypes.UINT,
ctypes.POINTER(ctypes.c_void_p),
wintypes.HANDLE,
wintypes.DWORD,
]
gdi32.CreateDIBSection.restype = wintypes.HBITMAP
user32.UpdateLayeredWindow.argtypes = [
wintypes.HWND,
wintypes.HDC,
ctypes.POINTER(POINT),
ctypes.POINTER(SIZE),
wintypes.HDC,
ctypes.POINTER(POINT),
wintypes.COLORREF,
ctypes.POINTER(BLENDFUNCTION),
wintypes.DWORD,
]
user32.UpdateLayeredWindow.restype = wintypes.BOOL
class OverlayWindow:
"""
Manages the transparent, topmost desktop overlay window for Catser.
"""
CLASS_NAME = "CatserOverlayClass"
def __init__(
self,
width: int,
height: int,
initial_x: int = 100,
initial_y: int = 100,
on_mouse_down: Optional[Callable[[int, int], None]] = None,
on_mouse_move: Optional[Callable[[int, int], None]] = None,
on_mouse_up: Optional[Callable[[int, int], None]] = None,
on_display_change: Optional[Callable[[], None]] = None,
):
self.width = width
self.height = height
self.x = initial_x
self.y = initial_y
self.on_mouse_down = on_mouse_down
self.on_mouse_move = on_mouse_move
self.on_mouse_up = on_mouse_up
self.on_display_change = on_display_change
self.hwnd: Optional[int] = None
self._wndproc_ref = None # Prevent garbage collection of callback
self._current_image: Optional[Image.Image] = None
self.is_dragging = False
# Persistent GDI handles for fast zero-allocation frame updates
self.hdc_screen: Optional[int] = None
self.hdc_mem: Optional[int] = None
self.hbitmap: Optional[int] = None
self.old_bmp: Optional[int] = None
self.bits_ptr: Optional[ctypes.c_void_p] = None
self._create_window()
self._init_gdi_resources()
def _init_gdi_resources(self) -> None:
"""Pre-allocates persistent DC and DIB section to avoid 60Hz GDI alloc/dealloc churn."""
self.hdc_screen = user32.GetDC(0)
self.hdc_mem = gdi32.CreateCompatibleDC(self.hdc_screen)
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = self.width
bmi.bmiHeader.biHeight = -self.height # Top-down DIB
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 32
bmi.bmiHeader.biCompression = 0 # BI_RGB
self.bits_ptr = ctypes.c_void_p()
self.hbitmap = gdi32.CreateDIBSection(
self.hdc_mem,
ctypes.byref(bmi),
0,
ctypes.byref(self.bits_ptr),
0,
0,
)
self.old_bmp = gdi32.SelectObject(self.hdc_mem, self.hbitmap)
def _create_window(self) -> None:
"""Registers window class and creates the Win32 layered overlay window."""
hinstance = kernel32.GetModuleHandleW(None)
self._wndproc_ref = WNDPROC(self._window_proc)
wndclass = WNDCLASSEXW()
wndclass.cbSize = ctypes.sizeof(WNDCLASSEXW)
wndclass.style = 0
wndclass.lpfnWndProc = self._wndproc_ref
wndclass.cbClsExtra = 0
wndclass.cbWndExtra = 0
wndclass.hInstance = hinstance
wndclass.hIcon = 0
wndclass.hCursor = user32.LoadCursorW(0, IDC_ARROW)
wndclass.hbrBackground = 0
wndclass.lpszMenuName = None
wndclass.lpszClassName = self.CLASS_NAME
wndclass.hIconSm = 0
user32.RegisterClassExW(ctypes.byref(wndclass))
# Extended style: layered, topmost, tool window (no taskbar icon), no-activate
dw_ex_style = WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE
dw_style = WS_POPUP | WS_VISIBLE
self.hwnd = user32.CreateWindowExW(
dw_ex_style,
self.CLASS_NAME,
"Catser Overlay",
dw_style,
self.x,
self.y,
self.width,
self.height,
0,
0,
hinstance,
None,
)
if not self.hwnd:
err = kernel32.GetLastError()
raise RuntimeError(f"Failed to create overlay window (error {err})")
# Keep on top
user32.SetWindowPos(self.hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW)
logger.info(f"Overlay window created with HWND: {self.hwnd} at ({self.x}, {self.y})")
def _window_proc(self, hwnd: int, msg: int, wparam: int, lparam: int) -> int:
"""Handles Windows message loop events including mouse interaction and hit-testing."""
if msg == WM_NCHITTEST:
# Check pixel alpha under cursor so transparent regions let clicks pass through
screen_x = lparam & 0xFFFF
screen_y = (lparam >> 16) & 0xFFFF
# Sign extend 16-bit to handle multi-monitor negative coordinates
if screen_x >= 0x8000:
screen_x -= 0x10000
if screen_y >= 0x8000:
screen_y -= 0x10000
local_x = screen_x - self.x
local_y = screen_y - self.y
if self.is_dragging:
return HTCLIENT
if self._current_image and 0 <= local_x < self.width and 0 <= local_y < self.height:
try:
pixel = self._current_image.getpixel((local_x, local_y))
alpha = pixel[3] if len(pixel) > 3 else 255
# Clickable only if pixel is reasonably opaque
if alpha > 30:
return HTCLIENT
except Exception:
pass
return HTTRANSPARENT
elif msg == WM_SETCURSOR:
# Handle cursor explicitly so Windows never falls back to an hourglass/wait cursor
hit = lparam & 0xFFFF
if hit == HTCLIENT:
cur_id = IDC_HAND if self.is_dragging else IDC_ARROW
cursor = user32.LoadCursorW(0, cur_id)
user32.SetCursor(cursor)
return 1
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
elif msg == WM_LBUTTONDOWN:
user32.SetCapture(hwnd)
self.is_dragging = True
pos = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(pos))
if self.on_mouse_down:
self.on_mouse_down(pos.x, pos.y)
return 0
elif msg == WM_MOUSEMOVE:
if self.is_dragging:
pos = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(pos))
if self.on_mouse_move:
self.on_mouse_move(pos.x, pos.y)
return 0
elif msg == WM_LBUTTONUP:
if self.is_dragging:
self.is_dragging = False
user32.ReleaseCapture()
pos = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(pos))
if self.on_mouse_up:
self.on_mouse_up(pos.x, pos.y)
return 0
elif msg in (WM_DISPLAYCHANGE, WM_SETTINGCHANGE):
if self.on_display_change:
try:
self.on_display_change()
except Exception as e:
logger.error(f"Error in on_display_change callback: {e}")
return 0
elif msg == WM_DESTROY:
user32.PostQuitMessage(0)
return 0
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
def set_position(self, x: int, y: int) -> None:
"""Updates the window on-screen coordinates."""
self.x = int(round(x))
self.y = int(round(y))
if self.hwnd:
user32.SetWindowPos(
self.hwnd,
HWND_TOPMOST,
self.x,
self.y,
0,
0,
SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
)
def draw_frame(self, image: Image.Image, x: Optional[int] = None, y: Optional[int] = None) -> None:
"""
Renders a 32-bit RGBA PIL Image to the overlay using UpdateLayeredWindow.
Converts pixels to premultiplied BGRA for hardware-accelerated Windows compositing.
"""
if not self.hwnd or not self.hdc_mem or not self.bits_ptr:
return
if x is not None:
self.x = int(round(x))
if y is not None:
self.y = int(round(y))
# Resize image if not matching window canvas
if image.size != (self.width, self.height):
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
self._current_image = image
# Convert PIL RGBA to premultiplied BGRA bytes
bgra_bytes = self._premultiply_bgra(image)
# Fast direct memory copy into the pre-allocated DIB section
ctypes.memmove(self.bits_ptr, bgra_bytes, len(bgra_bytes))
pt_dst = POINT(self.x, self.y)
size = SIZE(self.width, self.height)
pt_src = POINT(0, 0)
blend = BLENDFUNCTION()
blend.BlendOp = AC_SRC_OVER
blend.BlendFlags = 0
blend.SourceConstantAlpha = 255
blend.AlphaFormat = AC_SRC_ALPHA
# Single atomic call to update layered window position and 32-bit alpha frame
user32.UpdateLayeredWindow(
self.hwnd,
self.hdc_screen,
ctypes.byref(pt_dst),
ctypes.byref(size),
self.hdc_mem,
ctypes.byref(pt_src),
0,
ctypes.byref(blend),
ULW_ALPHA,
)
@staticmethod
def _premultiply_bgra(img: Image.Image) -> bytes:
"""
Converts a PIL RGBA image to premultiplied BGRA bytes for Win32
UpdateLayeredWindow (DIB_RGB_COLORS format required by GDI).
Uses numpy for a fully vectorised, branch-free transformation:
1. Reorder channels R,G,B,A → B,G,R,A (GDI expects BGR order)
2. Premultiply colour channels by alpha/255 in floating-point
3. Cast back to uint8
This is ~100× faster than an equivalent pure-Python loop and is the
reason the overlay no longer hangs at 60 FPS.
"""
import numpy as np # numpy 2.x is available in this env (checked at runtime)
if img.mode != "RGBA":
img = img.convert("RGBA")
# Shape: (H*W, 4) — columns are R, G, B, A
arr = np.frombuffer(img.tobytes(), dtype=np.uint8).reshape(-1, 4)
# Reorder to BGRA: index mapping R=0,G=1,B=2,A=3 → B=2,G=1,R=0,A=3
bgra = arr[:, [2, 1, 0, 3]].copy()
# Alpha channel as float32 in [0,1] for premultiplication
alpha = bgra[:, 3].astype(np.float32) / 255.0
# Premultiply B, G, R in place (column indices 0,1,2)
bgra[:, 0] = (bgra[:, 0] * alpha).astype(np.uint8)
bgra[:, 1] = (bgra[:, 1] * alpha).astype(np.uint8)
bgra[:, 2] = (bgra[:, 2] * alpha).astype(np.uint8)
return bgra.tobytes()
def process_messages(self) -> bool:
"""
Dispatches pending Windows messages.
Returns False if WM_QUIT was received.
"""
msg = wintypes.MSG()
while user32.PeekMessageW(ctypes.byref(msg), 0, 0, 0, 1): # PM_REMOVE = 1
if msg.message == 0x0012: # WM_QUIT
return False
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
return True
def destroy(self) -> None:
"""Destroys the window handle, releases GDI resources, and unregisters class."""
if self.hwnd:
user32.DestroyWindow(self.hwnd)
self.hwnd = None
if self.hdc_mem:
gdi32.SelectObject(self.hdc_mem, self.old_bmp)
gdi32.DeleteObject(self.hbitmap)
gdi32.DeleteDC(self.hdc_mem)
self.hdc_mem = None
if self.hdc_screen:
user32.ReleaseDC(0, self.hdc_screen)
self.hdc_screen = None
try:
hinstance = kernel32.GetModuleHandleW(None)
user32.UnregisterClassW(self.CLASS_NAME, hinstance)
except Exception:
pass