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
184 lines
5.7 KiB
Python
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()
|