"""Native macOS overlay window that draws a pulsing border around a target window.""" import math import objc from Cocoa import ( NSApplication, NSWindow, NSBorderlessWindowMask, NSColor, NSView, NSBezierPath, NSTimer, NSScreen, ) from Foundation import NSInsetRect, NSMakeRect from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID, kCGWindowOwnerPID, kCGWindowBounds, kCGWindowLayer, ) from cursor_flasher.config import Config 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 OverlayWindow: """Manages the overlay window lifecycle.""" def __init__(self, config: Config): self._config = config self._window: NSWindow | None = None self._view: PulseBorderView | None = None self._timer: NSTimer | None = None self._phase = 0.0 self._target_pid: int | None = None def show(self, pid: int) -> None: """Show the pulsing overlay around the window belonging to `pid`.""" self._target_pid = pid frame = self._get_window_frame(pid) if frame is None: return if self._window is None: self._create_window(frame) else: self._window.setFrame_display_(frame, True) self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1]))) self._window.orderFrontRegardless() self._start_animation() def hide(self) -> None: """Hide and clean up the overlay.""" self._stop_animation() if self._window is not None: self._window.orderOut_(None) def update_position(self) -> None: """Reposition overlay to match current Cursor window position.""" if self._target_pid is None or self._window is None: return frame = self._get_window_frame(self._target_pid) if frame is not None: self._window.setFrame_display_(frame, True) self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1]))) def _create_window(self, frame) -> None: self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered ) self._window.setOpaque_(False) self._window.setBackgroundColor_(NSColor.clearColor()) self._window.setLevel_(25) # Above normal windows self._window.setIgnoresMouseEvents_(True) self._window.setHasShadow_(False) content_rect = ((0, 0), (frame[1][0], frame[1][1])) self._view = PulseBorderView.alloc().initWithFrame_config_( content_rect, self._config ) self._window.setContentView_(self._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 if self._view is not None: self._view.setPhase_(self._phase) self.update_position() def _tick_(self, timer) -> None: self._tick_impl() def _get_window_frame(self, pid: int): """Get the screen frame of the main window for the given PID.""" window_list = CGWindowListCopyWindowInfo( kCGWindowListOptionOnScreenOnly, kCGNullWindowID ) if not window_list: return None screen_height = NSScreen.mainScreen().frame().size.height for info in window_list: if info.get(kCGWindowOwnerPID) != pid: continue if info.get(kCGWindowLayer, 999) != 0: continue bounds = info.get(kCGWindowBounds) if bounds is None: continue x = bounds["X"] y = screen_height - bounds["Y"] - bounds["Height"] w = bounds["Width"] h = bounds["Height"] return ((x, y), (w, h)) return None