feat(overlay): add Win32 32-bit ARGB layered window overlay
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user