Drive capture selection through a CGEventTap
Panel event delivery is unreliable on macOS 26 (clicks pass through even
with the app active and the panel key). The selection flow is now driven
by a CGEventTap that sees and consumes mouse events system-wide: hover
highlights windows, clicks capture them (rounded), drags marquee-select,
Esc cancels. Requires Accessibility permission, prompted on first use;
falls back to panel events when unavailable.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
import AppKit
|
||||
|
||||
/// Renders the capture overlay. All interaction state lives in
|
||||
/// CaptureController (driven by the CGEventTap); this view only draws.
|
||||
final class OverlayWindow: NSPanel {
|
||||
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
|
||||
let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick)
|
||||
init(screen: NSScreen, controller: CaptureController) {
|
||||
let content = OverlayView()
|
||||
super.init(
|
||||
contentRect: screen.frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
contentView = content
|
||||
content.controller = controller
|
||||
level = .screenSaver
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient]
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
hasShadow = false
|
||||
@@ -23,29 +26,7 @@ final class OverlayWindow: NSPanel {
|
||||
}
|
||||
|
||||
final class OverlayView: NSView {
|
||||
private let onComplete: (NSRect) -> Void
|
||||
private let onCancel: () -> Void
|
||||
private let onWindowPick: (NSRect) -> Void
|
||||
|
||||
private var startPoint: NSPoint?
|
||||
private(set) var selection: NSRect?
|
||||
private var isDragging = false
|
||||
private var dragDistance: CGFloat = 0
|
||||
|
||||
// Window detection (frames stored in AppKit global space)
|
||||
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
|
||||
private var hoveredWindowFrame: NSRect?
|
||||
|
||||
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
|
||||
self.onComplete = onComplete
|
||||
self.onCancel = onCancel
|
||||
self.onWindowPick = onWindowPick
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
weak var controller: CaptureController?
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
@@ -55,178 +36,56 @@ final class OverlayView: NSView {
|
||||
addCursorRect(bounds, cursor: .crosshair)
|
||||
}
|
||||
|
||||
// MARK: - Window detection
|
||||
|
||||
/// Rebuild the list of capturable windows intersecting this screen.
|
||||
/// Called lazily on mouse moves; cheap enough for interactive hover.
|
||||
private func refreshTrackedWindows() {
|
||||
guard let window = window else { return }
|
||||
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
|
||||
// Exclude this overlay's own window (the other overlays don't intersect
|
||||
// this screen, so the on-screen filter handles them).
|
||||
let ownID = window.windowNumber
|
||||
|
||||
guard let list = CGWindowListCopyWindowInfo(
|
||||
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
|
||||
) as? [[String: Any]] else { trackedWindows = []; return }
|
||||
|
||||
var found: [(CGWindowID, NSRect)] = []
|
||||
for info in list {
|
||||
guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
|
||||
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
|
||||
let layer = info[kCGWindowLayer as String] as? Int,
|
||||
layer == 0
|
||||
else { continue }
|
||||
// Skip our own overlays
|
||||
if Int(cid) == ownID { continue }
|
||||
let cgRect = NSRect(
|
||||
x: bounds["X"] ?? 0,
|
||||
y: bounds["Y"] ?? 0,
|
||||
width: bounds["Width"] ?? 0,
|
||||
height: bounds["Height"] ?? 0
|
||||
)
|
||||
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
|
||||
// Convert to AppKit space FIRST — CG and AppKit Y axes disagree on
|
||||
// multi-desktop layouts, so intersect tests must use one space.
|
||||
let rect = Self.cgToAppKit(rect: cgRect)
|
||||
// Skip windows filling the whole screen (fullscreen apps)
|
||||
guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue }
|
||||
// Only windows intersecting this screen
|
||||
guard rect.intersects(screenFrame) else { continue }
|
||||
found.append((cid, rect))
|
||||
}
|
||||
trackedWindows = found
|
||||
/// The screen frame this overlay covers, in AppKit global space.
|
||||
private var screenFrame: NSRect {
|
||||
guard let window else { return .zero }
|
||||
return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
|
||||
}
|
||||
|
||||
private func windowFrame(at point: NSPoint) -> NSRect? {
|
||||
guard let window = window else { return nil }
|
||||
// convertToScreen gives AppKit-global coordinates; tracked frames are
|
||||
// already in that space.
|
||||
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
|
||||
for (_, frame) in trackedWindows {
|
||||
if frame.contains(screenPoint) {
|
||||
return frame
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Convert a CGWindowList (top-left origin, CG global space) frame into
|
||||
/// AppKit screen coordinates (bottom-left origin) for capture.
|
||||
private static func cgToAppKit(rect: NSRect) -> NSRect {
|
||||
guard let mainScreen = NSScreen.screens.first else { return rect }
|
||||
let y = mainScreen.frame.height - rect.origin.y - rect.height
|
||||
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
override func mouseMoved(with event: NSEvent) {
|
||||
guard !isDragging else { return }
|
||||
refreshTrackedWindows()
|
||||
let p = convert(event.locationInWindow, from: nil)
|
||||
let frame = windowFrame(at: p)
|
||||
if frame != hoveredWindowFrame {
|
||||
hoveredWindowFrame = frame
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let p = convert(event.locationInWindow, from: nil)
|
||||
startPoint = p
|
||||
dragDistance = 0
|
||||
selection = NSRect(origin: p, size: .zero)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let start = startPoint else { return }
|
||||
let p = convert(event.locationInWindow, from: nil)
|
||||
dragDistance = hypot(p.x - start.x, p.y - start.y)
|
||||
// Only become a marquee after real movement; micro-jitter from a
|
||||
// click must not cancel window-pick mode.
|
||||
guard dragDistance >= 4 else { return }
|
||||
isDragging = true
|
||||
hoveredWindowFrame = nil
|
||||
selection = NSRect(
|
||||
x: min(start.x, p.x),
|
||||
y: min(start.y, p.y),
|
||||
width: abs(p.x - start.x),
|
||||
height: abs(p.y - start.y)
|
||||
/// Convert an AppKit-global rect into this view's coordinates.
|
||||
private func viewRect(_ global: NSRect) -> NSRect {
|
||||
let sf = screenFrame
|
||||
return NSRect(
|
||||
x: global.origin.x - sf.origin.x,
|
||||
y: global.origin.y - sf.origin.y,
|
||||
width: global.width,
|
||||
height: global.height
|
||||
)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
defer {
|
||||
isDragging = false
|
||||
dragDistance = 0
|
||||
}
|
||||
if isDragging {
|
||||
guard let sel = selection else { return }
|
||||
if sel.width >= 2, sel.height >= 2 {
|
||||
onComplete(window!.convertToScreen(sel))
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// It was a click. If a window is hovered, capture it with rounded
|
||||
// corners; otherwise treat as an empty marquee (cancel).
|
||||
if let frame = hoveredWindowFrame {
|
||||
onWindowPick(frame)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 53 {
|
||||
onCancel()
|
||||
} else {
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||||
NSBezierPath(rect: bounds).fill()
|
||||
|
||||
// Highlight the hovered window (rounded, like the real window corners)
|
||||
if let frame = hoveredWindowFrame {
|
||||
// Tracked frames are AppKit-global; view space = global - window origin.
|
||||
guard let window = window else { return }
|
||||
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
|
||||
let viewFrame = NSRect(
|
||||
x: frame.origin.x - screenFrame.origin.x,
|
||||
y: frame.origin.y - screenFrame.origin.y,
|
||||
width: frame.width,
|
||||
height: frame.height
|
||||
)
|
||||
guard let controller else { return }
|
||||
|
||||
// Highlight the hovered window (rounded, like real window corners)
|
||||
if let frame = controller.hoverFrame {
|
||||
let vf = viewRect(frame)
|
||||
NSGraphicsContext.current?.saveGraphicsState()
|
||||
NSGraphicsContext.current?.compositingOperation = .clear
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill()
|
||||
NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
|
||||
NSGraphicsContext.current?.restoreGraphicsState()
|
||||
|
||||
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10)
|
||||
let border = NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10)
|
||||
border.lineWidth = 2
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
border.stroke()
|
||||
return
|
||||
}
|
||||
|
||||
guard let sel = selection, sel.width > 0, sel.height > 0 else { return }
|
||||
// Marquee selection
|
||||
guard let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 else { return }
|
||||
let vf = viewRect(sel)
|
||||
|
||||
NSGraphicsContext.current?.saveGraphicsState()
|
||||
NSGraphicsContext.current?.compositingOperation = .clear
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: sel).fill()
|
||||
NSBezierPath(rect: vf).fill()
|
||||
NSGraphicsContext.current?.restoreGraphicsState()
|
||||
|
||||
let border = NSBezierPath(rect: sel)
|
||||
let border = NSBezierPath(rect: vf)
|
||||
border.lineWidth = 1.5
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
border.stroke()
|
||||
@@ -238,8 +97,8 @@ final class OverlayView: NSView {
|
||||
]
|
||||
let labelSize = label.size(withAttributes: attrs)
|
||||
let labelRect = NSRect(
|
||||
x: sel.maxX - labelSize.width - 8,
|
||||
y: sel.minY - labelSize.height - 10,
|
||||
x: vf.maxX - labelSize.width - 8,
|
||||
y: vf.minY - labelSize.height - 10,
|
||||
width: labelSize.width + 8,
|
||||
height: labelSize.height + 6
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user