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