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