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>
114 lines
4.1 KiB
Swift
114 lines
4.1 KiB
Swift
import AppKit
|
|
|
|
/// Full-screen dim overlay for one attached display. Subclasses `NSPanel` with
|
|
/// `.nonactivatingPanel` so it can accept mouse and key events without activating
|
|
/// Shotdeck or stealing the frontmost app beyond those events.
|
|
@MainActor
|
|
final class RegionPickerWindow: NSPanel {
|
|
let coveringScreen: NSScreen
|
|
private let pickerView: RegionPickerView
|
|
|
|
init(screen: NSScreen) {
|
|
self.coveringScreen = screen
|
|
self.pickerView = RegionPickerView(frame: CGRect(origin: .zero, size: screen.frame.size))
|
|
super.init(
|
|
contentRect: screen.frame,
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
setFrame(screen.frame, display: false)
|
|
level = .screenSaver
|
|
isOpaque = false
|
|
backgroundColor = .clear
|
|
ignoresMouseEvents = false
|
|
hasShadow = false
|
|
isMovable = false
|
|
hidesOnDeactivate = false
|
|
isFloatingPanel = true
|
|
becomesKeyOnlyIfNeeded = false
|
|
animationBehavior = .none
|
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
acceptsMouseMovedEvents = true
|
|
contentView = pickerView
|
|
}
|
|
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { false }
|
|
|
|
/// `localRect` is in this window's coordinates (screen.frame origin subtracted).
|
|
/// Pass `nil` to restore the full dim with no punch and no size chip.
|
|
func updateSelection(localRect: CGRect?, sizeText: String?) {
|
|
pickerView.selectionLocalRect = localRect
|
|
pickerView.sizeChipText = sizeText
|
|
pickerView.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
/// Draws ~35% black over the screen with an even-odd punch for the live selection,
|
|
/// a thin light border, and the size-readout chip.
|
|
@MainActor
|
|
private final class RegionPickerView: NSView {
|
|
var selectionLocalRect: CGRect?
|
|
var sizeChipText: String?
|
|
|
|
override var isOpaque: Bool { false }
|
|
override var acceptsFirstResponder: Bool { true }
|
|
// Accessory-app trap: first click on an inactive overlay is first-mouse and is dropped unless accepted.
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
|
|
let dim = NSColor.black.withAlphaComponent(0.35)
|
|
let path = NSBezierPath(rect: bounds)
|
|
if let sel = selectionLocalRect, sel.width > 0, sel.height > 0 {
|
|
path.append(NSBezierPath(rect: sel))
|
|
path.windingRule = .evenOdd
|
|
}
|
|
dim.setFill()
|
|
path.fill()
|
|
|
|
guard let sel = selectionLocalRect, sel.width > 0, sel.height > 0 else { return }
|
|
|
|
NSColor.white.withAlphaComponent(0.85).setStroke()
|
|
let border = NSBezierPath(rect: sel)
|
|
border.lineWidth = 1
|
|
border.stroke()
|
|
|
|
if let text = sizeChipText {
|
|
drawSizeChip(text, above: sel)
|
|
}
|
|
}
|
|
|
|
private func drawSizeChip(_ text: String, above sel: CGRect) {
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
|
.foregroundColor: NSColor.white,
|
|
]
|
|
let nsText = text as NSString
|
|
let textSize = nsText.size(withAttributes: attrs)
|
|
let padX: CGFloat = 8
|
|
let padY: CGFloat = 4
|
|
let chipSize = CGSize(
|
|
width: ceil(textSize.width) + padX * 2,
|
|
height: ceil(textSize.height) + padY * 2
|
|
)
|
|
|
|
var origin = CGPoint(x: sel.minX, y: sel.maxY + 6)
|
|
if origin.y + chipSize.height > bounds.maxY {
|
|
origin.y = sel.minY - 6 - chipSize.height
|
|
}
|
|
origin.x = min(max(origin.x, bounds.minX + 4), bounds.maxX - chipSize.width - 4)
|
|
origin.y = min(max(origin.y, bounds.minY + 4), bounds.maxY - chipSize.height - 4)
|
|
|
|
let chipRect = CGRect(origin: origin, size: chipSize)
|
|
NSColor.black.withAlphaComponent(0.75).setFill()
|
|
NSBezierPath(roundedRect: chipRect, xRadius: 4, yRadius: 4).fill()
|
|
nsText.draw(
|
|
at: CGPoint(x: chipRect.minX + padX, y: chipRect.minY + padY),
|
|
withAttributes: attrs
|
|
)
|
|
}
|
|
}
|