2026-03-10 02:54:15 -04:00
|
|
|
"""Native macOS overlay window that draws a pulsing border around target windows."""
|
2026-03-10 02:39:30 -04:00
|
|
|
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 cursor_flasher.config import Config
|
2026-03-10 02:54:15 -04:00
|
|
|
from cursor_flasher.detector import get_ax_window_frame
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-10 02:54:15 -04:00
|
|
|
"""A single overlay window + view pair tracking an AXWindow."""
|
2026-03-10 02:44:04 -04:00
|
|
|
__slots__ = ("window", "view")
|
|
|
|
|
|
|
|
|
|
def __init__(self, window: NSWindow, view: PulseBorderView):
|
|
|
|
|
self.window = window
|
|
|
|
|
self.view = view
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OverlayManager:
|
2026-03-10 02:54:15 -04:00
|
|
|
"""Manages overlay windows for Cursor windows that need attention.
|
2026-03-10 02:44:04 -04:00
|
|
|
|
2026-03-10 02:54:15 -04:00
|
|
|
Accepts AXWindow element refs from the detector and creates one
|
|
|
|
|
overlay per window, reading position directly from the a11y element.
|
2026-03-10 02:44:04 -04:00
|
|
|
"""
|
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:54:15 -04:00
|
|
|
self._ax_windows: list = []
|
2026-03-10 02:39:30 -04:00
|
|
|
self._timer: NSTimer | None = None
|
|
|
|
|
self._phase = 0.0
|
|
|
|
|
|
2026-03-10 02:54:15 -04:00
|
|
|
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()
|
2026-03-10 02:44:04 -04:00
|
|
|
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:54:15 -04:00
|
|
|
self._ax_windows = []
|
2026-03-10 02:39:30 -04:00
|
|
|
|
2026-03-10 02:54:15 -04:00
|
|
|
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
|
2026-03-10 02:44:04 -04:00
|
|
|
|
|
|
|
|
def _sync_overlays(self, frames: list[tuple]) -> None:
|
2026-03-10 02:54:15 -04:00
|
|
|
"""Ensure we have exactly len(frames) overlays, positioned correctly."""
|
2026-03-10 02:44:04 -04:00
|
|
|
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)
|
2026-03-10 02:54:15 -04:00
|
|
|
frames = self._read_frames()
|
|
|
|
|
if frames:
|
|
|
|
|
self._sync_overlays(frames)
|
2026-03-10 02:39:30 -04:00
|
|
|
|
|
|
|
|
def _tick_(self, timer) -> None:
|
|
|
|
|
self._tick_impl()
|