// 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() } }