feat: per-window detection — only flash windows needing attention
Detector now walks each AXWindow subtree independently and returns both aggregate signals (for state machine) and a list of AXWindow element refs for windows with active approval signals. Overlay reads position/size directly from AXWindow elements via AXValueGetValue, eliminating the CGWindowList dependency (which returned empty names for Electron windows anyway). Daemon passes only the active AXWindow refs to the overlay, so only the specific window(s) waiting for user input get flashed. Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Native macOS overlay window that draws a pulsing border around a target window."""
|
||||
"""Native macOS overlay window that draws a pulsing border around target windows."""
|
||||
import math
|
||||
|
||||
import objc
|
||||
@@ -13,17 +13,9 @@ from Cocoa import (
|
||||
NSScreen,
|
||||
)
|
||||
from Foundation import NSInsetRect
|
||||
from Quartz import (
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID,
|
||||
kCGWindowOwnerPID,
|
||||
kCGWindowBounds,
|
||||
kCGWindowLayer,
|
||||
kCGWindowNumber,
|
||||
)
|
||||
|
||||
from cursor_flasher.config import Config
|
||||
from cursor_flasher.detector import get_ax_window_frame
|
||||
|
||||
|
||||
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
|
||||
@@ -69,7 +61,7 @@ class PulseBorderView(NSView):
|
||||
|
||||
|
||||
class _OverlayEntry:
|
||||
"""A single overlay window + view pair."""
|
||||
"""A single overlay window + view pair tracking an AXWindow."""
|
||||
__slots__ = ("window", "view")
|
||||
|
||||
def __init__(self, window: NSWindow, view: PulseBorderView):
|
||||
@@ -78,23 +70,23 @@ class _OverlayEntry:
|
||||
|
||||
|
||||
class OverlayManager:
|
||||
"""Manages overlay windows for all Cursor windows belonging to a PID.
|
||||
"""Manages overlay windows for Cursor windows that need attention.
|
||||
|
||||
Creates one transparent overlay per Cursor window and keeps them
|
||||
positioned and animated in sync.
|
||||
Accepts AXWindow element refs from the detector and creates one
|
||||
overlay per window, reading position directly from the a11y element.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self._overlays: list[_OverlayEntry] = []
|
||||
self._ax_windows: list = []
|
||||
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)
|
||||
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()
|
||||
if not frames:
|
||||
return
|
||||
|
||||
@@ -110,20 +102,19 @@ class OverlayManager:
|
||||
self._stop_animation()
|
||||
for entry in self._overlays:
|
||||
entry.window.orderOut_(None)
|
||||
self._ax_windows = []
|
||||
|
||||
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 _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
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Ensure we have exactly len(frames) overlays, positioned correctly."""
|
||||
needed = len(frames)
|
||||
existing = len(self._overlays)
|
||||
|
||||
@@ -184,36 +175,9 @@ class OverlayManager:
|
||||
self._phase += step
|
||||
for entry in self._overlays:
|
||||
entry.view.setPhase_(self._phase)
|
||||
self.update_positions()
|
||||
frames = self._read_frames()
|
||||
if frames:
|
||||
self._sync_overlays(frames)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user