From 9bade210f577f98626c4293b170fb40c0fb0f8cc Mon Sep 17 00:00:00 2001 From: max Date: Tue, 8 Sep 2026 23:05:04 +0200 Subject: [PATCH] feat(overlay): add Win32 32-bit ARGB layered window overlay --- catser/overlay.py | 412 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 catser/overlay.py diff --git a/catser/overlay.py b/catser/overlay.py new file mode 100644 index 0000000..bba73c9 --- /dev/null +++ b/catser/overlay.py @@ -0,0 +1,412 @@ +""" +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