Frozen mode showed the still image at full brightness until a selection
existed, unlike live mode's dark veil. It now draws the same 35% veil over
everything and re-draws the selected area from the snapshot, keeping the
selection bright. The hint text matches the mode.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
193 lines
7.5 KiB
Swift
193 lines
7.5 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 = frozenImage != nil
|
||
? "Frozen screen · drag to select · Esc to cancel"
|
||
: "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: same look as live mode — a dark veil over everything,
|
||
// with the selection showing the frozen pixels undimmed.
|
||
if let frozenImage {
|
||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||
NSBezierPath(rect: bounds).fill()
|
||
|
||
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
|
||
let vf = viewRect(sel)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
NSBezierPath(rect: vf).addClip()
|
||
drawFrozen(frozenImage)
|
||
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
|
||
)
|
||
}
|
||
}
|