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>
This commit is contained in:
@@ -48,20 +48,27 @@ public final class RegionPickerController {
|
||||
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown]
|
||||
) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
// NSEvent is not Sendable; lift the two Sendable fields the handler
|
||||
// needs. Mouse geometry is read from NSEvent.mouseLocation on the
|
||||
// main thread inside the isolated methods, per spec.
|
||||
// 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 = event.keyCode
|
||||
let keyCode: UInt16 = (type == .keyDown) ? event.keyCode : 0
|
||||
let locationInWindow = event.locationInWindow
|
||||
let windowNumber = event.windowNumber
|
||||
let consume = MainActor.assumeIsolated {
|
||||
self.handle(type: type, keyCode: keyCode)
|
||||
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) -> Bool {
|
||||
private func handle(type: NSEvent.EventType, keyCode: UInt16, location: NSPoint?) -> Bool {
|
||||
switch type {
|
||||
case .keyDown:
|
||||
if keyCode == 53 { // kVK_Escape
|
||||
@@ -70,21 +77,21 @@ public final class RegionPickerController {
|
||||
}
|
||||
return false
|
||||
case .leftMouseDown:
|
||||
handleMouseDown()
|
||||
handleMouseDown(location: location)
|
||||
return false
|
||||
case .leftMouseDragged:
|
||||
handleMouseDragged()
|
||||
handleMouseDragged(location: location)
|
||||
return false
|
||||
case .leftMouseUp:
|
||||
handleMouseUp()
|
||||
handleMouseUp(location: location)
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMouseDown() {
|
||||
let point = NSEvent.mouseLocation
|
||||
private func handleMouseDown(location: NSPoint?) {
|
||||
let point = location ?? NSEvent.mouseLocation
|
||||
dragStart = point
|
||||
startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) })
|
||||
?? NSScreen.screens.first
|
||||
@@ -99,17 +106,17 @@ public final class RegionPickerController {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMouseDragged() {
|
||||
private func handleMouseDragged(location: NSPoint?) {
|
||||
guard dragStart != nil, startScreen != nil else { return }
|
||||
applyLiveSelection()
|
||||
applyLiveSelection(current: location)
|
||||
}
|
||||
|
||||
private func handleMouseUp() {
|
||||
private func handleMouseUp(location: NSPoint?) {
|
||||
guard let startScreen else {
|
||||
dragStart = nil
|
||||
return
|
||||
}
|
||||
guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else {
|
||||
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
|
||||
@@ -122,8 +129,8 @@ public final class RegionPickerController {
|
||||
finish(region: region)
|
||||
}
|
||||
|
||||
private func applyLiveSelection() {
|
||||
guard let startScreen, let rect = currentClampedRect() else { return }
|
||||
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)
|
||||
@@ -134,9 +141,9 @@ public final class RegionPickerController {
|
||||
|
||||
/// 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() -> CGRect? {
|
||||
private func currentClampedRect(current: NSPoint?) -> CGRect? {
|
||||
guard let dragStart, let startScreen else { return nil }
|
||||
let current = NSEvent.mouseLocation
|
||||
let current = current ?? NSEvent.mouseLocation
|
||||
let normalized = CGRect(
|
||||
x: min(dragStart.x, current.x),
|
||||
y: min(dragStart.y, current.y),
|
||||
@@ -146,6 +153,21 @@ public final class RegionPickerController {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user