Window frames are now converted to AppKit space once at detection time, so
hover highlighting and picking work on every display. A click is only
treated as a drag after 4pt of movement, so clicking a detected window
captures it instead of passing a stray marquee. Redact is now a standalone
tool (Select/Draw/Redact) with its own toolbar hints instead of a toggle
inside the draw bar.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
254 lines
9.5 KiB
Swift
254 lines
9.5 KiB
Swift
import AppKit
|
||
|
||
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)
|
||
super.init(
|
||
contentRect: screen.frame,
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
contentView = content
|
||
level = .screenSaver
|
||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient]
|
||
backgroundColor = .clear
|
||
isOpaque = false
|
||
hasShadow = false
|
||
isReleasedWhenClosed = false
|
||
acceptsMouseMovedEvents = true
|
||
}
|
||
|
||
override var canBecomeKey: Bool { true }
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
override var acceptsFirstResponder: Bool { true }
|
||
|
||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||
|
||
override func resetCursorRects() {
|
||
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
|
||
}
|
||
|
||
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)
|
||
)
|
||
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
|
||
)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
NSGraphicsContext.current?.compositingOperation = .clear
|
||
NSColor.black.setFill()
|
||
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
let border = NSBezierPath(roundedRect: viewFrame, 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 }
|
||
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
NSGraphicsContext.current?.compositingOperation = .clear
|
||
NSColor.black.setFill()
|
||
NSBezierPath(rect: sel).fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
let border = NSBezierPath(rect: sel)
|
||
border.lineWidth = 1.5
|
||
NSColor.controlAccentColor.setStroke()
|
||
border.stroke()
|
||
|
||
let label = "\(Int(sel.width)) × \(Int(sel.height))"
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: NSColor.white,
|
||
]
|
||
let labelSize = label.size(withAttributes: attrs)
|
||
let labelRect = NSRect(
|
||
x: sel.maxX - labelSize.width - 8,
|
||
y: sel.minY - labelSize.height - 10,
|
||
width: labelSize.width + 8,
|
||
height: labelSize.height + 6
|
||
)
|
||
NSColor.black.withAlphaComponent(0.6).setFill()
|
||
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
|
||
label.draw(
|
||
at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3),
|
||
withAttributes: attrs
|
||
)
|
||
}
|
||
}
|