Initial commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// CaptureEngine.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// ScreenCaptureKit replacement for Greenshot's WindowCapture / GDI capture code.
|
||||
|
||||
import AppKit
|
||||
import ScreenCaptureKit
|
||||
|
||||
/// A frozen snapshot of one display, used as the backdrop of the selection overlay.
|
||||
struct DisplaySnapshot {
|
||||
let screen: NSScreen
|
||||
let display: SCDisplay
|
||||
let image: CGImage
|
||||
/// Display bounds in CoreGraphics global coordinates (top-left origin, points).
|
||||
let cgFrame: CGRect
|
||||
var scale: CGFloat { CGFloat(image.width) / cgFrame.width }
|
||||
}
|
||||
|
||||
/// A window that can be picked in window capture mode.
|
||||
struct WindowInfo {
|
||||
let window: SCWindow
|
||||
/// Window frame in CoreGraphics global coordinates (top-left origin, points).
|
||||
var cgFrame: CGRect { window.frame }
|
||||
var title: String { window.title ?? "" }
|
||||
var applicationName: String { window.owningApplication?.applicationName ?? "" }
|
||||
var bundleID: String? { window.owningApplication?.bundleIdentifier }
|
||||
}
|
||||
|
||||
enum CaptureEngine {
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
@discardableResult
|
||||
static func ensurePermission() -> Bool {
|
||||
if CGPreflightScreenCaptureAccess() { return true }
|
||||
CGRequestScreenCaptureAccess()
|
||||
return CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
|
||||
// MARK: - Shareable content
|
||||
|
||||
static func shareableContent() async throws -> SCShareableContent {
|
||||
do {
|
||||
return try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
||||
} catch {
|
||||
throw CaptureError.permissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows worth offering to the user: on screen, reasonably sized, not our own overlay.
|
||||
static func selectableWindows(from content: SCShareableContent) -> [WindowInfo] {
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
return content.windows
|
||||
.filter { window in
|
||||
window.isOnScreen &&
|
||||
window.frame.width > 40 && window.frame.height > 40 &&
|
||||
window.owningApplication?.processID != ownPID
|
||||
}
|
||||
.map(WindowInfo.init)
|
||||
}
|
||||
|
||||
// MARK: - Snapshots
|
||||
|
||||
/// Grabs every display so the selection overlay can show a frozen desktop.
|
||||
static func snapshotAllDisplays() async throws -> [DisplaySnapshot] {
|
||||
let content = try await shareableContent()
|
||||
guard !content.displays.isEmpty else { throw CaptureError.noContent }
|
||||
|
||||
var snapshots: [DisplaySnapshot] = []
|
||||
for display in content.displays {
|
||||
guard let screen = ScreenGeometry.screen(for: display.displayID) else { continue }
|
||||
let image = try await capture(display: display, content: content, sourceRect: nil, screen: screen)
|
||||
snapshots.append(DisplaySnapshot(screen: screen,
|
||||
display: display,
|
||||
image: image,
|
||||
cgFrame: CGRect(x: CGFloat(display.frame.origin.x),
|
||||
y: CGFloat(display.frame.origin.y),
|
||||
width: CGFloat(display.width),
|
||||
height: CGFloat(display.height))))
|
||||
}
|
||||
guard !snapshots.isEmpty else { throw CaptureError.noContent }
|
||||
return snapshots
|
||||
}
|
||||
|
||||
private static func capture(display: SCDisplay,
|
||||
content: SCShareableContent,
|
||||
sourceRect: CGRect?,
|
||||
screen: NSScreen) async throws -> CGImage {
|
||||
let configuration = SCStreamConfiguration()
|
||||
let scale = screen.backingScaleFactor
|
||||
let rect = sourceRect ?? CGRect(x: 0, y: 0, width: display.width, height: display.height)
|
||||
configuration.sourceRect = rect
|
||||
configuration.width = Int((rect.width * scale).rounded())
|
||||
configuration.height = Int((rect.height * scale).rounded())
|
||||
configuration.scalesToFit = false
|
||||
configuration.showsCursor = Configuration.shared.captureMousepointer
|
||||
configuration.captureResolution = .best
|
||||
if #available(macOS 14.0, *) {
|
||||
configuration.ignoreShadowsSingleWindow = true
|
||||
}
|
||||
|
||||
let ownWindows = content.windows.filter {
|
||||
$0.owningApplication?.processID == ProcessInfo.processInfo.processIdentifier
|
||||
}
|
||||
let filter = SCContentFilter(display: display, excludingWindows: ownWindows)
|
||||
|
||||
do {
|
||||
return try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
||||
} catch {
|
||||
throw CaptureError.captureFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public capture entry points
|
||||
|
||||
/// Full screen capture of the display the mouse is on, or of a given screen.
|
||||
static func captureFullscreen(screen requested: NSScreen? = nil) async throws -> Capture {
|
||||
let content = try await shareableContent()
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
let screen = requested ?? ScreenGeometry.screen(containing: mouseLocation) ?? NSScreen.main
|
||||
guard let screen,
|
||||
let displayID = ScreenGeometry.displayID(for: screen),
|
||||
let display = content.displays.first(where: { $0.displayID == displayID })
|
||||
else { throw CaptureError.noContent }
|
||||
|
||||
let image = try await capture(display: display, content: content, sourceRect: nil, screen: screen)
|
||||
|
||||
var details = CaptureDetails()
|
||||
details.captureMode = .fullscreen
|
||||
details.title = screen.localizedName
|
||||
details.scaleFactor = screen.backingScaleFactor
|
||||
details.screenBounds = CGRect(x: CGFloat(display.frame.origin.x),
|
||||
y: CGFloat(display.frame.origin.y),
|
||||
width: CGFloat(display.width),
|
||||
height: CGFloat(display.height))
|
||||
return Capture(image: image, details: details)
|
||||
}
|
||||
|
||||
/// Captures one window on its own, without whatever overlaps it.
|
||||
static func captureWindow(_ info: WindowInfo) async throws -> Capture {
|
||||
let configuration = SCStreamConfiguration()
|
||||
let screen = ScreenGeometry.screen(containing: ScreenGeometry.appKitRect(fromCG: info.cgFrame).center)
|
||||
?? NSScreen.main
|
||||
let scale = screen?.backingScaleFactor ?? 2
|
||||
configuration.width = Int((info.cgFrame.width * scale).rounded())
|
||||
configuration.height = Int((info.cgFrame.height * scale).rounded())
|
||||
configuration.showsCursor = false
|
||||
configuration.scalesToFit = false
|
||||
configuration.captureResolution = .best
|
||||
if #available(macOS 14.0, *) {
|
||||
configuration.ignoreShadowsSingleWindow = Configuration.shared.windowCaptureMode != .windowWithShadow
|
||||
}
|
||||
|
||||
let filter = SCContentFilter(desktopIndependentWindow: info.window)
|
||||
let image: CGImage
|
||||
do {
|
||||
image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
||||
} catch {
|
||||
throw CaptureError.captureFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
var details = CaptureDetails()
|
||||
details.captureMode = .window
|
||||
details.title = info.title.isEmpty ? info.applicationName : info.title
|
||||
details.applicationName = info.applicationName
|
||||
details.applicationBundleID = info.bundleID
|
||||
details.scaleFactor = scale
|
||||
details.screenBounds = info.cgFrame
|
||||
return Capture(image: image, details: details)
|
||||
}
|
||||
|
||||
/// Crops a region out of an already frozen display snapshot.
|
||||
static func crop(snapshot: DisplaySnapshot, cgRect: CGRect, mode: CaptureMode) throws -> Capture {
|
||||
let local = CGRect(x: cgRect.origin.x - snapshot.cgFrame.origin.x,
|
||||
y: cgRect.origin.y - snapshot.cgFrame.origin.y,
|
||||
width: cgRect.width,
|
||||
height: cgRect.height)
|
||||
guard let cropped = ScreenGeometry.crop(snapshot.image, toPointRect: local, scale: snapshot.scale) else {
|
||||
throw CaptureError.captureFailed("The selected region was empty.")
|
||||
}
|
||||
var details = CaptureDetails()
|
||||
details.captureMode = mode
|
||||
details.title = snapshot.screen.localizedName
|
||||
details.scaleFactor = snapshot.scale
|
||||
details.screenBounds = cgRect
|
||||
return Capture(image: cropped, details: details)
|
||||
}
|
||||
|
||||
// MARK: - Feedback
|
||||
|
||||
@MainActor
|
||||
static func playFeedback(on rect: CGRect?) {
|
||||
if Configuration.shared.playCameraSound {
|
||||
NSSound(named: NSSound.Name("Grab"))?.play()
|
||||
}
|
||||
if Configuration.shared.showFlashAnimation, let rect {
|
||||
FlashOverlay.flash(appKitRect: ScreenGeometry.appKitRect(fromCG: rect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CGRect {
|
||||
var center: CGPoint { CGPoint(x: midX, y: midY) }
|
||||
}
|
||||
|
||||
/// Short white flash over the captured area, the equivalent of Greenshot's capture flash.
|
||||
@MainActor
|
||||
final class FlashOverlay {
|
||||
private static var windows: [NSWindow] = []
|
||||
|
||||
static func flash(appKitRect rect: CGRect) {
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
let window = NSWindow(contentRect: rect, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .white
|
||||
window.alphaValue = 0.7
|
||||
window.level = .screenSaver
|
||||
window.ignoresMouseEvents = true
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||
window.orderFrontRegardless()
|
||||
windows.append(window)
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
// Run on the main thread by contract, so main actor isolation can be assumed.
|
||||
MainActor.assumeIsolated {
|
||||
window.orderOut(nil)
|
||||
FlashOverlay.windows.removeAll { $0 === window }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ScreenGeometry.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Conversions between AppKit's bottom-left global space and CoreGraphics'
|
||||
// top-left global space, plus display lookups.
|
||||
|
||||
import AppKit
|
||||
|
||||
enum ScreenGeometry {
|
||||
|
||||
/// Height of the primary display, the anchor for both coordinate systems.
|
||||
static var primaryHeight: CGFloat {
|
||||
NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height
|
||||
?? NSScreen.screens.first?.frame.height
|
||||
?? 0
|
||||
}
|
||||
|
||||
/// AppKit global rect (bottom-left origin) to CoreGraphics global rect (top-left origin).
|
||||
static func cgRect(fromAppKit rect: CGRect) -> CGRect {
|
||||
CGRect(x: rect.origin.x,
|
||||
y: primaryHeight - rect.origin.y - rect.height,
|
||||
width: rect.width,
|
||||
height: rect.height)
|
||||
}
|
||||
|
||||
/// CoreGraphics global rect (top-left origin) to AppKit global rect (bottom-left origin).
|
||||
static func appKitRect(fromCG rect: CGRect) -> CGRect {
|
||||
CGRect(x: rect.origin.x,
|
||||
y: primaryHeight - rect.origin.y - rect.height,
|
||||
width: rect.width,
|
||||
height: rect.height)
|
||||
}
|
||||
|
||||
static func displayID(for screen: NSScreen) -> CGDirectDisplayID? {
|
||||
screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
|
||||
}
|
||||
|
||||
static func screen(for displayID: CGDirectDisplayID) -> NSScreen? {
|
||||
NSScreen.screens.first { ScreenGeometry.displayID(for: $0) == displayID }
|
||||
}
|
||||
|
||||
static func screen(containing point: CGPoint) -> NSScreen? {
|
||||
NSScreen.screens.first { $0.frame.contains(point) } ?? NSScreen.main
|
||||
}
|
||||
|
||||
/// Crops a CGImage using a rect expressed in points relative to the image's top-left corner.
|
||||
static func crop(_ image: CGImage, toPointRect rect: CGRect, scale: CGFloat) -> CGImage? {
|
||||
let pixelRect = CGRect(x: (rect.origin.x * scale).rounded(.down),
|
||||
y: (rect.origin.y * scale).rounded(.down),
|
||||
width: (rect.width * scale).rounded(),
|
||||
height: (rect.height * scale).rounded())
|
||||
.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
|
||||
guard pixelRect.width >= 1, pixelRect.height >= 1 else { return nil }
|
||||
return image.cropping(to: pixelRect)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user