381 lines
13 KiB
Swift
381 lines
13 KiB
Swift
// 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)
|
||
|
||
NSColor(calibratedWhite: 0, alpha: 0.75).setFill()
|
||
NSBezierPath(roundedRect: box, xRadius: 5, yRadius: 5).fill()
|
||
(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()
|
||
}
|
||
}
|