"""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()