Root cause: RegionPickerView lacked acceptsFirstMouse — as an LSUIElement accessory app Shotdeck is never active when the hotkey fires, so the user's first click on the overlay was refused and the drag never started. Also plumbs the monitored event's location through the controller (hardware-cursor reads made the chain untestable). Adds PickerSelfTest (SHOTDECK_PICKER_SELFTEST): posts synthetic mouse events through the app's own queue, asserts the exact CaptureRegion, saves a mid-drag overlay bitmap. Coordinator ran it: PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0); overlay bitmap shows dim+punch+chip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
204 lines
7.6 KiB
Swift
204 lines
7.6 KiB
Swift
import AppKit
|
|
import ShotdeckCore
|
|
|
|
@MainActor
|
|
public final class RegionPickerController {
|
|
private var windows: [RegionPickerWindow] = []
|
|
private var monitor: Any?
|
|
private var completion: (@MainActor (CaptureRegion?) -> Void)?
|
|
private var dragStart: NSPoint?
|
|
private var startScreen: NSScreen?
|
|
private var cursorPushed = false
|
|
|
|
public init() {}
|
|
|
|
/// Presents one overlay per attached screen. A pick already in progress is cancelled
|
|
/// (its completion called with nil) before the new one starts — never leaves a caller
|
|
/// waiting forever on a dropped re-entrant call.
|
|
public func pick(completion: @escaping @MainActor (CaptureRegion?) -> Void) {
|
|
if self.completion != nil {
|
|
finish(region: nil)
|
|
}
|
|
self.completion = completion
|
|
presentOverlays()
|
|
}
|
|
|
|
private func presentOverlays() {
|
|
let screens = NSScreen.screens
|
|
guard !screens.isEmpty else {
|
|
finish(region: nil)
|
|
return
|
|
}
|
|
|
|
NSCursor.crosshair.push()
|
|
cursorPushed = true
|
|
|
|
windows = screens.map { RegionPickerWindow(screen: $0) }
|
|
for window in windows {
|
|
window.orderFrontRegardless()
|
|
}
|
|
// Nonactivating panel can become key without activating the app; needed so
|
|
// Escape reaches the local monitor immediately, before any mouse click.
|
|
let mouse = NSEvent.mouseLocation
|
|
let keyWindow = windows.first(where: { $0.coveringScreen.frame.contains(mouse) })
|
|
?? windows[0]
|
|
keyWindow.makeKey()
|
|
|
|
monitor = NSEvent.addLocalMonitorForEvents(
|
|
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown]
|
|
) { [weak self] event in
|
|
guard let self else { return event }
|
|
// NSEvent is not Sendable; lift Sendable fields the handler needs.
|
|
// Prefer the event's window-local point converted to global AppKit
|
|
// coordinates; NSEvent.mouseLocation is only a fallback.
|
|
// keyCode is only valid on key events — reading it on a mouse event raises.
|
|
let type = event.type
|
|
let keyCode: UInt16 = (type == .keyDown) ? event.keyCode : 0
|
|
let locationInWindow = event.locationInWindow
|
|
let windowNumber = event.windowNumber
|
|
let consume = MainActor.assumeIsolated {
|
|
let location = self.globalAppKitLocation(
|
|
locationInWindow: locationInWindow,
|
|
windowNumber: windowNumber
|
|
)
|
|
return self.handle(type: type, keyCode: keyCode, location: location)
|
|
}
|
|
return consume ? nil : event
|
|
}
|
|
}
|
|
|
|
/// Returns `true` when the event should be swallowed.
|
|
private func handle(type: NSEvent.EventType, keyCode: UInt16, location: NSPoint?) -> Bool {
|
|
switch type {
|
|
case .keyDown:
|
|
if keyCode == 53 { // kVK_Escape
|
|
finish(region: nil)
|
|
return true
|
|
}
|
|
return false
|
|
case .leftMouseDown:
|
|
handleMouseDown(location: location)
|
|
return false
|
|
case .leftMouseDragged:
|
|
handleMouseDragged(location: location)
|
|
return false
|
|
case .leftMouseUp:
|
|
handleMouseUp(location: location)
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func handleMouseDown(location: NSPoint?) {
|
|
let point = location ?? NSEvent.mouseLocation
|
|
dragStart = point
|
|
startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) })
|
|
?? NSScreen.screens.first
|
|
guard let startScreen else { return }
|
|
let local = Self.localRect(
|
|
from: CGRect(origin: point, size: .zero).intersection(startScreen.frame),
|
|
on: startScreen
|
|
)
|
|
window(for: startScreen)?.updateSelection(localRect: local, sizeText: "0 x 0")
|
|
for window in windows where window.coveringScreen !== startScreen {
|
|
window.updateSelection(localRect: nil, sizeText: nil)
|
|
}
|
|
}
|
|
|
|
private func handleMouseDragged(location: NSPoint?) {
|
|
guard dragStart != nil, startScreen != nil else { return }
|
|
applyLiveSelection(current: location)
|
|
}
|
|
|
|
private func handleMouseUp(location: NSPoint?) {
|
|
guard let startScreen else {
|
|
dragStart = nil
|
|
return
|
|
}
|
|
guard let rect = currentClampedRect(current: location), rect.width >= 8, rect.height >= 8 else {
|
|
// Mis-click: reset drag state, leave every window open and fully dimmed.
|
|
dragStart = nil
|
|
self.startScreen = nil
|
|
for window in windows {
|
|
window.updateSelection(localRect: nil, sizeText: nil)
|
|
}
|
|
return
|
|
}
|
|
let region = CaptureRegion.fromAppKit(rect: rect, on: startScreen)
|
|
finish(region: region)
|
|
}
|
|
|
|
private func applyLiveSelection(current: NSPoint?) {
|
|
guard let startScreen, let rect = currentClampedRect(current: current) else { return }
|
|
let local = Self.localRect(from: rect, on: startScreen)
|
|
let text = "\(Int(rect.width)) x \(Int(rect.height))"
|
|
window(for: startScreen)?.updateSelection(localRect: local, sizeText: text)
|
|
for window in windows where window.coveringScreen !== startScreen {
|
|
window.updateSelection(localRect: nil, sizeText: nil)
|
|
}
|
|
}
|
|
|
|
/// Normalize the drag (so bottom-right → up-left is not misjudged) then clamp
|
|
/// to the screen the gesture started on — never selects across displays.
|
|
private func currentClampedRect(current: NSPoint?) -> CGRect? {
|
|
guard let dragStart, let startScreen else { return nil }
|
|
let current = current ?? NSEvent.mouseLocation
|
|
let normalized = CGRect(
|
|
x: min(dragStart.x, current.x),
|
|
y: min(dragStart.y, current.y),
|
|
width: abs(current.x - dragStart.x),
|
|
height: abs(current.y - dragStart.y)
|
|
)
|
|
return normalized.intersection(startScreen.frame)
|
|
}
|
|
|
|
/// Convert a monitored event's `locationInWindow` into global AppKit coordinates
|
|
/// (bottom-left origin, matching `NSEvent.mouseLocation` / `NSScreen.frame`).
|
|
private func globalAppKitLocation(locationInWindow: NSPoint, windowNumber: Int) -> NSPoint? {
|
|
if let window = windows.first(where: { $0.windowNumber == windowNumber }) {
|
|
return window.convertPoint(toScreen: locationInWindow)
|
|
}
|
|
if windowNumber != 0, let window = NSApp.window(withWindowNumber: windowNumber) {
|
|
return window.convertPoint(toScreen: locationInWindow)
|
|
}
|
|
if windowNumber == 0 {
|
|
return locationInWindow
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func window(for screen: NSScreen) -> RegionPickerWindow? {
|
|
windows.first { $0.coveringScreen === screen }
|
|
}
|
|
|
|
private static func localRect(from rect: CGRect, on screen: NSScreen) -> CGRect {
|
|
CGRect(
|
|
x: rect.minX - screen.frame.minX,
|
|
y: rect.minY - screen.frame.minY,
|
|
width: rect.width,
|
|
height: rect.height
|
|
)
|
|
}
|
|
|
|
private func finish(region: CaptureRegion?) {
|
|
if let monitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
self.monitor = nil
|
|
}
|
|
if cursorPushed {
|
|
NSCursor.pop()
|
|
cursorPushed = false
|
|
}
|
|
for window in windows {
|
|
window.orderOut(nil)
|
|
}
|
|
windows.removeAll()
|
|
dragStart = nil
|
|
startScreen = nil
|
|
let done = completion
|
|
completion = nil
|
|
done?(region)
|
|
}
|
|
}
|