Fix multi-display window detection and click-to-capture; separate Redact tool

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
This commit is contained in:
2026-09-09 10:46:32 +08:00
parent 5f137f89bf
commit 45e4962ec3
3 changed files with 65 additions and 40 deletions
+27 -16
View File
@@ -30,11 +30,11 @@ final class OverlayView: NSView {
private var startPoint: NSPoint?
private(set) var selection: NSRect?
private var isDragging = false
private var dragDistance: CGFloat = 0
// Window detection
// Window detection (frames stored in AppKit global space)
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
private var excludedIDs: [Int] = []
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
self.onComplete = onComplete
@@ -79,14 +79,17 @@ final class OverlayView: NSView {
else { continue }
// Skip our own overlays
if Int(cid) == ownID { continue }
let rect = NSRect(
let cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard rect.width >= 80, rect.height >= 60 else { continue }
// Skip tiny/utility windows
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 }
@@ -97,11 +100,11 @@ final class OverlayView: NSView {
private func windowFrame(at point: NSPoint) -> NSRect? {
guard let window = window else { return nil }
// convertToScreen gives AppKit-global coordinates; convert tracked
// CG frames to the same space and pick the frontmost containing it.
// 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 Self.cgToAppKit(rect: frame).contains(screenPoint) {
if frame.contains(screenPoint) {
return frame
}
}
@@ -132,15 +135,20 @@ final class OverlayView: NSView {
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
let p = convert(event.locationInWindow, from: nil)
selection = NSRect(
x: min(start.x, p.x),
y: min(start.y, p.y),
@@ -151,6 +159,10 @@ final class OverlayView: NSView {
}
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 {
@@ -161,10 +173,10 @@ final class OverlayView: NSView {
return
}
// No drag: it was a click. If a window is hovered, capture it with
// rounded corners; otherwise treat as an empty marquee (cancel).
// 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(Self.cgToAppKit(rect: frame))
onWindowPick(frame)
} else {
onCancel()
}
@@ -184,13 +196,12 @@ final class OverlayView: NSView {
// Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame {
// Convert the CG frame to AppKit-global, then to this view's space.
// 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 appKitFrame = Self.cgToAppKit(rect: frame)
let viewFrame = NSRect(
x: appKitFrame.origin.x - screenFrame.origin.x,
y: appKitFrame.origin.y - screenFrame.origin.y,
x: frame.origin.x - screenFrame.origin.x,
y: frame.origin.y - screenFrame.origin.y,
width: frame.width,
height: frame.height
)