Files
cursor-flasher/src/cursor_flasher/overlay.py
cottongin b31f39268e feat: per-window detection — only flash windows needing attention
Detector now walks each AXWindow subtree independently and returns
both aggregate signals (for state machine) and a list of AXWindow
element refs for windows with active approval signals.

Overlay reads position/size directly from AXWindow elements via
AXValueGetValue, eliminating the CGWindowList dependency (which
returned empty names for Electron windows anyway).

Daemon passes only the active AXWindow refs to the overlay, so
only the specific window(s) waiting for user input get flashed.

Made-with: Cursor
2026-03-10 02:54:15 -04:00

184 lines
5.7 KiB
Python

"""Native macOS overlay window that draws a pulsing border around target windows."""
import math
import objc
from Cocoa import (
NSApplication,
NSWindow,
NSBorderlessWindowMask,
NSColor,
NSView,
NSBezierPath,
NSTimer,
NSScreen,
)
from Foundation import NSInsetRect
from cursor_flasher.config import Config
from cursor_flasher.detector import get_ax_window_frame
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 _OverlayEntry:
"""A single overlay window + view pair tracking an AXWindow."""
__slots__ = ("window", "view")
def __init__(self, window: NSWindow, view: PulseBorderView):
self.window = window
self.view = view
class OverlayManager:
"""Manages overlay windows for Cursor windows that need attention.
Accepts AXWindow element refs from the detector and creates one
overlay per window, reading position directly from the a11y element.
"""
def __init__(self, config: Config):
self._config = config
self._overlays: list[_OverlayEntry] = []
self._ax_windows: list = []
self._timer: NSTimer | None = None
self._phase = 0.0
def show(self, ax_windows: list) -> None:
"""Show pulsing overlays around the given AXWindow elements."""
self._ax_windows = list(ax_windows)
frames = self._read_frames()
if not frames:
return
self._sync_overlays(frames)
for entry in self._overlays:
entry.window.orderFrontRegardless()
self._start_animation()
def hide(self) -> None:
"""Hide all overlay windows."""
self._stop_animation()
for entry in self._overlays:
entry.window.orderOut_(None)
self._ax_windows = []
def _read_frames(self) -> list[tuple]:
"""Read current frames from stored AXWindow refs."""
frames = []
for ax_win in self._ax_windows:
frame = get_ax_window_frame(ax_win)
if frame is not None:
frames.append(frame)
return frames
def _sync_overlays(self, frames: list[tuple]) -> None:
"""Ensure we have exactly len(frames) overlays, positioned correctly."""
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_(
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
)
window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(25) # Above normal windows
window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False)
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view = PulseBorderView.alloc().initWithFrame_config_(
content_rect, self._config
)
window.setContentView_(view)
return _OverlayEntry(window, 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
for entry in self._overlays:
entry.view.setPhase_(self._phase)
frames = self._read_frames()
if frames:
self._sync_overlays(frames)
def _tick_(self, timer) -> None:
self._tick_impl()