diff --git a/scripts/test_overlay.py b/scripts/test_overlay.py index 9945e0d..59c097e 100644 --- a/scripts/test_overlay.py +++ b/scripts/test_overlay.py @@ -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() diff --git a/src/cursor_flasher/overlay.py b/src/cursor_flasher/overlay.py index 9def2c0..76e61c6 100644 --- a/src/cursor_flasher/overlay.py +++ b/src/cursor_flasher/overlay.py @@ -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