diff --git a/catser/overlay.py b/catser/overlay.py index e6fc4be..81418d4 100644 --- a/catser/overlay.py +++ b/catser/overlay.py @@ -46,6 +46,13 @@ 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 @@ -63,12 +70,17 @@ 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 +# Cursors +IDC_ARROW = 32512 +IDC_HAND = 32649 + # Hit test return codes HTTRANSPARENT = -1 HTCLIENT = 1 @@ -190,7 +202,39 @@ class OverlayWindow: 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.""" @@ -206,7 +250,7 @@ class OverlayWindow: wndclass.cbWndExtra = 0 wndclass.hInstance = hinstance wndclass.hIcon = 0 - wndclass.hCursor = user32.LoadCursorW(0, 32512) # IDC_ARROW + wndclass.hCursor = user32.LoadCursorW(0, IDC_ARROW) wndclass.hbrBackground = 0 wndclass.lpszMenuName = None wndclass.lpszClassName = self.CLASS_NAME @@ -271,6 +315,16 @@ class OverlayWindow: 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 @@ -324,7 +378,7 @@ class OverlayWindow: 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: + if not self.hwnd or not self.hdc_mem or not self.bits_ptr: return if x is not None: @@ -339,34 +393,10 @@ class OverlayWindow: 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) + # 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) @@ -381,22 +411,16 @@ class OverlayWindow: # Single atomic call to update layered window position and 32-bit alpha frame user32.UpdateLayeredWindow( self.hwnd, - hdc_screen, + self.hdc_screen, ctypes.byref(pt_dst), ctypes.byref(size), - hdc_mem, + self.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: """ @@ -446,7 +470,23 @@ class OverlayWindow: return True def destroy(self) -> None: - """Destroys the window handle and unregisters class.""" + """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