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:
32
scripts/test_overlay.py
Normal file
32
scripts/test_overlay.py
Normal 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.")
|
||||||
178
src/cursor_flasher/overlay.py
Normal file
178
src/cursor_flasher/overlay.py
Normal 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
|
||||||
Reference in New Issue
Block a user