Files
cursor-flasher/src/cursor_flasher/overlay.py

220 lines
6.8 KiB
Python
Raw Normal View History

"""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
from Quartz import (
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowOwnerPID,
kCGWindowBounds,
kCGWindowLayer,
kCGWindowNumber,
)
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 _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.
"""
def __init__(self, config: Config):
self._config = config
self._overlays: list[_OverlayEntry] = []
self._timer: NSTimer | None = None
self._phase = 0.0
self._target_pid: int | None = None
def show(self, pid: int) -> None:
"""Show pulsing overlays around every window belonging to `pid`."""
self._target_pid = pid
frames = self._get_all_window_frames(pid)
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)
def update_positions(self) -> None:
"""Reposition overlays to track current Cursor window positions."""
if self._target_pid is None:
return
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_(
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)
self.update_positions()
def _tick_(self, timer) -> None:
self._tick_impl()
def _get_all_window_frames(self, pid: int) -> list[tuple]:
"""Get screen frames for all on-screen windows belonging to `pid`."""
window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
if not window_list:
return []
screen_height = NSScreen.mainScreen().frame().size.height
frames = []
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"]
if w < 100 or h < 100:
continue
x = bounds["X"]
y = screen_height - bounds["Y"] - h
frames.append(((x, y), (w, h)))
return frames