A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
196 lines
7.7 KiB
Swift
196 lines
7.7 KiB
Swift
import AppKit
|
||
|
||
/// Renders the capture overlay. All interaction state lives in
|
||
/// CaptureController (driven by the CGEventTap); this view only draws.
|
||
final class OverlayWindow: NSPanel {
|
||
init(screen: NSScreen, controller: CaptureController) {
|
||
let content = OverlayView()
|
||
super.init(
|
||
// Non-activating: the overlay must NOT steal focus, so the app
|
||
// being captured keeps its active appearance (undimmed chrome).
|
||
// Input comes from the CGEventTap, not panel events.
|
||
contentRect: screen.frame,
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
contentView = content
|
||
content.controller = controller
|
||
level = .screenSaver
|
||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||
backgroundColor = .clear
|
||
isOpaque = false
|
||
hasShadow = false
|
||
isReleasedWhenClosed = false
|
||
acceptsMouseMovedEvents = true
|
||
}
|
||
|
||
override var canBecomeKey: Bool { true }
|
||
}
|
||
|
||
final class OverlayView: NSView {
|
||
weak var controller: CaptureController?
|
||
/// In frozen mode: the still image to display for this screen (already
|
||
/// full resolution). Nil = live mode (dim the real screen).
|
||
var frozenImage: CGImage?
|
||
|
||
override var acceptsFirstResponder: Bool { true }
|
||
|
||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||
|
||
override func resetCursorRects() {
|
||
addCursorRect(bounds, cursor: .crosshair)
|
||
}
|
||
|
||
/// The screen frame this overlay covers, in AppKit global space.
|
||
private var screenFrame: NSRect {
|
||
guard let window else { return .zero }
|
||
return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
|
||
}
|
||
|
||
/// Convert an AppKit-global rect into this view's coordinates.
|
||
private func viewRect(_ global: NSRect) -> NSRect {
|
||
let sf = screenFrame
|
||
return NSRect(
|
||
x: global.origin.x - sf.origin.x,
|
||
y: global.origin.y - sf.origin.y,
|
||
width: global.width,
|
||
height: global.height
|
||
)
|
||
}
|
||
|
||
/// Draw the frozen snapshot filling the screen, then replace its centre
|
||
/// hole with the live... no: the image IS the content, drawn opaque.
|
||
private func drawFrozen(_ image: CGImage) {
|
||
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
||
ctx.saveGState()
|
||
ctx.interpolationQuality = .high
|
||
// Image is in pixels, view in points — draw into the view bounds.
|
||
ctx.draw(image, in: bounds)
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
private func drawSizeLabel(for sel: NSRect, in vf: NSRect) {
|
||
let label = "\(Int(sel.width)) × \(Int(sel.height))"
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: NSColor.white,
|
||
]
|
||
let labelSize = label.size(withAttributes: attrs)
|
||
let labelRect = NSRect(
|
||
x: vf.maxX - labelSize.width - 8,
|
||
y: vf.minY - labelSize.height - 10,
|
||
width: labelSize.width + 8,
|
||
height: labelSize.height + 6
|
||
)
|
||
NSColor.black.withAlphaComponent(0.6).setFill()
|
||
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
|
||
label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs)
|
||
}
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
if let frozenImage {
|
||
drawFrozen(frozenImage)
|
||
} else {
|
||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||
NSBezierPath(rect: bounds).fill()
|
||
}
|
||
|
||
// Esc hint — the only way out once clicks are consumed by the tap.
|
||
let hint = "Drag to select · hover + click a window to capture it · Esc to cancel"
|
||
let hintAttrs: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: NSColor.white.withAlphaComponent(0.85),
|
||
]
|
||
let hintSize = hint.size(withAttributes: hintAttrs)
|
||
let hintRect = NSRect(
|
||
x: (bounds.width - hintSize.width) / 2 - 10,
|
||
y: bounds.height - 46,
|
||
width: hintSize.width + 20,
|
||
height: hintSize.height + 12
|
||
)
|
||
NSColor.black.withAlphaComponent(0.65).setFill()
|
||
NSBezierPath(roundedRect: hintRect, xRadius: 8, yRadius: 8).fill()
|
||
hint.draw(at: NSPoint(x: hintRect.minX + 10, y: hintRect.minY + 6), withAttributes: hintAttrs)
|
||
|
||
guard let controller else { return }
|
||
|
||
// Frozen mode: no window highlight; the marquee is cut straight out
|
||
// of the still image so it shows the frozen pixels.
|
||
if frozenImage != nil {
|
||
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
|
||
let vf = viewRect(sel)
|
||
// Dim everything except the selection (even-odd fill).
|
||
let maskPath = CGMutablePath()
|
||
maskPath.addRect(bounds)
|
||
maskPath.addRect(vf)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
ctx?.addPath(maskPath)
|
||
ctx?.fillPath(using: .evenOdd)
|
||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||
ctx?.addPath(maskPath)
|
||
ctx?.fillPath(using: .evenOdd)
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
let border = NSBezierPath(rect: vf)
|
||
border.lineWidth = 1.5
|
||
NSColor.controlAccentColor.setStroke()
|
||
border.stroke()
|
||
drawSizeLabel(for: sel, in: vf)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Highlight the hovered window (rounded, like real window corners)
|
||
if let frame = controller.hoverFrame {
|
||
let vf = viewRect(frame)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
NSGraphicsContext.current?.compositingOperation = .clear
|
||
NSColor.black.setFill()
|
||
NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
let border = NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10)
|
||
border.lineWidth = 2
|
||
NSColor.controlAccentColor.setStroke()
|
||
border.stroke()
|
||
return
|
||
}
|
||
|
||
// Marquee selection
|
||
guard let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 else { return }
|
||
let vf = viewRect(sel)
|
||
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
NSGraphicsContext.current?.compositingOperation = .clear
|
||
NSColor.black.setFill()
|
||
NSBezierPath(rect: vf).fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
let border = NSBezierPath(rect: vf)
|
||
border.lineWidth = 1.5
|
||
NSColor.controlAccentColor.setStroke()
|
||
border.stroke()
|
||
|
||
let label = "\(Int(sel.width)) × \(Int(sel.height))"
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: NSColor.white,
|
||
]
|
||
let labelSize = label.size(withAttributes: attrs)
|
||
let labelRect = NSRect(
|
||
x: vf.maxX - labelSize.width - 8,
|
||
y: vf.minY - labelSize.height - 10,
|
||
width: labelSize.width + 8,
|
||
height: labelSize.height + 6
|
||
)
|
||
NSColor.black.withAlphaComponent(0.6).setFill()
|
||
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
|
||
label.draw(
|
||
at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3),
|
||
withAttributes: attrs
|
||
)
|
||
}
|
||
}
|