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 Cocoa import NSApplication, NSRunLoop, NSDate
|
||||||
|
|
||||||
from cursor_flasher.config import Config
|
from cursor_flasher.config import Config
|
||||||
from cursor_flasher.overlay import OverlayWindow
|
from cursor_flasher.overlay import OverlayManager
|
||||||
from cursor_flasher.detector import CursorDetector
|
from cursor_flasher.detector import CursorDetector
|
||||||
|
|
||||||
app = NSApplication.sharedApplication()
|
app = NSApplication.sharedApplication()
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
overlay = OverlayWindow(config)
|
overlay = OverlayManager(config)
|
||||||
detector = CursorDetector()
|
detector = CursorDetector()
|
||||||
|
|
||||||
pid = detector._find_cursor_pid()
|
pid = detector._find_cursor_pid()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from Cocoa import (
|
|||||||
NSTimer,
|
NSTimer,
|
||||||
NSScreen,
|
NSScreen,
|
||||||
)
|
)
|
||||||
from Foundation import NSInsetRect, NSMakeRect
|
from Foundation import NSInsetRect
|
||||||
from Quartz import (
|
from Quartz import (
|
||||||
CGWindowListCopyWindowInfo,
|
CGWindowListCopyWindowInfo,
|
||||||
kCGWindowListOptionOnScreenOnly,
|
kCGWindowListOptionOnScreenOnly,
|
||||||
@@ -20,6 +20,7 @@ from Quartz import (
|
|||||||
kCGWindowOwnerPID,
|
kCGWindowOwnerPID,
|
||||||
kCGWindowBounds,
|
kCGWindowBounds,
|
||||||
kCGWindowLayer,
|
kCGWindowLayer,
|
||||||
|
kCGWindowNumber,
|
||||||
)
|
)
|
||||||
|
|
||||||
from cursor_flasher.config import Config
|
from cursor_flasher.config import Config
|
||||||
@@ -67,63 +68,100 @@ class PulseBorderView(NSView):
|
|||||||
self.setNeedsDisplay_(True)
|
self.setNeedsDisplay_(True)
|
||||||
|
|
||||||
|
|
||||||
class OverlayWindow:
|
class _OverlayEntry:
|
||||||
"""Manages the overlay window lifecycle."""
|
"""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):
|
def __init__(self, config: Config):
|
||||||
self._config = config
|
self._config = config
|
||||||
self._window: NSWindow | None = None
|
self._overlays: list[_OverlayEntry] = []
|
||||||
self._view: PulseBorderView | None = None
|
|
||||||
self._timer: NSTimer | None = None
|
self._timer: NSTimer | None = None
|
||||||
self._phase = 0.0
|
self._phase = 0.0
|
||||||
self._target_pid: int | None = None
|
self._target_pid: int | None = None
|
||||||
|
|
||||||
def show(self, pid: int) -> 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
|
self._target_pid = pid
|
||||||
frame = self._get_window_frame(pid)
|
frames = self._get_all_window_frames(pid)
|
||||||
if frame is None:
|
if not frames:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._window is None:
|
self._sync_overlays(frames)
|
||||||
self._create_window(frame)
|
|
||||||
else:
|
for entry in self._overlays:
|
||||||
self._window.setFrame_display_(frame, True)
|
entry.window.orderFrontRegardless()
|
||||||
self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1])))
|
|
||||||
|
|
||||||
self._window.orderFrontRegardless()
|
|
||||||
self._start_animation()
|
self._start_animation()
|
||||||
|
|
||||||
def hide(self) -> None:
|
def hide(self) -> None:
|
||||||
"""Hide and clean up the overlay."""
|
"""Hide all overlay windows."""
|
||||||
self._stop_animation()
|
self._stop_animation()
|
||||||
if self._window is not None:
|
for entry in self._overlays:
|
||||||
self._window.orderOut_(None)
|
entry.window.orderOut_(None)
|
||||||
|
|
||||||
def update_position(self) -> None:
|
def update_positions(self) -> None:
|
||||||
"""Reposition overlay to match current Cursor window position."""
|
"""Reposition overlays to track current Cursor window positions."""
|
||||||
if self._target_pid is None or self._window is None:
|
if self._target_pid is None:
|
||||||
return
|
return
|
||||||
frame = self._get_window_frame(self._target_pid)
|
frames = self._get_all_window_frames(self._target_pid)
|
||||||
if frame is not None:
|
self._sync_overlays(frames)
|
||||||
self._window.setFrame_display_(frame, True)
|
|
||||||
self._view.setFrame_(((0, 0), (frame[1][0], frame[1][1])))
|
|
||||||
|
|
||||||
def _create_window(self, frame) -> None:
|
def _sync_overlays(self, frames: list[tuple]) -> None:
|
||||||
self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
"""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
|
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
|
||||||
)
|
)
|
||||||
self._window.setOpaque_(False)
|
window.setOpaque_(False)
|
||||||
self._window.setBackgroundColor_(NSColor.clearColor())
|
window.setBackgroundColor_(NSColor.clearColor())
|
||||||
self._window.setLevel_(25) # Above normal windows
|
window.setLevel_(25) # Above normal windows
|
||||||
self._window.setIgnoresMouseEvents_(True)
|
window.setIgnoresMouseEvents_(True)
|
||||||
self._window.setHasShadow_(False)
|
window.setHasShadow_(False)
|
||||||
|
|
||||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
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
|
content_rect, self._config
|
||||||
)
|
)
|
||||||
self._window.setContentView_(self._view)
|
window.setContentView_(view)
|
||||||
|
return _OverlayEntry(window, view)
|
||||||
|
|
||||||
def _start_animation(self) -> None:
|
def _start_animation(self) -> None:
|
||||||
if self._timer is not None:
|
if self._timer is not None:
|
||||||
@@ -144,22 +182,23 @@ class OverlayWindow:
|
|||||||
speed = self._config.pulse_speed
|
speed = self._config.pulse_speed
|
||||||
step = (2.0 * math.pi) / (speed * 30.0)
|
step = (2.0 * math.pi) / (speed * 30.0)
|
||||||
self._phase += step
|
self._phase += step
|
||||||
if self._view is not None:
|
for entry in self._overlays:
|
||||||
self._view.setPhase_(self._phase)
|
entry.view.setPhase_(self._phase)
|
||||||
self.update_position()
|
self.update_positions()
|
||||||
|
|
||||||
def _tick_(self, timer) -> None:
|
def _tick_(self, timer) -> None:
|
||||||
self._tick_impl()
|
self._tick_impl()
|
||||||
|
|
||||||
def _get_window_frame(self, pid: int):
|
def _get_all_window_frames(self, pid: int) -> list[tuple]:
|
||||||
"""Get the screen frame of the main window for the given PID."""
|
"""Get screen frames for all on-screen windows belonging to `pid`."""
|
||||||
window_list = CGWindowListCopyWindowInfo(
|
window_list = CGWindowListCopyWindowInfo(
|
||||||
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
||||||
)
|
)
|
||||||
if not window_list:
|
if not window_list:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
screen_height = NSScreen.mainScreen().frame().size.height
|
screen_height = NSScreen.mainScreen().frame().size.height
|
||||||
|
frames = []
|
||||||
|
|
||||||
for info in window_list:
|
for info in window_list:
|
||||||
if info.get(kCGWindowOwnerPID) != pid:
|
if info.get(kCGWindowOwnerPID) != pid:
|
||||||
@@ -169,10 +208,12 @@ class OverlayWindow:
|
|||||||
bounds = info.get(kCGWindowBounds)
|
bounds = info.get(kCGWindowBounds)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
continue
|
continue
|
||||||
x = bounds["X"]
|
|
||||||
y = screen_height - bounds["Y"] - bounds["Height"]
|
|
||||||
w = bounds["Width"]
|
w = bounds["Width"]
|
||||||
h = bounds["Height"]
|
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