feat: add native macOS overlay window with pulsing border

Uses NSWindow + PulseBorderView with Core Animation-style pulse.
Fixed NSRect handling from plan: uses NSInsetRect instead of
non-existent .insetBy method on pyobjc tuples.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:39:30 -04:00
parent f4cbfb997e
commit f967575ebc
2 changed files with 210 additions and 0 deletions

32
scripts/test_overlay.py Normal file
View File

@@ -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.")

View File

@@ -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