diff --git a/scripts/test_overlay.py b/scripts/test_overlay.py new file mode 100644 index 0000000..9945e0d --- /dev/null +++ b/scripts/test_overlay.py @@ -0,0 +1,32 @@ +"""Manual test: shows a pulsing border around Cursor for 10 seconds.""" +import sys +import time + +from Cocoa import NSApplication, NSRunLoop, NSDate + +from cursor_flasher.config import Config +from cursor_flasher.overlay import OverlayWindow +from cursor_flasher.detector import CursorDetector + +app = NSApplication.sharedApplication() + +config = Config() +overlay = OverlayWindow(config) +detector = CursorDetector() + +pid = detector._find_cursor_pid() +if pid is None: + print("Cursor not running") + sys.exit(1) + +print(f"Showing overlay for PID {pid} for 10 seconds...") +overlay.show(pid) + +end_time = time.time() + 10 +while time.time() < end_time: + NSRunLoop.currentRunLoop().runUntilDate_( + NSDate.dateWithTimeIntervalSinceNow_(0.1) + ) + +overlay.hide() +print("Done.") diff --git a/src/cursor_flasher/overlay.py b/src/cursor_flasher/overlay.py new file mode 100644 index 0000000..9def2c0 --- /dev/null +++ b/src/cursor_flasher/overlay.py @@ -0,0 +1,178 @@ +"""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