feat: support multiple Cursor windows in overlay
Renamed OverlayWindow to OverlayManager. Now discovers all on-screen windows for the Cursor PID and creates/reuses/hides overlay windows dynamically to match. Filters out tiny windows (<100px). Verified: detects 3 windows across monitors. Made-with: Cursor
This commit is contained in:
@@ -5,13 +5,13 @@ import time
|
||||
from Cocoa import NSApplication, NSRunLoop, NSDate
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
from cursor_flasher.overlay import OverlayWindow
|
||||
from cursor_flasher.overlay import OverlayManager
|
||||
from cursor_flasher.detector import CursorDetector
|
||||
|
||||
app = NSApplication.sharedApplication()
|
||||
|
||||
config = Config()
|
||||
overlay = OverlayWindow(config)
|
||||
overlay = OverlayManager(config)
|
||||
detector = CursorDetector()
|
||||
|
||||
pid = detector._find_cursor_pid()
|
||||
|
||||
@@ -12,7 +12,7 @@ from Cocoa import (
|
||||
NSTimer,
|
||||
NSScreen,
|
||||
)
|
||||
from Foundation import NSInsetRect, NSMakeRect
|
||||
from Foundation import NSInsetRect
|
||||
from Quartz import (
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
@@ -20,6 +20,7 @@ from Quartz import (
|
||||
kCGWindowOwnerPID,
|
||||
kCGWindowBounds,
|
||||
kCGWindowLayer,
|
||||
kCGWindowNumber,
|
||||
)
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
@@ -67,63 +68,100 @@ class PulseBorderView(NSView):
|
||||
self.setNeedsDisplay_(True)
|
||||
|
||||
|
||||
class OverlayWindow:
|
||||
"""Manages the overlay window lifecycle."""
|
||||
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._window: NSWindow | None = None
|
||||
self._view: PulseBorderView | None = None
|
||||
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 the pulsing overlay around the window belonging to `pid`."""
|
||||
"""Show pulsing overlays around every window belonging to `pid`."""
|
||||
self._target_pid = pid
|
||||
frame = self._get_window_frame(pid)
|
||||
if frame is None:
|
||||
frames = self._get_all_window_frames(pid)
|
||||
if not frames:
|
||||
return
|
||||
|
||||
if self._window is None:
|
||||
self._create_window(frame)
|
||||
else:
|
||||
self._window.setFrame_display_(frame, True)
|
||||
self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1])))
|
||||
self._sync_overlays(frames)
|
||||
|
||||
for entry in self._overlays:
|
||||
entry.window.orderFrontRegardless()
|
||||
|
||||
self._window.orderFrontRegardless()
|
||||
self._start_animation()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide and clean up the overlay."""
|
||||
"""Hide all overlay windows."""
|
||||
self._stop_animation()
|
||||
if self._window is not None:
|
||||
self._window.orderOut_(None)
|
||||
for entry in self._overlays:
|
||||
entry.window.orderOut_(None)
|
||||
|
||||
def update_position(self) -> None:
|
||||
"""Reposition overlay to match current Cursor window position."""
|
||||
if self._target_pid is None or self._window is None:
|
||||
def update_positions(self) -> None:
|
||||
"""Reposition overlays to track current Cursor window positions."""
|
||||
if self._target_pid is None:
|
||||
return
|
||||
frame = self._get_window_frame(self._target_pid)
|
||||
if frame is not None:
|
||||
self._window.setFrame_display_(frame, True)
|
||||
self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1])))
|
||||
frames = self._get_all_window_frames(self._target_pid)
|
||||
self._sync_overlays(frames)
|
||||
|
||||
def _create_window(self, frame) -> None:
|
||||
self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
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
|
||||
)
|
||||
self._window.setOpaque_(False)
|
||||
self._window.setBackgroundColor_(NSColor.clearColor())
|
||||
self._window.setLevel_(25) # Above normal windows
|
||||
self._window.setIgnoresMouseEvents_(True)
|
||||
self._window.setHasShadow_(False)
|
||||
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]))
|
||||
self._view = PulseBorderView.alloc().initWithFrame_config_(
|
||||
view = PulseBorderView.alloc().initWithFrame_config_(
|
||||
content_rect, self._config
|
||||
)
|
||||
self._window.setContentView_(self._view)
|
||||
window.setContentView_(view)
|
||||
return _OverlayEntry(window, view)
|
||||
|
||||
def _start_animation(self) -> None:
|
||||
if self._timer is not None:
|
||||
@@ -144,22 +182,23 @@ class OverlayWindow:
|
||||
speed = self._config.pulse_speed
|
||||
step = (2.0 * math.pi) / (speed * 30.0)
|
||||
self._phase += step
|
||||
if self._view is not None:
|
||||
self._view.setPhase_(self._phase)
|
||||
self.update_position()
|
||||
for entry in self._overlays:
|
||||
entry.view.setPhase_(self._phase)
|
||||
self.update_positions()
|
||||
|
||||
def _tick_(self, timer) -> None:
|
||||
self._tick_impl()
|
||||
|
||||
def _get_window_frame(self, pid: int):
|
||||
"""Get the screen frame of the main window for the given PID."""
|
||||
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 None
|
||||
return []
|
||||
|
||||
screen_height = NSScreen.mainScreen().frame().size.height
|
||||
frames = []
|
||||
|
||||
for info in window_list:
|
||||
if info.get(kCGWindowOwnerPID) != pid:
|
||||
@@ -169,10 +208,12 @@ class OverlayWindow:
|
||||
bounds = info.get(kCGWindowBounds)
|
||||
if bounds is None:
|
||||
continue
|
||||
x = bounds["X"]
|
||||
y = screen_height - bounds["Y"] - bounds["Height"]
|
||||
w = bounds["Width"]
|
||||
h = bounds["Height"]
|
||||
return ((x, y), (w, h))
|
||||
if w < 100 or h < 100:
|
||||
continue
|
||||
x = bounds["X"]
|
||||
y = screen_height - bounds["Y"] - h
|
||||
frames.append(((x, y), (w, h)))
|
||||
|
||||
return None
|
||||
return frames
|
||||
|
||||
Reference in New Issue
Block a user