Files
cursor-flasher/src/cursor_flasher/overlay.py

184 lines
5.7 KiB
Python
Raw Normal View History

"""Native macOS overlay window that draws a pulsing border around target windows."""
import math
import objc
from Cocoa import (
NSApplication,
NSWindow,
NSBorderlessWindowMask,
NSColor,
NSView,
NSBezierPath,
NSTimer,
NSScreen,
)
from Foundation import NSInsetRect
from cursor_flasher.config import Config
from cursor_flasher.detector import get_ax_window_frame
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
"""Convert a hex color string to NSColor."""
hex_str = hex_str.lstrip("#")
r = int(hex_str[0:2], 16) / 255.0
g = int(hex_str[2:4], 16) / 255.0
b = int(hex_str[4:6], 16) / 255.0
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
class PulseBorderView(NSView):
"""Custom view that draws a pulsing border rectangle."""
def initWithFrame_config_(self, frame, config):
self = objc.super(PulseBorderView, self).initWithFrame_(frame)
if self is None:
return None
self._config = config
self._phase = 0.0
return self
def drawRect_(self, rect):
opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min
alpha = self._config.pulse_opacity_min + opacity_range * (
0.5 + 0.5 * math.sin(self._phase)
)
color = hex_to_nscolor(self._config.pulse_color, alpha)
color.setStroke()
width = self._config.pulse_width
inset = width / 2.0
bounds = objc.super(PulseBorderView, self).bounds()
inset_rect = NSInsetRect(bounds, inset, inset)
path = NSBezierPath.bezierPathWithRect_(inset_rect)
path.setLineWidth_(width)
path.stroke()
def setPhase_(self, phase):
self._phase = phase
self.setNeedsDisplay_(True)
class _OverlayEntry:
"""A single overlay window + view pair tracking an AXWindow."""
__slots__ = ("window", "view")
def __init__(self, window: NSWindow, view: PulseBorderView):
self.window = window
self.view = view
class OverlayManager:
"""Manages overlay windows for Cursor windows that need attention.
Accepts AXWindow element refs from the detector and creates one
overlay per window, reading position directly from the a11y element.
"""
def __init__(self, config: Config):
self._config = config
self._overlays: list[_OverlayEntry] = []
self._ax_windows: list = []
self._timer: NSTimer | None = None
self._phase = 0.0
def show(self, ax_windows: list) -> None:
"""Show pulsing overlays around the given AXWindow elements."""
self._ax_windows = list(ax_windows)
frames = self._read_frames()
if not frames:
return
self._sync_overlays(frames)
for entry in self._overlays:
entry.window.orderFrontRegardless()
self._start_animation()
def hide(self) -> None:
"""Hide all overlay windows."""
self._stop_animation()
for entry in self._overlays:
entry.window.orderOut_(None)
self._ax_windows = []
def _read_frames(self) -> list[tuple]:
"""Read current frames from stored AXWindow refs."""
frames = []
for ax_win in self._ax_windows:
frame = get_ax_window_frame(ax_win)
if frame is not None:
frames.append(frame)
return frames
def _sync_overlays(self, frames: list[tuple]) -> None:
"""Ensure we have exactly len(frames) overlays, positioned correctly."""
needed = len(frames)
existing = len(self._overlays)
for i in range(needed):
frame = frames[i]
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
if i < existing:
entry = self._overlays[i]
entry.window.setFrame_display_(frame, True)
entry.view.setFrame_(content_rect)
else:
entry = self._create_overlay(frame)
self._overlays.append(entry)
entry.window.orderFrontRegardless()
for i in range(needed, existing):
self._overlays[i].window.orderOut_(None)
if needed < existing:
self._overlays = self._overlays[:needed]
def _create_overlay(self, frame) -> _OverlayEntry:
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
)
window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(25) # Above normal windows
window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False)
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view = PulseBorderView.alloc().initWithFrame_config_(
content_rect, self._config
)
window.setContentView_(view)
return _OverlayEntry(window, view)
def _start_animation(self) -> None:
if self._timer is not None:
return
interval = 1.0 / 30.0 # 30 fps
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
interval, self, "_tick:", None, True
)
def _stop_animation(self) -> None:
if self._timer is not None:
self._timer.invalidate()
self._timer = None
self._phase = 0.0
@objc.python_method
def _tick_impl(self):
speed = self._config.pulse_speed
step = (2.0 * math.pi) / (speed * 30.0)
self._phase += step
for entry in self._overlays:
entry.view.setPhase_(self._phase)
frames = self._read_frames()
if frames:
self._sync_overlays(frames)
def _tick_(self, timer) -> None:
self._tick_impl()