Initial commit
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user