Files
Catser/catser/overlay.py
T

413 lines
13 KiB
Python

"""
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
# 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_PAINT = 0x000F
WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
WM_MOUSEMOVE = 0x0200
WM_NCHITTEST = 0x0084
# 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),
]
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,
):
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.hwnd: Optional[int] = None
self._wndproc_ref = None # Prevent garbage collection of callback
self._current_image: Optional[Image.Image] = None
self.is_dragging = False
self._create_window()
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, 32512) # 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_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 == 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:
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
# Windows UpdateLayeredWindow expects B, G, R, A with color channels premultiplied by alpha
bgra_bytes = self._premultiply_bgra(image)
# Create compatible Memory DC and DIB section
hdc_screen = user32.GetDC(0)
hdc_mem = gdi32.CreateCompatibleDC(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
bits_ptr = ctypes.c_void_p()
hbitmap = gdi32.CreateDIBSection(
hdc_mem,
ctypes.byref(bmi),
0,
ctypes.byref(bits_ptr),
0,
0,
)
# Copy BGRA bytes directly into the DIB memory buffer
ctypes.memmove(bits_ptr, bgra_bytes, len(bgra_bytes))
old_bmp = gdi32.SelectObject(hdc_mem, hbitmap)
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,
hdc_screen,
ctypes.byref(pt_dst),
ctypes.byref(size),
hdc_mem,
ctypes.byref(pt_src),
0,
ctypes.byref(blend),
ULW_ALPHA,
)
# Cleanup GDI handles
gdi32.SelectObject(hdc_mem, old_bmp)
gdi32.DeleteObject(hbitmap)
gdi32.DeleteDC(hdc_mem)
user32.ReleaseDC(0, hdc_screen)
@staticmethod
def _premultiply_bgra(img: Image.Image) -> bytes:
"""
Fast premultiplication and channel reordering from RGBA to BGRA.
"""
# Ensure RGBA
if img.mode != "RGBA":
img = img.convert("RGBA")
raw_rgba = img.tobytes("raw", "RGBA")
length = len(raw_rgba)
out = bytearray(length)
# Vectorized or loop transformation
# Each pixel is 4 bytes: R, G, B, A -> premultiplied B, G, R, A
for i in range(0, length, 4):
r = raw_rgba[i]
g = raw_rgba[i + 1]
b = raw_rgba[i + 2]
a = raw_rgba[i + 3]
if a == 255:
out[i] = b
out[i + 1] = g
out[i + 2] = r
out[i + 3] = 255
elif a == 0:
out[i] = 0
out[i + 1] = 0
out[i + 2] = 0
out[i + 3] = 0
else:
out[i] = (b * a) // 255
out[i + 1] = (g * a) // 255
out[i + 2] = (r * a) // 255
out[i + 3] = a
return bytes(out)
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 and unregisters class."""
if self.hwnd:
user32.DestroyWindow(self.hwnd)
self.hwnd = None