2026-03-10 02:39:30 -04:00
|
|
|
"""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,
|
|
|
|
|
)
|
2026-03-10 02:44:04 -04:00
|
|
|
from Foundation import NSInsetRect
|
2026-03-10 02:39:30 -04:00
|
|
|
from Quartz import (
|
|
|
|
|
CGWindowListCopyWindowInfo,
|
|
|
|
|
kCGWindowListOptionOnScreenOnly,
|
|
|
|
|
kCGNullWindowID,
|
|
|
|
|
kCGWindowOwnerPID,
|
|
|
|
|
kCGWindowBounds,
|
|
|
|
|
kCGWindowLayer,
|
2026-03-10 02:44:04 -04:00
|
|
|
kCGWindowNumber,
|
2026-03-10 02:39:30 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 02:44:04 -04:00
|
|
|
class _OverlayEntry:
|
|
|
|
|
"""A single overlay window + view pair."""
|
|
|
|
|
__slots__ = ("window", "view")
|
|
|
|
|
|
|
|
|
|
def __init__(self, window: NSWindow, view: PulseBorderView):
|
|
|
|
|
self.window = window
|
|
|
|
|
self.view = view
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OverlayManager:
|
|
|
|
|
"""Manages overlay windows for all Cursor windows belonging to a PID.
|
|
|
|
|
|
|
|
|
|
Creates one transparent overlay per Cursor window and keeps them
|
|
|
|
|
positioned and animated in sync.
|
|
|
|
|
"""
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
def __init__(self, config: Config):
|
|
|
|
|
self._config = config
|
2026-03-10 02:44:04 -04:00
|
|
|
self._overlays: list[_OverlayEntry] = []
|
2026-03-10 02:39:30 -04:00
|
|
|
self._timer: NSTimer | None = None
|
|
|
|
|
self._phase = 0.0
|
|
|
|
|
self._target_pid: int | None = None
|
|
|
|
|
|
|
|
|
|
def show(self, pid: int) -> None:
|
2026-03-10 02:44:04 -04:00
|
|
|
"""Show pulsing overlays around every window belonging to `pid`."""
|
2026-03-10 02:39:30 -04:00
|
|
|
self._target_pid = pid
|
2026-03-10 02:44:04 -04:00
|
|
|
frames = self._get_all_window_frames(pid)
|
|
|
|
|
if not frames:
|
2026-03-10 02:39:30 -04:00
|
|
|
return
|
|
|
|
|
|
2026-03-10 02:44:04 -04:00
|
|
|
self._sync_overlays(frames)
|
|
|
|
|
|
|
|
|
|
for entry in self._overlays:
|
|
|
|
|
entry.window.orderFrontRegardless()
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
self._start_animation()
|
|
|
|
|
|
|
|
|
|
def hide(self) -> None:
|
2026-03-10 02:44:04 -04:00
|
|
|
"""Hide all overlay windows."""
|
2026-03-10 02:39:30 -04:00
|
|
|
self._stop_animation()
|
2026-03-10 02:44:04 -04:00
|
|
|
for entry in self._overlays:
|
|
|
|
|
entry.window.orderOut_(None)
|
2026-03-10 02:39:30 -04:00
|
|
|
|
2026-03-10 02:44:04 -04:00
|
|
|
def update_positions(self) -> None:
|
|
|
|
|
"""Reposition overlays to track current Cursor window positions."""
|
|
|
|
|
if self._target_pid is None:
|
2026-03-10 02:39:30 -04:00
|
|
|
return
|
2026-03-10 02:44:04 -04:00
|
|
|
frames = self._get_all_window_frames(self._target_pid)
|
|
|
|
|
self._sync_overlays(frames)
|
|
|
|
|
|
|
|
|
|
def _sync_overlays(self, frames: list[tuple]) -> None:
|
|
|
|
|
"""Ensure we have exactly len(frames) overlays, positioned correctly.
|
|
|
|
|
|
|
|
|
|
Reuses existing overlay windows where possible, creates new ones
|
|
|
|
|
if more windows appeared, and hides extras if windows closed.
|
|
|
|
|
"""
|
|
|
|
|
needed = len(frames)
|
|
|
|
|
existing = len(self._overlays)
|
|
|
|
|
|
|
|
|
|
for i in range(needed):
|
|
|
|
|
frame = frames[i]
|
|
|
|
|
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
|
|
|
|
|
|
|
|
|
if i < existing:
|
|
|
|
|
entry = self._overlays[i]
|
|
|
|
|
entry.window.setFrame_display_(frame, True)
|
|
|
|
|
entry.view.setFrame_(content_rect)
|
|
|
|
|
else:
|
|
|
|
|
entry = self._create_overlay(frame)
|
|
|
|
|
self._overlays.append(entry)
|
|
|
|
|
entry.window.orderFrontRegardless()
|
|
|
|
|
|
|
|
|
|
for i in range(needed, existing):
|
|
|
|
|
self._overlays[i].window.orderOut_(None)
|
|
|
|
|
|
|
|
|
|
if needed < existing:
|
|
|
|
|
self._overlays = self._overlays[:needed]
|
|
|
|
|
|
|
|
|
|
def _create_overlay(self, frame) -> _OverlayEntry:
|
|
|
|
|
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
2026-03-10 02:39:30 -04:00
|
|
|
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
|
|
|
|
|
)
|
2026-03-10 02:44:04 -04:00
|
|
|
window.setOpaque_(False)
|
|
|
|
|
window.setBackgroundColor_(NSColor.clearColor())
|
|
|
|
|
window.setLevel_(25) # Above normal windows
|
|
|
|
|
window.setIgnoresMouseEvents_(True)
|
|
|
|
|
window.setHasShadow_(False)
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
2026-03-10 02:44:04 -04:00
|
|
|
view = PulseBorderView.alloc().initWithFrame_config_(
|
2026-03-10 02:39:30 -04:00
|
|
|
content_rect, self._config
|
|
|
|
|
)
|
2026-03-10 02:44:04 -04:00
|
|
|
window.setContentView_(view)
|
|
|
|
|
return _OverlayEntry(window, view)
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
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
|
2026-03-10 02:44:04 -04:00
|
|
|
for entry in self._overlays:
|
|
|
|
|
entry.view.setPhase_(self._phase)
|
|
|
|
|
self.update_positions()
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
def _tick_(self, timer) -> None:
|
|
|
|
|
self._tick_impl()
|
|
|
|
|
|
2026-03-10 02:44:04 -04:00
|
|
|
def _get_all_window_frames(self, pid: int) -> list[tuple]:
|
|
|
|
|
"""Get screen frames for all on-screen windows belonging to `pid`."""
|
2026-03-10 02:39:30 -04:00
|
|
|
window_list = CGWindowListCopyWindowInfo(
|
|
|
|
|
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
|
|
|
|
)
|
|
|
|
|
if not window_list:
|
2026-03-10 02:44:04 -04:00
|
|
|
return []
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
screen_height = NSScreen.mainScreen().frame().size.height
|
2026-03-10 02:44:04 -04:00
|
|
|
frames = []
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
w = bounds["Width"]
|
|
|
|
|
h = bounds["Height"]
|
2026-03-10 02:44:04 -04:00
|
|
|
if w < 100 or h < 100:
|
|
|
|
|
continue
|
|
|
|
|
x = bounds["X"]
|
|
|
|
|
y = screen_height - bounds["Y"] - h
|
|
|
|
|
frames.append(((x, y), (w, h)))
|
2026-03-10 02:39:30 -04:00
|
|
|
|
2026-03-10 02:44:04 -04:00
|
|
|
return frames
|