Files
shotdeck/Sources/Shotdeck/RegionPickerController.swift
kua-agentandClaude Fable 5 f2088bbed8 fix: picker first-mouse acceptance + event-based drag coords; in-process picker selftest
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>
2026-09-01 21:46:54 +04:00

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)
}
}