Files
Snaggle/GreenshotMac/Capture/CaptureOverlay.swift
T
2026-09-17 16:15:02 -05:00

385 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CaptureOverlay.swift
// GreenshotMac
//
// The equivalent of Greenshot's CaptureForm: a frozen, dimmed copy of the desktop
// on which the user drags a region or clicks a window.
import AppKit
import ScreenCaptureKit
enum OverlayResult {
case region(DisplaySnapshot, CGRect) // rect in CoreGraphics global coordinates
case window(WindowInfo)
case cancelled
}
enum OverlayMode {
case region
case window
}
@MainActor
final class CaptureOverlayController {
private var windows: [OverlayWindow] = []
private let snapshots: [DisplaySnapshot]
private let windowInfos: [WindowInfo]
private var mode: OverlayMode
private var completion: ((OverlayResult) -> Void)?
private var finished = false
init(snapshots: [DisplaySnapshot], windows windowInfos: [WindowInfo], mode: OverlayMode) {
self.snapshots = snapshots
self.windowInfos = windowInfos
self.mode = mode
}
func present(completion: @escaping (OverlayResult) -> Void) {
self.completion = completion
for snapshot in snapshots {
let window = OverlayWindow(snapshot: snapshot, windows: windowInfos, mode: mode, controller: self)
windows.append(window)
window.orderFrontRegardless()
}
windows.first?.makeKey()
NSApp.activate(ignoringOtherApps: true)
}
/// Keeps every display's overlay in the same mode.
func setMode(_ newMode: OverlayMode) {
mode = newMode
for window in windows { window.overlayView.mode = newMode }
}
func toggleMode() {
setMode(mode == .region ? .window : .region)
}
func finish(_ result: OverlayResult) {
guard !finished else { return }
finished = true
for window in windows { window.orderOut(nil) }
windows.removeAll()
completion?(result)
completion = nil
}
}
// MARK: - Window
@MainActor
final class OverlayWindow: NSWindow {
let overlayView: OverlayView
init(snapshot: DisplaySnapshot, windows: [WindowInfo], mode: OverlayMode, controller: CaptureOverlayController) {
overlayView = OverlayView(snapshot: snapshot, windows: windows, mode: mode, controller: controller)
super.init(contentRect: snapshot.screen.frame,
styleMask: .borderless,
backing: .buffered,
defer: false)
isOpaque = false
backgroundColor = .clear
level = .screenSaver
hasShadow = false
ignoresMouseEvents = false
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
acceptsMouseMovedEvents = true
contentView = overlayView
setFrame(snapshot.screen.frame, display: true)
}
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
// MARK: - View
@MainActor
final class OverlayView: NSView {
private let snapshot: DisplaySnapshot
private let windowInfos: [WindowInfo]
private weak var controller: CaptureOverlayController?
private var backdrop: NSImage
private var anchor: CGPoint?
private var current: CGPoint?
private var mouseLocation: CGPoint = .zero
private var hoveredWindow: WindowInfo?
private var trackingArea: NSTrackingArea?
var mode: OverlayMode {
didSet {
hoveredWindow = nil
anchor = nil
current = nil
needsDisplay = true
}
}
init(snapshot: DisplaySnapshot, windows: [WindowInfo], mode: OverlayMode, controller: CaptureOverlayController) {
self.snapshot = snapshot
self.windowInfos = windows
self.mode = mode
self.controller = controller
self.backdrop = NSImage(cgImage: snapshot.image,
size: CGSize(width: snapshot.cgFrame.width, height: snapshot.cgFrame.height))
super.init(frame: CGRect(origin: .zero, size: snapshot.screen.frame.size))
wantsLayer = true
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
// MARK: Tracking and cursor
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let trackingArea { removeTrackingArea(trackingArea) }
let area = NSTrackingArea(rect: bounds,
options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
addTrackingArea(area)
trackingArea = area
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .crosshair)
}
override var acceptsFirstResponder: Bool { true }
// MARK: Coordinate helpers
/// View point (bottom-left origin) to CoreGraphics global point (top-left origin).
private func cgPoint(from viewPoint: CGPoint) -> CGPoint {
CGPoint(x: snapshot.cgFrame.origin.x + viewPoint.x,
y: snapshot.cgFrame.origin.y + (bounds.height - viewPoint.y))
}
/// CoreGraphics global rect to a rect in this view.
private func viewRect(fromCG rect: CGRect) -> CGRect {
CGRect(x: rect.origin.x - snapshot.cgFrame.origin.x,
y: bounds.height - (rect.origin.y - snapshot.cgFrame.origin.y) - rect.height,
width: rect.width,
height: rect.height)
}
private var selectionRect: CGRect? {
guard let anchor, let current else { return nil }
let rect = CGRect(x: min(anchor.x, current.x),
y: min(anchor.y, current.y),
width: abs(current.x - anchor.x),
height: abs(current.y - anchor.y))
return rect.width >= 1 && rect.height >= 1 ? rect : nil
}
private var highlightRect: CGRect? {
switch mode {
case .region:
return selectionRect
case .window:
guard let hoveredWindow else { return nil }
return viewRect(fromCG: hoveredWindow.cgFrame).intersection(bounds)
}
}
// MARK: Mouse
override func mouseMoved(with event: NSEvent) {
mouseLocation = convert(event.locationInWindow, from: nil)
if mode == .window {
updateHoveredWindow()
}
needsDisplay = true
}
override func mouseDown(with event: NSEvent) {
mouseLocation = convert(event.locationInWindow, from: nil)
if mode == .window {
updateHoveredWindow()
if let hoveredWindow {
controller?.finish(.window(hoveredWindow))
}
return
}
anchor = mouseLocation
current = mouseLocation
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
mouseLocation = convert(event.locationInWindow, from: nil)
if mode == .region {
if anchor == nil { anchor = mouseLocation }
current = mouseLocation
}
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
mouseLocation = convert(event.locationInWindow, from: nil)
guard mode == .region else { return }
current = mouseLocation
guard let rect = selectionRect, rect.width >= 3, rect.height >= 3 else {
anchor = nil
current = nil
needsDisplay = true
return
}
let topLeft = cgPoint(from: CGPoint(x: rect.minX, y: rect.maxY))
let cgRect = CGRect(x: topLeft.x.rounded(), y: topLeft.y.rounded(),
width: rect.width.rounded(), height: rect.height.rounded())
controller?.finish(.region(snapshot, cgRect))
}
override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 53: // Escape
controller?.finish(.cancelled)
case 49: // Space
controller?.toggleMode()
default:
super.keyDown(with: event)
}
}
private func updateHoveredWindow() {
let point = cgPoint(from: mouseLocation)
hoveredWindow = windowInfos.first { $0.cgFrame.contains(point) }
}
// MARK: Drawing
override func draw(_ dirtyRect: CGRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
backdrop.draw(in: bounds, from: .zero, operation: .copy, fraction: 1.0)
let highlight = highlightRect
NSColor(calibratedWhite: 0, alpha: 0.42).setFill()
if let highlight {
// Dim everything but the highlighted area.
let path = NSBezierPath(rect: bounds)
path.append(NSBezierPath(rect: highlight))
path.windingRule = .evenOdd
path.fill()
NSColor.systemGreen.setStroke()
let border = NSBezierPath(rect: highlight.insetBy(dx: -0.5, dy: -0.5))
border.lineWidth = 1.5
border.stroke()
} else {
bounds.fill()
drawCrosshair(in: context)
}
if Configuration.shared.showSelectionSize, let highlight {
drawSizeLabel(for: highlight)
}
if Configuration.shared.showMagnifier, highlight == nil || mode == .region {
drawMagnifier()
}
if highlight == nil {
drawHint()
}
}
private func drawCrosshair(in context: CGContext) {
context.saveGState()
context.setStrokeColor(NSColor.systemGreen.withAlphaComponent(0.8).cgColor)
context.setLineWidth(1)
context.move(to: CGPoint(x: 0, y: mouseLocation.y))
context.addLine(to: CGPoint(x: bounds.width, y: mouseLocation.y))
context.move(to: CGPoint(x: mouseLocation.x, y: 0))
context.addLine(to: CGPoint(x: mouseLocation.x, y: bounds.height))
context.strokePath()
context.restoreGState()
}
private func drawSizeLabel(for rect: CGRect) {
let scale = snapshot.scale
let text: String
switch mode {
case .region:
text = "\(Int(rect.width * scale)) × \(Int(rect.height * scale)) px"
case .window:
let title = hoveredWindow.map { $0.applicationName.isEmpty ? $0.title : $0.applicationName } ?? ""
text = "\(title) \(Int(rect.width * scale)) × \(Int(rect.height * scale)) px"
}
draw(label: text, near: CGPoint(x: rect.minX, y: rect.maxY + 6))
}
private func drawHint() {
let text = mode == .region
? "Drag to select a region · Space for window mode · Esc to cancel"
: "Click a window to capture it · Space for region mode · Esc to cancel"
draw(label: text, near: CGPoint(x: mouseLocation.x + 18, y: mouseLocation.y - 34))
}
private func draw(label text: String, near point: CGPoint) {
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white
]
let size = (text as NSString).size(withAttributes: attributes)
var box = CGRect(x: point.x, y: point.y, width: size.width + 12, height: size.height + 8)
box.origin.x = min(max(4, box.origin.x), bounds.width - box.width - 4)
box.origin.y = min(max(4, box.origin.y), bounds.height - box.height - 4)
let chip = NSBezierPath(roundedRect: box, xRadius: Theme.controlRadius, yRadius: Theme.controlRadius)
NSColor(calibratedWhite: 0, alpha: 0.72).setFill()
chip.fill()
NSColor(calibratedWhite: 1, alpha: 0.18).setStroke()
chip.lineWidth = 1
chip.stroke()
(text as NSString).draw(at: CGPoint(x: box.minX + 6, y: box.minY + 4), withAttributes: attributes)
}
private func drawMagnifier() {
let magnification: CGFloat = 8
let side: CGFloat = 120
let sourceSide = side / magnification
let cgCursor = cgPoint(from: mouseLocation)
let localPoint = CGPoint(x: cgCursor.x - snapshot.cgFrame.origin.x,
y: cgCursor.y - snapshot.cgFrame.origin.y)
let sourceRect = CGRect(x: localPoint.x - sourceSide / 2,
y: localPoint.y - sourceSide / 2,
width: sourceSide,
height: sourceSide)
guard let zoomed = ScreenGeometry.crop(snapshot.image, toPointRect: sourceRect, scale: snapshot.scale) else {
return
}
var frame = CGRect(x: mouseLocation.x + 20, y: mouseLocation.y + 20, width: side, height: side)
if frame.maxX > bounds.width - 8 { frame.origin.x = mouseLocation.x - side - 20 }
if frame.maxY > bounds.height - 8 { frame.origin.y = mouseLocation.y - side - 20 }
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
context.interpolationQuality = .none
let clip = NSBezierPath(roundedRect: frame, xRadius: 8, yRadius: 8)
clip.addClip()
context.translateBy(x: frame.minX, y: frame.minY + frame.height)
context.scaleBy(x: 1, y: -1)
context.draw(zoomed, in: CGRect(origin: .zero, size: frame.size))
context.restoreGState()
NSColor.white.withAlphaComponent(0.9).setStroke()
clip.lineWidth = 1
clip.stroke()
// Centre marker showing the exact pixel under the cursor.
NSColor.systemGreen.setStroke()
let marker = NSBezierPath(rect: CGRect(x: frame.midX - magnification / 2,
y: frame.midY - magnification / 2,
width: magnification,
height: magnification))
marker.lineWidth = 1
marker.stroke()
}
}