Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,244 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A10000000000000000000001 /* GreenshotMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GreenshotMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A10000000000000000000010 /* Exceptions for "GreenshotMac" folder in "GreenshotMac" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Resources/Info.plist,
|
||||
);
|
||||
target = A10000000000000000000004 /* GreenshotMac */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A10000000000000000000002 /* GreenshotMac */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A10000000000000000000010 /* Exceptions for "GreenshotMac" folder in "GreenshotMac" target */,
|
||||
);
|
||||
path = GreenshotMac;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A10000000000000000000005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A10000000000000000000000 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000000000000000000002 /* GreenshotMac */,
|
||||
A10000000000000000000003 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000000000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000000000000000000001 /* GreenshotMac.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A10000000000000000000004 /* GreenshotMac */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "GreenshotMac" */;
|
||||
buildPhases = (
|
||||
A10000000000000000000006 /* Sources */,
|
||||
A10000000000000000000005 /* Frameworks */,
|
||||
A10000000000000000000007 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A10000000000000000000002 /* GreenshotMac */,
|
||||
);
|
||||
name = GreenshotMac;
|
||||
productName = GreenshotMac;
|
||||
productReference = A10000000000000000000001 /* GreenshotMac.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A10000000000000000000008 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
A10000000000000000000004 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A10000000000000000000011 /* Build configuration list for PBXProject "GreenshotMac" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A10000000000000000000000;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A10000000000000000000003 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A10000000000000000000004 /* GreenshotMac */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A10000000000000000000007 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A10000000000000000000006 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A10000000000000000000012 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ARCHS = arm64;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A10000000000000000000013 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ARCHS = arm64;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A10000000000000000000014 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = GreenshotMac/Resources/GreenshotMac.entitlements;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = GreenshotMac/Resources/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.greenshot.mac.GreenshotMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A10000000000000000000015 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = GreenshotMac/Resources/GreenshotMac.entitlements;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = GreenshotMac/Resources/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.greenshot.mac.GreenshotMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A10000000000000000000009 /* Build configuration list for PBXNativeTarget "GreenshotMac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A10000000000000000000014 /* Debug */,
|
||||
A10000000000000000000015 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A10000000000000000000011 /* Build configuration list for PBXProject "GreenshotMac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A10000000000000000000012 /* Debug */,
|
||||
A10000000000000000000013 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A10000000000000000000008 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion = "1600" version = "1.7">
|
||||
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000000000000000000004"
|
||||
BuildableName = "GreenshotMac.app"
|
||||
BlueprintName = "GreenshotMac"
|
||||
ReferencedContainer = "container:GreenshotMac.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000000000000000000004"
|
||||
BuildableName = "GreenshotMac.app"
|
||||
BlueprintName = "GreenshotMac"
|
||||
ReferencedContainer = "container:GreenshotMac.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000000000000000000004"
|
||||
BuildableName = "GreenshotMac.app"
|
||||
BlueprintName = "GreenshotMac"
|
||||
ReferencedContainer = "container:GreenshotMac.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,59 @@
|
||||
// AppDelegate.swift
|
||||
// GreenshotMac
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
private var statusMenu: StatusMenuController?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// A second copy would install its own menu bar item and register the same global
|
||||
// hotkeys, which the first copy already owns. Hand over to the running instance.
|
||||
if let other = AppDelegate.otherRunningInstance() {
|
||||
NSLog("GreenshotMac: another instance is already running (pid \(other.processIdentifier)); exiting.")
|
||||
other.activate()
|
||||
Notifier.show(title: "GreenshotMac is already running",
|
||||
body: "Use the camera icon in the menu bar.")
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
statusMenu = StatusMenuController()
|
||||
MainMenuBuilder.install()
|
||||
HotkeyBindings.shared.registerAll()
|
||||
|
||||
if !CGPreflightScreenCaptureAccess() {
|
||||
// Triggers the system prompt on first launch so the first capture just works.
|
||||
CGRequestScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
|
||||
/// Another process running the same bundle identifier, if there is one.
|
||||
static func otherRunningInstance() -> NSRunningApplication? {
|
||||
guard let bundleID = Bundle.main.bundleIdentifier else { return nil }
|
||||
let current = NSRunningApplication.current
|
||||
return NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
|
||||
.first { $0.processIdentifier != current.processIdentifier && !$0.isTerminated }
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
HotkeyManager.shared.unregisterAll()
|
||||
}
|
||||
|
||||
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
for url in urls {
|
||||
guard let image = NSImage(contentsOf: url),
|
||||
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { continue }
|
||||
var details = CaptureDetails()
|
||||
details.captureMode = .importedFile
|
||||
details.title = url.deletingPathExtension().lastPathComponent
|
||||
details.scaleFactor = CGFloat(cgImage.width) / max(1, image.size.width)
|
||||
EditorWindowController.open(with: Capture(image: cgImage, details: details))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// CaptureCoordinator.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Drives the capture flows, the macOS counterpart of Greenshot's CaptureHelper.
|
||||
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
final class CaptureCoordinator {
|
||||
static let shared = CaptureCoordinator()
|
||||
|
||||
private var overlay: CaptureOverlayController?
|
||||
private var isCapturing = false
|
||||
private var lastRegion: CGRect?
|
||||
|
||||
private init() {}
|
||||
|
||||
var hasLastRegion: Bool { lastRegion != nil }
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
func captureRegion() {
|
||||
beginOverlayCapture(mode: .region)
|
||||
}
|
||||
|
||||
func captureWindow() {
|
||||
beginOverlayCapture(mode: .window)
|
||||
}
|
||||
|
||||
func captureFullscreen() {
|
||||
guard !isCapturing, CaptureEngine.ensurePermission() else { return requestPermission() }
|
||||
isCapturing = true
|
||||
Task {
|
||||
defer { isCapturing = false }
|
||||
do {
|
||||
try await delayIfNeeded()
|
||||
let capture = try await CaptureEngine.captureFullscreen()
|
||||
finish(capture)
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func captureLastRegion() {
|
||||
guard let lastRegion else { return captureRegion() }
|
||||
guard !isCapturing, CaptureEngine.ensurePermission() else { return requestPermission() }
|
||||
isCapturing = true
|
||||
Task {
|
||||
defer { isCapturing = false }
|
||||
do {
|
||||
try await delayIfNeeded()
|
||||
let snapshots = try await CaptureEngine.snapshotAllDisplays()
|
||||
guard let snapshot = snapshots.first(where: { $0.cgFrame.intersects(lastRegion) }) else {
|
||||
throw CaptureError.noContent
|
||||
}
|
||||
let capture = try CaptureEngine.crop(snapshot: snapshot, cgRect: lastRegion, mode: .lastRegion)
|
||||
finish(capture)
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens an image file in the editor, the equivalent of Greenshot's "Open image from file".
|
||||
func openImageFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.image]
|
||||
panel.allowsMultipleSelection = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard panel.runModal() == .OK,
|
||||
let url = panel.url,
|
||||
let image = NSImage(contentsOf: url),
|
||||
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||
|
||||
var details = CaptureDetails()
|
||||
details.captureMode = .importedFile
|
||||
details.title = url.deletingPathExtension().lastPathComponent
|
||||
details.scaleFactor = CGFloat(cgImage.width) / max(1, image.size.width)
|
||||
EditorWindowController.open(with: Capture(image: cgImage, details: details))
|
||||
}
|
||||
|
||||
// MARK: - Overlay flow
|
||||
|
||||
private func beginOverlayCapture(mode: OverlayMode) {
|
||||
guard !isCapturing else { return }
|
||||
guard CaptureEngine.ensurePermission() else { return requestPermission() }
|
||||
isCapturing = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await delayIfNeeded()
|
||||
let content = try await CaptureEngine.shareableContent()
|
||||
let snapshots = try await CaptureEngine.snapshotAllDisplays()
|
||||
let windows = CaptureEngine.selectableWindows(from: content)
|
||||
|
||||
let controller = CaptureOverlayController(snapshots: snapshots, windows: windows, mode: mode)
|
||||
overlay = controller
|
||||
controller.present { result in
|
||||
self.overlay = nil
|
||||
self.isCapturing = false
|
||||
self.handle(result)
|
||||
}
|
||||
} catch {
|
||||
isCapturing = false
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ result: OverlayResult) {
|
||||
switch result {
|
||||
case .cancelled:
|
||||
return
|
||||
case .region(let snapshot, let rect):
|
||||
lastRegion = rect
|
||||
do {
|
||||
let capture = try CaptureEngine.crop(snapshot: snapshot, cgRect: rect, mode: .region)
|
||||
finish(capture)
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
case .window(let info):
|
||||
Task {
|
||||
do {
|
||||
let capture = try await CaptureEngine.captureWindow(info)
|
||||
finish(capture)
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
private func finish(_ capture: Capture) {
|
||||
CaptureEngine.playFeedback(on: capture.details.screenBounds)
|
||||
Destinations.export(capture, to: Configuration.shared.defaultDestination)
|
||||
}
|
||||
|
||||
private func delayIfNeeded() async throws {
|
||||
let millis = Configuration.shared.captureDelayMillis
|
||||
guard millis > 0 else { return }
|
||||
try await Task.sleep(nanoseconds: UInt64(millis) * 1_000_000)
|
||||
}
|
||||
|
||||
private func report(_ error: Error) {
|
||||
if let captureError = error as? CaptureError, case .cancelled = captureError { return }
|
||||
Destinations.presentError(error)
|
||||
}
|
||||
|
||||
private func requestPermission() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Screen Recording permission needed"
|
||||
alert.informativeText = """
|
||||
macOS requires explicit permission before an app can capture the screen.
|
||||
|
||||
Open System Settings › Privacy & Security › Screen Recording, enable GreenshotMac, then try again.
|
||||
"""
|
||||
alert.addButton(withTitle: "Open System Settings")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if alert.runModal() == .alertFirstButtonReturn,
|
||||
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// HotkeyBindings.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Maps the four capture actions onto global hotkeys and keeps them registered.
|
||||
|
||||
import AppKit
|
||||
|
||||
enum HotkeyAction: String, CaseIterable {
|
||||
case region
|
||||
case window
|
||||
case fullscreen
|
||||
case lastRegion
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .region: return "Capture region"
|
||||
case .window: return "Capture window"
|
||||
case .fullscreen: return "Capture full screen"
|
||||
case .lastRegion: return "Capture last region"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultsKey: String {
|
||||
switch self {
|
||||
case .region: return Configuration.Keys.hotkeyRegion
|
||||
case .window: return Configuration.Keys.hotkeyWindow
|
||||
case .fullscreen: return Configuration.Keys.hotkeyFullscreen
|
||||
case .lastRegion: return Configuration.Keys.hotkeyLastRegion
|
||||
}
|
||||
}
|
||||
|
||||
var defaultSpec: HotkeySpec {
|
||||
switch self {
|
||||
case .region: return DefaultHotkeys.region
|
||||
case .window: return DefaultHotkeys.window
|
||||
case .fullscreen: return DefaultHotkeys.fullscreen
|
||||
case .lastRegion: return DefaultHotkeys.lastRegion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class HotkeyBindings {
|
||||
static let shared = HotkeyBindings()
|
||||
|
||||
private var registrations: [HotkeyAction: UInt32] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
func spec(for action: HotkeyAction) -> HotkeySpec {
|
||||
Configuration.shared.hotkey(forKey: action.defaultsKey, default: action.defaultSpec)
|
||||
}
|
||||
|
||||
func set(_ spec: HotkeySpec, for action: HotkeyAction) {
|
||||
Configuration.shared.setHotkey(spec, forKey: action.defaultsKey)
|
||||
registerAll()
|
||||
NotificationCenter.default.post(name: .hotkeysChanged, object: nil)
|
||||
}
|
||||
|
||||
func registerAll() {
|
||||
for (_, id) in registrations { HotkeyManager.shared.unregister(id) }
|
||||
registrations.removeAll()
|
||||
|
||||
for action in HotkeyAction.allCases {
|
||||
let spec = self.spec(for: action)
|
||||
guard !spec.isEmpty else { continue }
|
||||
if let id = HotkeyManager.shared.register(spec, handler: { HotkeyBindings.perform(action) }) {
|
||||
registrations[action] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func perform(_ action: HotkeyAction) {
|
||||
Task { @MainActor in
|
||||
switch action {
|
||||
case .region: CaptureCoordinator.shared.captureRegion()
|
||||
case .window: CaptureCoordinator.shared.captureWindow()
|
||||
case .fullscreen: CaptureCoordinator.shared.captureFullscreen()
|
||||
case .lastRegion: CaptureCoordinator.shared.captureLastRegion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let hotkeysChanged = Notification.Name("GreenshotMacHotkeysChanged")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// MainMenuBuilder.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// A menu bar for the editor windows; the app itself lives in the status bar.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum MainMenuBuilder {
|
||||
|
||||
static func install() {
|
||||
let mainMenu = NSMenu()
|
||||
|
||||
// Application menu
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu()
|
||||
appMenu.addItem(withTitle: "About GreenshotMac", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Hide GreenshotMac", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Quit GreenshotMac", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
|
||||
// File
|
||||
let fileItem = NSMenuItem()
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Save", action: #selector(EditorWindowController.saveDocument(_:)), keyEquivalent: "s")
|
||||
let saveAs = NSMenuItem(title: "Save As…",
|
||||
action: #selector(EditorWindowController.saveDocumentAs(_:)),
|
||||
keyEquivalent: "s")
|
||||
saveAs.keyEquivalentModifierMask = [.command, .shift]
|
||||
fileMenu.addItem(saveAs)
|
||||
fileMenu.addItem(withTitle: "Print…", action: #selector(EditorWindowController.printDocument(_:)), keyEquivalent: "p")
|
||||
fileMenu.addItem(withTitle: "Share…", action: #selector(EditorWindowController.shareDocument(_:)), keyEquivalent: "")
|
||||
fileMenu.addItem(.separator())
|
||||
fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
fileItem.submenu = fileMenu
|
||||
mainMenu.addItem(fileItem)
|
||||
|
||||
// Edit
|
||||
let editItem = NSMenuItem()
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
||||
let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
|
||||
redo.keyEquivalentModifierMask = [.command, .shift]
|
||||
editMenu.addItem(redo)
|
||||
editMenu.addItem(.separator())
|
||||
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
editMenu.addItem(withTitle: "Copy", action: #selector(EditorWindowController.copy(_:)), keyEquivalent: "c")
|
||||
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
editMenu.addItem(withTitle: "Duplicate", action: #selector(EditorWindowController.duplicateSelection(_:)), keyEquivalent: "d")
|
||||
editMenu.addItem(withTitle: "Delete", action: #selector(EditorWindowController.deleteSelection(_:)), keyEquivalent: "\u{8}")
|
||||
editItem.submenu = editMenu
|
||||
mainMenu.addItem(editItem)
|
||||
|
||||
// Capture
|
||||
let captureItem = NSMenuItem()
|
||||
let captureMenu = NSMenu(title: "Capture")
|
||||
captureMenu.addItem(withTitle: "Region", action: #selector(CaptureMenuTarget.captureRegion), keyEquivalent: "")
|
||||
captureMenu.addItem(withTitle: "Window", action: #selector(CaptureMenuTarget.captureWindow), keyEquivalent: "")
|
||||
captureMenu.addItem(withTitle: "Full screen", action: #selector(CaptureMenuTarget.captureFullscreen), keyEquivalent: "")
|
||||
captureMenu.addItem(withTitle: "Last region", action: #selector(CaptureMenuTarget.captureLastRegion), keyEquivalent: "")
|
||||
for menuItem in captureMenu.items { menuItem.target = CaptureMenuTarget.shared }
|
||||
captureItem.submenu = captureMenu
|
||||
mainMenu.addItem(captureItem)
|
||||
|
||||
// Tools
|
||||
let toolsItem = NSMenuItem()
|
||||
let toolsMenu = NSMenu(title: "Tools")
|
||||
for tool in ToolKind.allCases {
|
||||
let menuItem = NSMenuItem(title: tool.title,
|
||||
action: #selector(EditorWindowController.selectTool(_:)),
|
||||
keyEquivalent: tool.keyEquivalent)
|
||||
menuItem.keyEquivalentModifierMask = []
|
||||
menuItem.representedObject = tool.rawValue
|
||||
toolsMenu.addItem(menuItem)
|
||||
}
|
||||
toolsMenu.addItem(.separator())
|
||||
toolsMenu.addItem(withTitle: "Apply crop", action: #selector(EditorWindowController.applyCrop(_:)), keyEquivalent: "\r")
|
||||
toolsMenu.addItem(.separator())
|
||||
toolsMenu.addItem(withTitle: "Bring to Front", action: #selector(EditorWindowController.bringToFront(_:)), keyEquivalent: "")
|
||||
toolsMenu.addItem(withTitle: "Send to Back", action: #selector(EditorWindowController.sendToBack(_:)), keyEquivalent: "")
|
||||
toolsItem.submenu = toolsMenu
|
||||
mainMenu.addItem(toolsItem)
|
||||
|
||||
// View
|
||||
let viewItem = NSMenuItem()
|
||||
let viewMenu = NSMenu(title: "View")
|
||||
viewMenu.addItem(withTitle: "Zoom In", action: #selector(EditorWindowController.zoomIn(_:)), keyEquivalent: "+")
|
||||
viewMenu.addItem(withTitle: "Zoom Out", action: #selector(EditorWindowController.zoomOut(_:)), keyEquivalent: "-")
|
||||
viewMenu.addItem(withTitle: "Actual Size", action: #selector(EditorWindowController.actualSize(_:)), keyEquivalent: "0")
|
||||
viewItem.submenu = viewMenu
|
||||
mainMenu.addItem(viewItem)
|
||||
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CaptureMenuTarget: NSObject {
|
||||
static let shared = CaptureMenuTarget()
|
||||
|
||||
@objc func captureRegion() { CaptureCoordinator.shared.captureRegion() }
|
||||
@objc func captureWindow() { CaptureCoordinator.shared.captureWindow() }
|
||||
@objc func captureFullscreen() { CaptureCoordinator.shared.captureFullscreen() }
|
||||
@objc func captureLastRegion() { CaptureCoordinator.shared.captureLastRegion() }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// StatusMenuController.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// The menu bar item, equivalent to Greenshot's tray icon and context menu.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class StatusMenuController: NSObject, NSMenuDelegate {
|
||||
|
||||
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
private var preferencesController: PreferencesWindowController?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
if let button = statusItem.button {
|
||||
button.image = NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "GreenshotMac")
|
||||
button.image?.isTemplate = true
|
||||
button.toolTip = "GreenshotMac"
|
||||
}
|
||||
statusItem.menu = buildMenu()
|
||||
}
|
||||
|
||||
private func buildMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
menu.addItem(item(title: "Capture region",
|
||||
action: #selector(captureRegion),
|
||||
hotkey: HotkeyBindings.shared.spec(for: .region)))
|
||||
menu.addItem(item(title: "Capture window",
|
||||
action: #selector(captureWindow),
|
||||
hotkey: HotkeyBindings.shared.spec(for: .window)))
|
||||
menu.addItem(item(title: "Capture full screen",
|
||||
action: #selector(captureFullscreen),
|
||||
hotkey: HotkeyBindings.shared.spec(for: .fullscreen)))
|
||||
menu.addItem(item(title: "Capture last region",
|
||||
action: #selector(captureLastRegion),
|
||||
hotkey: HotkeyBindings.shared.spec(for: .lastRegion)))
|
||||
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item(title: "Open image from file…", action: #selector(openImageFile), hotkey: nil))
|
||||
menu.addItem(item(title: "Open last screenshot in Finder", action: #selector(revealLast), hotkey: nil))
|
||||
|
||||
menu.addItem(.separator())
|
||||
let destinationItem = NSMenuItem(title: "Default destination", action: nil, keyEquivalent: "")
|
||||
destinationItem.submenu = destinationMenu()
|
||||
menu.addItem(destinationItem)
|
||||
menu.addItem(item(title: "Preferences…", action: #selector(showPreferences), hotkey: nil))
|
||||
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item(title: "About GreenshotMac", action: #selector(showAbout), hotkey: nil))
|
||||
menu.addItem(item(title: "Quit GreenshotMac", action: #selector(quit), hotkey: nil))
|
||||
return menu
|
||||
}
|
||||
|
||||
private func destinationMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
for destination in DestinationID.allCases {
|
||||
let menuItem = NSMenuItem(title: destination.title,
|
||||
action: #selector(selectDestination(_:)),
|
||||
keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = destination.rawValue
|
||||
menuItem.state = Configuration.shared.defaultDestination == destination ? .on : .off
|
||||
menu.addItem(menuItem)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func item(title: String, action: Selector, hotkey: HotkeySpec?) -> NSMenuItem {
|
||||
let menuItem = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
if let hotkey, !hotkey.isEmpty {
|
||||
menuItem.toolTip = HotkeyManager.description(of: hotkey)
|
||||
}
|
||||
return menuItem
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
for menuItem in menu.items where menuItem.title == "Default destination" {
|
||||
menuItem.submenu = destinationMenu()
|
||||
}
|
||||
if let revealItem = menu.items.first(where: { $0.title == "Open last screenshot in Finder" }) {
|
||||
revealItem.isEnabled = Notifier.mostRecentSavedURL != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func captureRegion() { CaptureCoordinator.shared.captureRegion() }
|
||||
@objc private func captureWindow() { CaptureCoordinator.shared.captureWindow() }
|
||||
@objc private func captureFullscreen() { CaptureCoordinator.shared.captureFullscreen() }
|
||||
@objc private func captureLastRegion() { CaptureCoordinator.shared.captureLastRegion() }
|
||||
@objc private func openImageFile() { CaptureCoordinator.shared.openImageFile() }
|
||||
@objc private func revealLast() { Notifier.revealMostRecent() }
|
||||
|
||||
@objc private func selectDestination(_ sender: NSMenuItem) {
|
||||
guard let raw = sender.representedObject as? String,
|
||||
let destination = DestinationID(rawValue: raw) else { return }
|
||||
Configuration.shared.defaultDestination = destination
|
||||
}
|
||||
|
||||
@objc private func showPreferences() {
|
||||
if preferencesController == nil {
|
||||
preferencesController = PreferencesWindowController()
|
||||
}
|
||||
preferencesController?.showWindow(nil)
|
||||
preferencesController?.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc private func showAbout() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "GreenshotMac",
|
||||
.credits: NSAttributedString(
|
||||
string: "A native macOS screenshot tool modelled on Greenshot for Windows.\n"
|
||||
+ "Licensed under the GNU General Public License v3.",
|
||||
attributes: [.font: NSFont.systemFont(ofSize: 11)])
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// main.swift
|
||||
// GreenshotMac
|
||||
|
||||
import AppKit
|
||||
|
||||
/// NSApplication.delegate is a weak reference, so the delegate is held here for the
|
||||
/// lifetime of the process.
|
||||
enum AppLauncher {
|
||||
@MainActor private static var retainedDelegate: AppDelegate?
|
||||
|
||||
@MainActor
|
||||
static func launch() {
|
||||
let application = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
retainedDelegate = delegate
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.accessory)
|
||||
application.run()
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level code always runs on the main thread, so assuming main actor isolation is safe here.
|
||||
MainActor.assumeIsolated {
|
||||
AppLauncher.launch()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Capture.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Equivalent of Greenshot's ICapture / ICaptureDetails.
|
||||
|
||||
import AppKit
|
||||
|
||||
enum CaptureMode: String {
|
||||
case region
|
||||
case fullscreen
|
||||
case window
|
||||
case lastRegion
|
||||
case importedFile
|
||||
}
|
||||
|
||||
/// Metadata that travels with a capture; used by the filename pattern engine.
|
||||
struct CaptureDetails {
|
||||
var captureMode: CaptureMode = .region
|
||||
var dateTime: Date = Date()
|
||||
/// Title of the captured window, or the screen name for a full screen capture.
|
||||
var title: String = ""
|
||||
/// Bundle identifier of the captured application, when known.
|
||||
var applicationBundleID: String?
|
||||
/// Application name of the captured window, when known.
|
||||
var applicationName: String?
|
||||
/// Screen rectangle the capture was taken from, in global (top-left origin) coordinates.
|
||||
var screenBounds: CGRect = .zero
|
||||
/// Scale factor of the display the capture came from.
|
||||
var scaleFactor: CGFloat = 2.0
|
||||
/// Set once the capture has been written to disk.
|
||||
var filePath: String?
|
||||
}
|
||||
|
||||
/// A screenshot plus its metadata.
|
||||
final class Capture {
|
||||
var image: CGImage
|
||||
var details: CaptureDetails
|
||||
|
||||
init(image: CGImage, details: CaptureDetails) {
|
||||
self.image = image
|
||||
self.details = details
|
||||
}
|
||||
|
||||
var pixelSize: CGSize {
|
||||
CGSize(width: image.width, height: image.height)
|
||||
}
|
||||
|
||||
/// Size in points, i.e. pixels divided by the capture's backing scale factor.
|
||||
var pointSize: CGSize {
|
||||
CGSize(width: CGFloat(image.width) / details.scaleFactor,
|
||||
height: CGFloat(image.height) / details.scaleFactor)
|
||||
}
|
||||
|
||||
var nsImage: NSImage {
|
||||
NSImage(cgImage: image, size: pointSize)
|
||||
}
|
||||
}
|
||||
|
||||
enum CaptureError: LocalizedError {
|
||||
case permissionDenied
|
||||
case noContent
|
||||
case cancelled
|
||||
case captureFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return "GreenshotMac needs Screen Recording permission. Open System Settings › Privacy & Security › Screen Recording and enable GreenshotMac."
|
||||
case .noContent:
|
||||
return "Nothing was available to capture."
|
||||
case .cancelled:
|
||||
return "Capture cancelled."
|
||||
case .captureFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Configuration.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's ICoreConfiguration / IEditorConfiguration onto UserDefaults.
|
||||
|
||||
import AppKit
|
||||
|
||||
enum OutputFormat: String, CaseIterable, Codable {
|
||||
case png, jpg, tiff, bmp, gif
|
||||
|
||||
var displayName: String { rawValue.uppercased() }
|
||||
|
||||
var fileType: NSBitmapImageRep.FileType {
|
||||
switch self {
|
||||
case .png: return .png
|
||||
case .jpg: return .jpeg
|
||||
case .tiff: return .tiff
|
||||
case .bmp: return .bmp
|
||||
case .gif: return .gif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WindowCaptureMode: String, CaseIterable, Codable {
|
||||
case screen // crop the window rectangle out of a full screen grab
|
||||
case window // ask ScreenCaptureKit for the window itself (no shadow)
|
||||
case windowWithShadow
|
||||
}
|
||||
|
||||
/// Identifiers of the destinations a capture can be sent to.
|
||||
enum DestinationID: String, CaseIterable, Codable {
|
||||
case picker
|
||||
case editor
|
||||
case clipboard
|
||||
case file
|
||||
case fileWithDialog
|
||||
case printer
|
||||
case share
|
||||
case email
|
||||
case openWith
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .picker: return "Show destination picker"
|
||||
case .editor: return "Open in image editor"
|
||||
case .clipboard: return "Copy to clipboard"
|
||||
case .file: return "Save (auto-named)"
|
||||
case .fileWithDialog: return "Save as…"
|
||||
case .printer: return "Print"
|
||||
case .share: return "Share…"
|
||||
case .email: return "Send as email"
|
||||
case .openWith: return "Open with…"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .picker: return "list.bullet"
|
||||
case .editor: return "pencil.tip.crop.circle"
|
||||
case .clipboard: return "doc.on.clipboard"
|
||||
case .file: return "square.and.arrow.down"
|
||||
case .fileWithDialog: return "folder"
|
||||
case .printer: return "printer"
|
||||
case .share: return "square.and.arrow.up"
|
||||
case .email: return "envelope"
|
||||
case .openWith: return "arrow.up.forward.app"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A modifier + key combination used for a global hotkey.
|
||||
struct HotkeySpec: Codable, Equatable {
|
||||
var keyCode: UInt32
|
||||
var modifiers: UInt32 // Carbon modifier mask
|
||||
|
||||
static let none = HotkeySpec(keyCode: 0, modifiers: 0)
|
||||
var isEmpty: Bool { keyCode == 0 && modifiers == 0 }
|
||||
}
|
||||
|
||||
final class Configuration {
|
||||
static let shared = Configuration()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private init() {
|
||||
defaults.register(defaults: [
|
||||
Keys.outputFileFilenamePattern: "${capturetime:d\"yyyy-MM-dd HH_mm_ss\"}-${title}",
|
||||
Keys.outputFileFormat: OutputFormat.png.rawValue,
|
||||
Keys.outputFileJpegQuality: 80,
|
||||
Keys.outputFilePath: FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first?.path
|
||||
?? NSHomeDirectory(),
|
||||
Keys.captureMousepointer: false,
|
||||
Keys.captureDelayMillis: 0,
|
||||
Keys.playCameraSound: true,
|
||||
Keys.showFlashAnimation: true,
|
||||
Keys.windowCaptureMode: WindowCaptureMode.window.rawValue,
|
||||
Keys.defaultDestination: DestinationID.picker.rawValue,
|
||||
Keys.pickerDestinations: [DestinationID.editor.rawValue,
|
||||
DestinationID.file.rawValue,
|
||||
DestinationID.clipboard.rawValue,
|
||||
DestinationID.email.rawValue],
|
||||
Keys.copyPathToClipboard: false,
|
||||
Keys.editorLineThickness: 2,
|
||||
Keys.editorLineColor: "#FF0000",
|
||||
Keys.editorFillColor: "#00000000",
|
||||
Keys.editorFontFamily: "Helvetica",
|
||||
Keys.editorFontSize: 14,
|
||||
Keys.editorBlurRadius: 12,
|
||||
Keys.editorPixelSize: 8,
|
||||
Keys.editorHighlightColor: "#FFFF00",
|
||||
Keys.editorDropShadow: false,
|
||||
Keys.showMagnifier: true,
|
||||
Keys.showSelectionSize: true
|
||||
])
|
||||
}
|
||||
|
||||
enum Keys {
|
||||
static let outputFileFilenamePattern = "OutputFileFilenamePattern"
|
||||
static let outputFileFormat = "OutputFileFormat"
|
||||
static let outputFileJpegQuality = "OutputFileJpegQuality"
|
||||
static let outputFilePath = "OutputFilePath"
|
||||
static let captureMousepointer = "CaptureMousepointer"
|
||||
static let captureDelayMillis = "CaptureDelayMillis"
|
||||
static let playCameraSound = "PlayCameraSound"
|
||||
static let showFlashAnimation = "ShowFlashAnimation"
|
||||
static let windowCaptureMode = "WindowCaptureMode"
|
||||
static let defaultDestination = "DefaultDestination"
|
||||
static let pickerDestinations = "PickerDestinations"
|
||||
static let copyPathToClipboard = "CopyPathToClipboard"
|
||||
static let editorLineThickness = "EditorLineThickness"
|
||||
static let editorLineColor = "EditorLineColor"
|
||||
static let editorFillColor = "EditorFillColor"
|
||||
static let editorFontFamily = "EditorFontFamily"
|
||||
static let editorFontSize = "EditorFontSize"
|
||||
static let editorBlurRadius = "EditorBlurRadius"
|
||||
static let editorPixelSize = "EditorPixelSize"
|
||||
static let editorHighlightColor = "EditorHighlightColor"
|
||||
static let editorDropShadow = "EditorDropShadow"
|
||||
static let showMagnifier = "ShowMagnifier"
|
||||
static let showSelectionSize = "ShowSelectionSize"
|
||||
static let hotkeyRegion = "HotkeyRegion"
|
||||
static let hotkeyFullscreen = "HotkeyFullscreen"
|
||||
static let hotkeyWindow = "HotkeyWindow"
|
||||
static let hotkeyLastRegion = "HotkeyLastRegion"
|
||||
}
|
||||
|
||||
// MARK: - Output
|
||||
|
||||
var outputFileFilenamePattern: String {
|
||||
get { defaults.string(forKey: Keys.outputFileFilenamePattern) ?? "" }
|
||||
set { defaults.set(newValue, forKey: Keys.outputFileFilenamePattern) }
|
||||
}
|
||||
|
||||
var outputFileFormat: OutputFormat {
|
||||
get { OutputFormat(rawValue: defaults.string(forKey: Keys.outputFileFormat) ?? "png") ?? .png }
|
||||
set { defaults.set(newValue.rawValue, forKey: Keys.outputFileFormat) }
|
||||
}
|
||||
|
||||
var outputFileJpegQuality: Int {
|
||||
get { defaults.integer(forKey: Keys.outputFileJpegQuality) }
|
||||
set { defaults.set(newValue, forKey: Keys.outputFileJpegQuality) }
|
||||
}
|
||||
|
||||
var outputFilePath: String {
|
||||
get { defaults.string(forKey: Keys.outputFilePath) ?? NSHomeDirectory() }
|
||||
set { defaults.set(newValue, forKey: Keys.outputFilePath) }
|
||||
}
|
||||
|
||||
var copyPathToClipboard: Bool {
|
||||
get { defaults.bool(forKey: Keys.copyPathToClipboard) }
|
||||
set { defaults.set(newValue, forKey: Keys.copyPathToClipboard) }
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
var captureMousepointer: Bool {
|
||||
get { defaults.bool(forKey: Keys.captureMousepointer) }
|
||||
set { defaults.set(newValue, forKey: Keys.captureMousepointer) }
|
||||
}
|
||||
|
||||
var captureDelayMillis: Int {
|
||||
get { defaults.integer(forKey: Keys.captureDelayMillis) }
|
||||
set { defaults.set(newValue, forKey: Keys.captureDelayMillis) }
|
||||
}
|
||||
|
||||
var playCameraSound: Bool {
|
||||
get { defaults.bool(forKey: Keys.playCameraSound) }
|
||||
set { defaults.set(newValue, forKey: Keys.playCameraSound) }
|
||||
}
|
||||
|
||||
var showFlashAnimation: Bool {
|
||||
get { defaults.bool(forKey: Keys.showFlashAnimation) }
|
||||
set { defaults.set(newValue, forKey: Keys.showFlashAnimation) }
|
||||
}
|
||||
|
||||
var windowCaptureMode: WindowCaptureMode {
|
||||
get { WindowCaptureMode(rawValue: defaults.string(forKey: Keys.windowCaptureMode) ?? "window") ?? .window }
|
||||
set { defaults.set(newValue.rawValue, forKey: Keys.windowCaptureMode) }
|
||||
}
|
||||
|
||||
var showMagnifier: Bool {
|
||||
get { defaults.bool(forKey: Keys.showMagnifier) }
|
||||
set { defaults.set(newValue, forKey: Keys.showMagnifier) }
|
||||
}
|
||||
|
||||
var showSelectionSize: Bool {
|
||||
get { defaults.bool(forKey: Keys.showSelectionSize) }
|
||||
set { defaults.set(newValue, forKey: Keys.showSelectionSize) }
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
var defaultDestination: DestinationID {
|
||||
get { DestinationID(rawValue: defaults.string(forKey: Keys.defaultDestination) ?? "editor") ?? .editor }
|
||||
set { defaults.set(newValue.rawValue, forKey: Keys.defaultDestination) }
|
||||
}
|
||||
|
||||
var pickerDestinations: [DestinationID] {
|
||||
get { (defaults.stringArray(forKey: Keys.pickerDestinations) ?? []).compactMap(DestinationID.init(rawValue:)) }
|
||||
set { defaults.set(newValue.map(\.rawValue), forKey: Keys.pickerDestinations) }
|
||||
}
|
||||
|
||||
// MARK: - Editor defaults
|
||||
|
||||
var editorLineThickness: Int {
|
||||
get { defaults.integer(forKey: Keys.editorLineThickness) }
|
||||
set { defaults.set(newValue, forKey: Keys.editorLineThickness) }
|
||||
}
|
||||
|
||||
var editorLineColor: NSColor {
|
||||
get { NSColor(hexString: defaults.string(forKey: Keys.editorLineColor) ?? "#FF0000") ?? .systemRed }
|
||||
set { defaults.set(newValue.hexString, forKey: Keys.editorLineColor) }
|
||||
}
|
||||
|
||||
var editorFillColor: NSColor {
|
||||
get { NSColor(hexString: defaults.string(forKey: Keys.editorFillColor) ?? "#00000000") ?? .clear }
|
||||
set { defaults.set(newValue.hexString, forKey: Keys.editorFillColor) }
|
||||
}
|
||||
|
||||
var editorHighlightColor: NSColor {
|
||||
get { NSColor(hexString: defaults.string(forKey: Keys.editorHighlightColor) ?? "#FFFF00") ?? .systemYellow }
|
||||
set { defaults.set(newValue.hexString, forKey: Keys.editorHighlightColor) }
|
||||
}
|
||||
|
||||
var editorFontFamily: String {
|
||||
get { defaults.string(forKey: Keys.editorFontFamily) ?? "Helvetica" }
|
||||
set { defaults.set(newValue, forKey: Keys.editorFontFamily) }
|
||||
}
|
||||
|
||||
var editorFontSize: Int {
|
||||
get { defaults.integer(forKey: Keys.editorFontSize) }
|
||||
set { defaults.set(newValue, forKey: Keys.editorFontSize) }
|
||||
}
|
||||
|
||||
var editorBlurRadius: Int {
|
||||
get { defaults.integer(forKey: Keys.editorBlurRadius) }
|
||||
set { defaults.set(newValue, forKey: Keys.editorBlurRadius) }
|
||||
}
|
||||
|
||||
var editorPixelSize: Int {
|
||||
get { defaults.integer(forKey: Keys.editorPixelSize) }
|
||||
set { defaults.set(newValue, forKey: Keys.editorPixelSize) }
|
||||
}
|
||||
|
||||
var editorDropShadow: Bool {
|
||||
get { defaults.bool(forKey: Keys.editorDropShadow) }
|
||||
set { defaults.set(newValue, forKey: Keys.editorDropShadow) }
|
||||
}
|
||||
|
||||
// MARK: - Hotkeys
|
||||
|
||||
func hotkey(forKey key: String, default fallback: HotkeySpec) -> HotkeySpec {
|
||||
guard let data = defaults.data(forKey: key),
|
||||
let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data) else { return fallback }
|
||||
return spec
|
||||
}
|
||||
|
||||
func setHotkey(_ spec: HotkeySpec, forKey key: String) {
|
||||
if let data = try? JSONEncoder().encode(spec) {
|
||||
defaults.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSColor {
|
||||
/// #RRGGBB or #RRGGBBAA
|
||||
convenience init?(hexString: String) {
|
||||
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if hex.hasPrefix("#") { hex.removeFirst() }
|
||||
guard hex.count == 6 || hex.count == 8, let value = UInt64(hex, radix: 16) else { return nil }
|
||||
let r, g, b, a: CGFloat
|
||||
if hex.count == 6 {
|
||||
r = CGFloat((value >> 16) & 0xFF) / 255
|
||||
g = CGFloat((value >> 8) & 0xFF) / 255
|
||||
b = CGFloat(value & 0xFF) / 255
|
||||
a = 1
|
||||
} else {
|
||||
r = CGFloat((value >> 24) & 0xFF) / 255
|
||||
g = CGFloat((value >> 16) & 0xFF) / 255
|
||||
b = CGFloat((value >> 8) & 0xFF) / 255
|
||||
a = CGFloat(value & 0xFF) / 255
|
||||
}
|
||||
self.init(srgbRed: r, green: g, blue: b, alpha: a)
|
||||
}
|
||||
|
||||
var hexString: String {
|
||||
guard let rgb = usingColorSpace(.sRGB) else { return "#000000FF" }
|
||||
let r = Int((rgb.redComponent * 255).rounded())
|
||||
let g = Int((rgb.greenComponent * 255).rounded())
|
||||
let b = Int((rgb.blueComponent * 255).rounded())
|
||||
let a = Int((rgb.alphaComponent * 255).rounded())
|
||||
return String(format: "#%02X%02X%02X%02X", r, g, b, a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// FilenamePattern.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's FilenameHelper: ${variable:parameters} expansion.
|
||||
//
|
||||
// Supported variables:
|
||||
// ${capturetime} - capture timestamp, ${capturetime:d"yyyy-MM-dd HH_mm_ss"} for a custom format
|
||||
// ${now} - current timestamp, same parameter syntax
|
||||
// ${title} - window title of the capture
|
||||
// ${appname} - application name of the captured window
|
||||
// ${user} - short user name
|
||||
// ${hostname} - computer name
|
||||
// ${mode} - capture mode (region / window / fullscreen)
|
||||
// ${NUM} - zero padded incrementing counter, ${NUM:d4} for four digits
|
||||
// Parameters shared by all variables:
|
||||
// :s<from>,<to> - substring
|
||||
// :r<search>,<replace> - replace
|
||||
// :t - trim
|
||||
// :u / :l - upper / lower case
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FilenamePattern {
|
||||
private static let variableRegex = try! NSRegularExpression(
|
||||
pattern: #"\$\{(?<variable>[^:}]+)[:]?(?<parameters>[^}]*)\}"#)
|
||||
|
||||
private static let counterKey = "FilenamePatternCounter"
|
||||
private static let maxTitleLength = 80
|
||||
|
||||
/// Expands the pattern and returns a filename *without* extension.
|
||||
static func filename(pattern: String, details: CaptureDetails) -> String {
|
||||
let expanded = expand(pattern: pattern, details: details)
|
||||
return makeSafe(expanded.isEmpty ? defaultName(details) : expanded)
|
||||
}
|
||||
|
||||
/// Expands the pattern and appends the extension of the given format.
|
||||
static func filename(pattern: String, details: CaptureDetails, format: OutputFormat) -> String {
|
||||
filename(pattern: pattern, details: details) + "." + format.rawValue
|
||||
}
|
||||
|
||||
static func defaultName(_ details: CaptureDetails) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH_mm_ss"
|
||||
return formatter.string(from: details.dateTime)
|
||||
}
|
||||
|
||||
static func expand(pattern: String, details: CaptureDetails) -> String {
|
||||
let ns = pattern as NSString
|
||||
var result = ""
|
||||
var location = 0
|
||||
|
||||
variableRegex.enumerateMatches(in: pattern, range: NSRange(location: 0, length: ns.length)) { match, _, _ in
|
||||
guard let match else { return }
|
||||
result += ns.substring(with: NSRange(location: location, length: match.range.location - location))
|
||||
let variable = ns.substring(with: match.range(withName: "variable"))
|
||||
let parameters = match.range(withName: "parameters").location == NSNotFound
|
||||
? "" : ns.substring(with: match.range(withName: "parameters"))
|
||||
result += value(for: variable, parameters: parameters, details: details)
|
||||
location = match.range.location + match.range.length
|
||||
}
|
||||
result += ns.substring(from: location)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func value(for variable: String, parameters: String, details: CaptureDetails) -> String {
|
||||
var replacement: String
|
||||
|
||||
switch variable.lowercased() {
|
||||
case "capturetime":
|
||||
replacement = formatted(date: details.dateTime, parameters: parameters)
|
||||
case "now":
|
||||
replacement = formatted(date: Date(), parameters: parameters)
|
||||
case "title":
|
||||
replacement = String(details.title.prefix(maxTitleLength))
|
||||
case "appname", "application":
|
||||
replacement = details.applicationName ?? ""
|
||||
case "user", "username":
|
||||
replacement = NSUserName()
|
||||
case "hostname", "computername":
|
||||
replacement = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
case "mode":
|
||||
replacement = details.captureMode.rawValue
|
||||
case "num":
|
||||
replacement = counter(parameters: parameters)
|
||||
default:
|
||||
replacement = ProcessInfo.processInfo.environment[variable] ?? ""
|
||||
}
|
||||
|
||||
return apply(parameters: parameters, to: replacement)
|
||||
}
|
||||
|
||||
private static func formatted(date: Date, parameters: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
if let format = parameter(parameters, prefix: "d") {
|
||||
formatter.dateFormat = format
|
||||
} else {
|
||||
formatter.dateFormat = "yyyy-MM-dd HH_mm_ss"
|
||||
}
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func counter(parameters: String) -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
let next = defaults.integer(forKey: counterKey) + 1
|
||||
defaults.set(next, forKey: counterKey)
|
||||
let digits = Int(parameter(parameters, prefix: "d") ?? "") ?? 1
|
||||
return String(format: "%0\(max(1, digits))d", next)
|
||||
}
|
||||
|
||||
/// Extracts a single parameter such as d"yyyy-MM-dd" or d4 from the parameter string.
|
||||
private static func parameter(_ parameters: String, prefix: String) -> String? {
|
||||
for part in split(parameters) where part.hasPrefix(prefix) {
|
||||
var value = String(part.dropFirst(prefix.count))
|
||||
if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
|
||||
value = String(value.dropFirst().dropLast())
|
||||
}
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func apply(parameters: String, to input: String) -> String {
|
||||
var value = input
|
||||
for part in split(parameters) {
|
||||
guard let marker = part.first else { continue }
|
||||
let argument = String(part.dropFirst())
|
||||
switch marker {
|
||||
case "t":
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case "u":
|
||||
value = value.uppercased()
|
||||
case "l":
|
||||
value = value.lowercased()
|
||||
case "r":
|
||||
let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
if pieces.count == 2 {
|
||||
value = value.replacingOccurrences(of: unquote(String(pieces[0])),
|
||||
with: unquote(String(pieces[1])))
|
||||
}
|
||||
case "s":
|
||||
let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let from = max(0, Int(pieces.first.map(String.init) ?? "") ?? 0)
|
||||
let to = pieces.count == 2 ? (Int(String(pieces[1])) ?? value.count) : value.count
|
||||
if from < value.count {
|
||||
let start = value.index(value.startIndex, offsetBy: from)
|
||||
let end = value.index(value.startIndex, offsetBy: min(max(to, from), value.count))
|
||||
value = String(value[start..<end])
|
||||
} else {
|
||||
value = ""
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Splits parameters on "," but keeps quoted sections together.
|
||||
private static func split(_ parameters: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var current = ""
|
||||
var inQuotes = false
|
||||
for character in parameters {
|
||||
if character == "\"" {
|
||||
inQuotes.toggle()
|
||||
current.append(character)
|
||||
} else if character == "," && !inQuotes {
|
||||
parts.append(current)
|
||||
current = ""
|
||||
} else {
|
||||
current.append(character)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { parts.append(current) }
|
||||
return parts
|
||||
}
|
||||
|
||||
private static func unquote(_ value: String) -> String {
|
||||
guard value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") else { return value }
|
||||
return String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
/// Strips characters that are illegal in a macOS filename.
|
||||
static func makeSafe(_ filename: String) -> String {
|
||||
var safe = filename
|
||||
for character in [":", "/", "\\", "\0"] {
|
||||
safe = safe.replacingOccurrences(of: character, with: "_")
|
||||
}
|
||||
safe = safe.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if safe.hasPrefix(".") { safe = "_" + safe.dropFirst() }
|
||||
if safe.isEmpty { safe = "screenshot" }
|
||||
return String(safe.prefix(200))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// HotkeyManager.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// System wide hotkeys through the Carbon hotkey API, the macOS counterpart of
|
||||
// Greenshot's HotkeyControl (RegisterHotKey on Win32).
|
||||
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
final class HotkeyManager {
|
||||
static let shared = HotkeyManager()
|
||||
|
||||
private struct Registration {
|
||||
let ref: EventHotKeyRef
|
||||
let handler: () -> Void
|
||||
}
|
||||
|
||||
private var registrations: [UInt32: Registration] = [:]
|
||||
private var nextID: UInt32 = 1
|
||||
private var eventHandler: EventHandlerRef?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Installs the process wide Carbon event handler. Safe to call more than once.
|
||||
private func installHandlerIfNeeded() {
|
||||
guard eventHandler == nil else { return }
|
||||
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
|
||||
eventKind: UInt32(kEventHotKeyPressed))
|
||||
let callback: EventHandlerUPP = { _, event, _ in
|
||||
var hotkeyID = EventHotKeyID()
|
||||
let status = GetEventParameter(event,
|
||||
EventParamName(kEventParamDirectObject),
|
||||
EventParamType(typeEventHotKeyID),
|
||||
nil,
|
||||
MemoryLayout<EventHotKeyID>.size,
|
||||
nil,
|
||||
&hotkeyID)
|
||||
guard status == noErr else { return status }
|
||||
DispatchQueue.main.async {
|
||||
HotkeyManager.shared.fire(id: hotkeyID.id)
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
InstallEventHandler(GetApplicationEventTarget(), callback, 1, &eventType, nil, &eventHandler)
|
||||
}
|
||||
|
||||
fileprivate func fire(id: UInt32) {
|
||||
registrations[id]?.handler()
|
||||
}
|
||||
|
||||
/// Registers a hotkey and returns its identifier, or nil when the combination is taken.
|
||||
@discardableResult
|
||||
func register(_ spec: HotkeySpec, handler: @escaping () -> Void) -> UInt32? {
|
||||
guard !spec.isEmpty else { return nil }
|
||||
installHandlerIfNeeded()
|
||||
|
||||
let id = nextID
|
||||
nextID += 1
|
||||
|
||||
var hotkeyRef: EventHotKeyRef?
|
||||
let hotkeyID = EventHotKeyID(signature: OSType(0x47535354), id: id) // 'GSST'
|
||||
let status = RegisterEventHotKey(spec.keyCode,
|
||||
spec.modifiers,
|
||||
hotkeyID,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&hotkeyRef)
|
||||
guard status == noErr, let hotkeyRef else { return nil }
|
||||
registrations[id] = Registration(ref: hotkeyRef, handler: handler)
|
||||
return id
|
||||
}
|
||||
|
||||
func unregister(_ id: UInt32) {
|
||||
guard let registration = registrations.removeValue(forKey: id) else { return }
|
||||
UnregisterEventHotKey(registration.ref)
|
||||
}
|
||||
|
||||
func unregisterAll() {
|
||||
for id in registrations.keys { unregister(id) }
|
||||
}
|
||||
|
||||
// MARK: - Display helpers
|
||||
|
||||
static func description(of spec: HotkeySpec) -> String {
|
||||
guard !spec.isEmpty else { return "None" }
|
||||
var text = ""
|
||||
if spec.modifiers & UInt32(controlKey) != 0 { text += "⌃" }
|
||||
if spec.modifiers & UInt32(optionKey) != 0 { text += "⌥" }
|
||||
if spec.modifiers & UInt32(shiftKey) != 0 { text += "⇧" }
|
||||
if spec.modifiers & UInt32(cmdKey) != 0 { text += "⌘" }
|
||||
text += keyName(for: spec.keyCode)
|
||||
return text
|
||||
}
|
||||
|
||||
static func keyName(for keyCode: UInt32) -> String {
|
||||
let names: [UInt32: String] = [
|
||||
UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C",
|
||||
UInt32(kVK_ANSI_D): "D", UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
|
||||
UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", UInt32(kVK_ANSI_I): "I",
|
||||
UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
|
||||
UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O",
|
||||
UInt32(kVK_ANSI_P): "P", UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
|
||||
UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", UInt32(kVK_ANSI_U): "U",
|
||||
UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
|
||||
UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
|
||||
UInt32(kVK_ANSI_1): "1", UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
|
||||
UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6",
|
||||
UInt32(kVK_ANSI_7): "7", UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
|
||||
UInt32(kVK_ANSI_0): "0",
|
||||
UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Escape): "Esc",
|
||||
UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
|
||||
UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
|
||||
UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12"
|
||||
]
|
||||
return names[keyCode] ?? "Key \(keyCode)"
|
||||
}
|
||||
|
||||
/// Converts AppKit modifier flags to the Carbon mask used by RegisterEventHotKey.
|
||||
static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
|
||||
var modifiers: UInt32 = 0
|
||||
if flags.contains(.command) { modifiers |= UInt32(cmdKey) }
|
||||
if flags.contains(.option) { modifiers |= UInt32(optionKey) }
|
||||
if flags.contains(.control) { modifiers |= UInt32(controlKey) }
|
||||
if flags.contains(.shift) { modifiers |= UInt32(shiftKey) }
|
||||
return modifiers
|
||||
}
|
||||
}
|
||||
|
||||
/// Greenshot's Windows defaults (PrintScreen based) have no direct macOS equivalent,
|
||||
/// so these mirror the Greenshot key layout using ⌘⇧ combinations that macOS leaves free.
|
||||
enum DefaultHotkeys {
|
||||
static let region = HotkeySpec(keyCode: UInt32(kVK_ANSI_4), modifiers: UInt32(cmdKey | shiftKey | optionKey))
|
||||
static let fullscreen = HotkeySpec(keyCode: UInt32(kVK_ANSI_3), modifiers: UInt32(cmdKey | shiftKey | optionKey))
|
||||
static let window = HotkeySpec(keyCode: UInt32(kVK_ANSI_5), modifiers: UInt32(cmdKey | shiftKey | optionKey))
|
||||
static let lastRegion = HotkeySpec(keyCode: UInt32(kVK_ANSI_6), modifiers: UInt32(cmdKey | shiftKey | optionKey))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ImageExporter.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's ImageIO: encoding a capture to the configured output format
|
||||
// and writing it to the configured directory using the filename pattern.
|
||||
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum ImageExporter {
|
||||
|
||||
static func data(from image: CGImage, format: OutputFormat, jpegQuality: Int) -> Data? {
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
rep.size = CGSize(width: image.width, height: image.height)
|
||||
|
||||
var properties: [NSBitmapImageRep.PropertyKey: Any] = [:]
|
||||
if format == .jpg {
|
||||
properties[.compressionFactor] = NSNumber(value: Double(jpegQuality) / 100.0)
|
||||
}
|
||||
return rep.representation(using: format.fileType, properties: properties)
|
||||
}
|
||||
|
||||
/// Flattens an image onto white, needed for formats without an alpha channel.
|
||||
static func flattened(_ image: CGImage) -> CGImage {
|
||||
guard let context = CGContext(data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return image }
|
||||
context.setFillColor(NSColor.white.cgColor)
|
||||
context.fill(CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
|
||||
/// The URL a capture would be saved to with the current configuration.
|
||||
static func suggestedURL(for details: CaptureDetails,
|
||||
format: OutputFormat = Configuration.shared.outputFileFormat) -> URL {
|
||||
let configuration = Configuration.shared
|
||||
let name = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
|
||||
details: details,
|
||||
format: format)
|
||||
let directory = URL(fileURLWithPath: configuration.outputFilePath, isDirectory: true)
|
||||
return uniqueURL(directory.appendingPathComponent(name))
|
||||
}
|
||||
|
||||
/// Appends " (2)", " (3)", … until the URL is free.
|
||||
static func uniqueURL(_ url: URL) -> URL {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return url }
|
||||
let directory = url.deletingLastPathComponent()
|
||||
let ext = url.pathExtension
|
||||
let base = url.deletingPathExtension().lastPathComponent
|
||||
var index = 2
|
||||
while index < 1000 {
|
||||
let candidate = directory
|
||||
.appendingPathComponent("\(base) (\(index))")
|
||||
.appendingPathExtension(ext)
|
||||
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
|
||||
index += 1
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func write(_ image: CGImage, to url: URL, format: OutputFormat) throws -> URL {
|
||||
let configuration = Configuration.shared
|
||||
let source = (format == .jpg || format == .bmp) ? flattened(image) : image
|
||||
guard let data = data(from: source, format: format, jpegQuality: configuration.outputFileJpegQuality) else {
|
||||
throw CaptureError.captureFailed("Could not encode the image as \(format.displayName).")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
static func format(forPathExtension pathExtension: String) -> OutputFormat {
|
||||
switch pathExtension.lowercased() {
|
||||
case "jpg", "jpeg": return .jpg
|
||||
case "tif", "tiff": return .tiff
|
||||
case "bmp": return .bmp
|
||||
case "gif": return .gif
|
||||
default: return .png
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// LaunchAtLogin.swift
|
||||
// GreenshotMac
|
||||
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
enum LaunchAtLogin {
|
||||
static var isEnabled: Bool {
|
||||
get { SMAppService.mainApp.status == .enabled }
|
||||
set {
|
||||
do {
|
||||
if newValue {
|
||||
if SMAppService.mainApp.status != .enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
}
|
||||
} else {
|
||||
if SMAppService.mainApp.status == .enabled {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("GreenshotMac: could not change launch at login: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// DestinationPicker.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's destination picker dialog: a preview of the capture,
|
||||
// one large button per destination, and "always use this destination".
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class DestinationPickerWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
private static var openPickers: [DestinationPickerWindowController] = []
|
||||
|
||||
private let capture: Capture
|
||||
private let destinations: [DestinationID]
|
||||
private let rememberCheckbox = NSButton(checkboxWithTitle: "Always use this destination (you can change it in Preferences)",
|
||||
target: nil,
|
||||
action: nil)
|
||||
|
||||
static func present(for capture: Capture) {
|
||||
let controller = DestinationPickerWindowController(capture: capture)
|
||||
openPickers.append(controller)
|
||||
controller.showWindow(nil)
|
||||
controller.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private init(capture: Capture) {
|
||||
self.capture = capture
|
||||
let configured = Configuration.shared.pickerDestinations.filter { $0 != .picker }
|
||||
self.destinations = configured.isEmpty ? [.editor, .file, .clipboard, .email] : configured
|
||||
|
||||
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 560, height: 560),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Choose Destination for Screenshot"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.center()
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
buildInterface()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
DestinationPickerWindowController.openPickers.removeAll { $0 === self }
|
||||
}
|
||||
|
||||
// MARK: - Interface
|
||||
|
||||
private func buildInterface() {
|
||||
guard let window, let contentView = window.contentView else { return }
|
||||
|
||||
let preview = NSImageView()
|
||||
preview.image = capture.nsImage
|
||||
preview.imageScaling = .scaleProportionallyDown
|
||||
preview.imageAlignment = .alignCenter
|
||||
preview.wantsLayer = true
|
||||
preview.layer?.backgroundColor = NSColor.underPageBackgroundColor.cgColor
|
||||
preview.layer?.borderColor = NSColor.separatorColor.cgColor
|
||||
preview.layer?.borderWidth = 1
|
||||
preview.layer?.cornerRadius = 4
|
||||
preview.translatesAutoresizingMaskIntoConstraints = false
|
||||
preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
preview.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
|
||||
let buttonRow = NSStackView()
|
||||
buttonRow.orientation = .horizontal
|
||||
buttonRow.alignment = .top
|
||||
buttonRow.distribution = .fillEqually
|
||||
buttonRow.spacing = 8
|
||||
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
for (index, destination) in destinations.enumerated() {
|
||||
buttonRow.addArrangedSubview(makeButton(for: destination, index: index))
|
||||
}
|
||||
|
||||
rememberCheckbox.target = self
|
||||
rememberCheckbox.action = #selector(rememberToggled)
|
||||
rememberCheckbox.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let separator = NSBox()
|
||||
separator.boxType = .separator
|
||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
contentView.addSubview(preview)
|
||||
contentView.addSubview(separator)
|
||||
contentView.addSubview(buttonRow)
|
||||
contentView.addSubview(rememberCheckbox)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
preview.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
|
||||
preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
|
||||
separator.topAnchor.constraint(equalTo: preview.bottomAnchor, constant: 14),
|
||||
separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
|
||||
buttonRow.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 14),
|
||||
buttonRow.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
buttonRow.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
|
||||
rememberCheckbox.topAnchor.constraint(equalTo: buttonRow.bottomAnchor, constant: 14),
|
||||
rememberCheckbox.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||
rememberCheckbox.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16)
|
||||
])
|
||||
|
||||
// Size the window so the preview keeps the capture's aspect ratio, within sensible bounds.
|
||||
let aspect = capture.pointSize.height / max(1, capture.pointSize.width)
|
||||
let previewWidth: CGFloat = 528
|
||||
let previewHeight = min(max(180, previewWidth * aspect), 380)
|
||||
preview.heightAnchor.constraint(equalToConstant: previewHeight).isActive = true
|
||||
window.setContentSize(NSSize(width: 560, height: previewHeight + 190))
|
||||
window.center()
|
||||
}
|
||||
|
||||
private func makeButton(for destination: DestinationID, index: Int) -> NSView {
|
||||
let button = NSButton()
|
||||
button.bezelStyle = .shadowlessSquare
|
||||
button.isBordered = false
|
||||
button.imagePosition = .imageAbove
|
||||
button.title = destination.pickerTitle
|
||||
button.font = NSFont.systemFont(ofSize: 11)
|
||||
button.image = NSImage(systemSymbolName: destination.symbolName, accessibilityDescription: destination.title)?
|
||||
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 34, weight: .regular))
|
||||
button.imageScaling = .scaleNone
|
||||
button.target = self
|
||||
button.action = #selector(destinationClicked(_:))
|
||||
button.tag = index
|
||||
button.toolTip = destination.title
|
||||
// ⌘1…⌘9 select a destination from the keyboard.
|
||||
if index < 9 {
|
||||
button.keyEquivalent = String(index + 1)
|
||||
button.keyEquivalentModifierMask = [.command]
|
||||
}
|
||||
button.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
button.heightAnchor.constraint(equalToConstant: 76).isActive = true
|
||||
return button
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func destinationClicked(_ sender: NSButton) {
|
||||
guard sender.tag >= 0, sender.tag < destinations.count else { return }
|
||||
let destination = destinations[sender.tag]
|
||||
|
||||
if rememberCheckbox.state == .on {
|
||||
Configuration.shared.defaultDestination = destination
|
||||
}
|
||||
close()
|
||||
Destinations.export(capture, to: destination)
|
||||
}
|
||||
|
||||
@objc private func rememberToggled() {}
|
||||
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
extension DestinationID {
|
||||
/// Shorter labels for the picker buttons, matching Greenshot's wording.
|
||||
var pickerTitle: String {
|
||||
switch self {
|
||||
case .editor: return "Edit in Greenshot"
|
||||
case .file: return "Save to Disk"
|
||||
case .fileWithDialog: return "Save As…"
|
||||
case .clipboard: return "Copy to Clipboard"
|
||||
case .email: return "Send as Mail"
|
||||
case .printer: return "Print"
|
||||
case .share: return "Share…"
|
||||
case .openWith: return "Open With…"
|
||||
case .picker: return "Choose…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Destinations.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's IDestination implementations for macOS.
|
||||
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
enum Destinations {
|
||||
|
||||
/// Sends a capture to a destination. `anchor` is used to position pickers and share sheets.
|
||||
@discardableResult
|
||||
static func export(_ capture: Capture,
|
||||
to destination: DestinationID,
|
||||
anchor: NSView? = nil) -> Bool {
|
||||
switch destination {
|
||||
case .picker:
|
||||
DestinationPickerWindowController.present(for: capture)
|
||||
return true
|
||||
case .editor:
|
||||
EditorWindowController.open(with: capture)
|
||||
return true
|
||||
case .clipboard:
|
||||
copyToClipboard(capture)
|
||||
Notifier.show(title: "Copied to clipboard", body: nil)
|
||||
return true
|
||||
case .file:
|
||||
return saveToFile(capture)
|
||||
case .fileWithDialog:
|
||||
return saveWithDialog(capture)
|
||||
case .printer:
|
||||
printCapture(capture)
|
||||
return true
|
||||
case .share:
|
||||
share(capture, anchor: anchor)
|
||||
return true
|
||||
case .email:
|
||||
sendAsEmail(capture, anchor: anchor)
|
||||
return true
|
||||
case .openWith:
|
||||
openWith(capture)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
|
||||
static func copyToClipboard(_ capture: Capture) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
let image = capture.nsImage
|
||||
pasteboard.writeObjects([image])
|
||||
if let data = ImageExporter.data(from: capture.image, format: .png, jpegQuality: 100) {
|
||||
pasteboard.setData(data, forType: .png)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
@discardableResult
|
||||
static func saveToFile(_ capture: Capture) -> Bool {
|
||||
let url = ImageExporter.suggestedURL(for: capture.details)
|
||||
do {
|
||||
try ImageExporter.write(capture.image, to: url, format: Configuration.shared.outputFileFormat)
|
||||
capture.details.filePath = url.path
|
||||
if Configuration.shared.copyPathToClipboard {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.path, forType: .string)
|
||||
}
|
||||
Notifier.show(title: "Screenshot saved", body: url.lastPathComponent, revealing: url)
|
||||
return true
|
||||
} catch {
|
||||
presentError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveWithDialog(_ capture: Capture) -> Bool {
|
||||
let panel = NSSavePanel()
|
||||
let configuration = Configuration.shared
|
||||
panel.nameFieldStringValue = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
|
||||
details: capture.details,
|
||||
format: configuration.outputFileFormat)
|
||||
panel.directoryURL = URL(fileURLWithPath: configuration.outputFilePath, isDirectory: true)
|
||||
panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp, .gif]
|
||||
panel.canCreateDirectories = true
|
||||
panel.isExtensionHidden = false
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return false }
|
||||
|
||||
let format = ImageExporter.format(forPathExtension: url.pathExtension)
|
||||
do {
|
||||
try ImageExporter.write(capture.image, to: url, format: format)
|
||||
capture.details.filePath = url.path
|
||||
return true
|
||||
} catch {
|
||||
presentError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Print
|
||||
|
||||
static func printCapture(_ capture: Capture) {
|
||||
let image = capture.nsImage
|
||||
let info = NSPrintInfo.shared
|
||||
info.horizontalPagination = .fit
|
||||
info.verticalPagination = .fit
|
||||
info.isHorizontallyCentered = true
|
||||
info.isVerticallyCentered = true
|
||||
info.orientation = image.size.width > image.size.height ? .landscape : .portrait
|
||||
|
||||
let imageView = NSImageView(frame: CGRect(origin: .zero, size: image.size))
|
||||
imageView.image = image
|
||||
imageView.imageScaling = .scaleProportionallyUpOrDown
|
||||
|
||||
let operation = NSPrintOperation(view: imageView, printInfo: info)
|
||||
operation.showsPrintPanel = true
|
||||
operation.showsProgressPanel = true
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
operation.run()
|
||||
}
|
||||
|
||||
// MARK: - Share
|
||||
|
||||
static func share(_ capture: Capture, anchor: NSView?) {
|
||||
guard let url = writeTemporaryFile(capture) else { return }
|
||||
let picker = NSSharingServicePicker(items: [url])
|
||||
if let anchor {
|
||||
picker.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY)
|
||||
} else if let window = anchorWindow() {
|
||||
let view = window.contentView ?? NSView()
|
||||
picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open with
|
||||
|
||||
static func openWith(_ capture: Capture) {
|
||||
guard let url = writeTemporaryFile(capture) else { return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.message = "Choose an application to open the screenshot with"
|
||||
panel.directoryURL = URL(fileURLWithPath: "/Applications")
|
||||
panel.allowedContentTypes = [.application]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard panel.runModal() == .OK, let application = panel.url else { return }
|
||||
NSWorkspace.shared.open([url],
|
||||
withApplicationAt: application,
|
||||
configuration: NSWorkspace.OpenConfiguration()) { _, error in
|
||||
if let error {
|
||||
DispatchQueue.main.async { presentError(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email
|
||||
|
||||
static func sendAsEmail(_ capture: Capture, anchor: NSView?) {
|
||||
guard let url = writeTemporaryFile(capture) else { return }
|
||||
let subject = capture.details.title.isEmpty ? "Screenshot" : "Screenshot: \(capture.details.title)"
|
||||
|
||||
if let service = NSSharingService(named: .composeEmail) {
|
||||
service.subject = subject
|
||||
service.perform(withItems: [url])
|
||||
} else {
|
||||
// No mail client is configured for the share service; fall back to the share sheet.
|
||||
share(capture, anchor: anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func writeTemporaryFile(_ capture: Capture) -> URL? {
|
||||
let configuration = Configuration.shared
|
||||
let name = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
|
||||
details: capture.details,
|
||||
format: configuration.outputFileFormat)
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("GreenshotMac", isDirectory: true)
|
||||
let url = ImageExporter.uniqueURL(directory.appendingPathComponent(name))
|
||||
do {
|
||||
try ImageExporter.write(capture.image, to: url, format: configuration.outputFileFormat)
|
||||
return url
|
||||
} catch {
|
||||
presentError(error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func anchorWindow() -> NSWindow? {
|
||||
NSApp.keyWindow ?? NSApp.windows.first { $0.isVisible }
|
||||
}
|
||||
|
||||
static func presentError(_ error: Error) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "GreenshotMac"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "OK")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Notifier.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Small wrapper around UserNotifications with a Finder-reveal fallback.
|
||||
|
||||
import AppKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
enum Notifier {
|
||||
private static var authorizationRequested = false
|
||||
private static var lastSavedURL: URL?
|
||||
|
||||
static func show(title: String, body: String?, revealing url: URL? = nil) {
|
||||
lastSavedURL = url ?? lastSavedURL
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
if !authorizationRequested {
|
||||
authorizationRequested = true
|
||||
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
if let body { content.body = body }
|
||||
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
center.add(request, withCompletionHandler: nil)
|
||||
}
|
||||
|
||||
static var mostRecentSavedURL: URL? { lastSavedURL }
|
||||
|
||||
static func revealMostRecent() {
|
||||
guard let url = lastSavedURL, FileManager.default.fileExists(atPath: url.path) else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// EditorCanvasView.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// The drawing canvas: hit testing, creation, moving, resizing and inline text editing.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
protocol EditorCanvasDelegate: AnyObject {
|
||||
func canvasDidChangeSelection(_ canvas: EditorCanvasView)
|
||||
func canvasDidChangeContent(_ canvas: EditorCanvasView)
|
||||
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind)
|
||||
}
|
||||
|
||||
enum ResizeHandle: Int, CaseIterable {
|
||||
case topLeft, top, topRight, right, bottomRight, bottom, bottomLeft, left
|
||||
|
||||
func point(in rect: CGRect) -> CGPoint {
|
||||
switch self {
|
||||
case .topLeft: return CGPoint(x: rect.minX, y: rect.minY)
|
||||
case .top: return CGPoint(x: rect.midX, y: rect.minY)
|
||||
case .topRight: return CGPoint(x: rect.maxX, y: rect.minY)
|
||||
case .right: return CGPoint(x: rect.maxX, y: rect.midY)
|
||||
case .bottomRight: return CGPoint(x: rect.maxX, y: rect.maxY)
|
||||
case .bottom: return CGPoint(x: rect.midX, y: rect.maxY)
|
||||
case .bottomLeft: return CGPoint(x: rect.minX, y: rect.maxY)
|
||||
case .left: return CGPoint(x: rect.minX, y: rect.midY)
|
||||
}
|
||||
}
|
||||
|
||||
func resized(_ rect: CGRect, to point: CGPoint) -> CGRect {
|
||||
var result = rect
|
||||
switch self {
|
||||
case .topLeft:
|
||||
result = CGRect(x: point.x, y: point.y, width: rect.maxX - point.x, height: rect.maxY - point.y)
|
||||
case .top:
|
||||
result = CGRect(x: rect.minX, y: point.y, width: rect.width, height: rect.maxY - point.y)
|
||||
case .topRight:
|
||||
result = CGRect(x: rect.minX, y: point.y, width: point.x - rect.minX, height: rect.maxY - point.y)
|
||||
case .right:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: point.x - rect.minX, height: rect.height)
|
||||
case .bottomRight:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: point.x - rect.minX, height: point.y - rect.minY)
|
||||
case .bottom:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: point.y - rect.minY)
|
||||
case .bottomLeft:
|
||||
result = CGRect(x: point.x, y: rect.minY, width: rect.maxX - point.x, height: point.y - rect.minY)
|
||||
case .left:
|
||||
result = CGRect(x: point.x, y: rect.minY, width: rect.maxX - point.x, height: rect.height)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
final class EditorCanvasView: NSView, NSTextViewDelegate {
|
||||
|
||||
let surface: Surface
|
||||
weak var delegate: EditorCanvasDelegate?
|
||||
|
||||
var tool: ToolKind = .select {
|
||||
didSet {
|
||||
endTextEditing()
|
||||
if tool != .select { surface.selection = nil }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style applied to newly created elements.
|
||||
var style = ElementStyle()
|
||||
|
||||
var zoom: CGFloat = 1 {
|
||||
didSet { refreshFrame() }
|
||||
}
|
||||
|
||||
/// Resizes the view to the image size at the current zoom. Called after a zoom change
|
||||
/// and whenever the surface itself changes size, such as after a crop.
|
||||
func refreshFrame() {
|
||||
frame = CGRect(origin: frame.origin,
|
||||
size: CGSize(width: surface.imageSize.width * zoom,
|
||||
height: surface.imageSize.height * zoom))
|
||||
invalidateIntrinsicContentSize()
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private enum Interaction {
|
||||
case none
|
||||
case creating(Element)
|
||||
case moving(Element, CGPoint)
|
||||
case resizing(Element, ResizeHandle)
|
||||
case drawingFreehand(FreehandElement)
|
||||
}
|
||||
|
||||
private var interaction: Interaction = .none
|
||||
private var textEditor: NSTextView?
|
||||
private var editingElement: TextElement?
|
||||
|
||||
init(surface: Surface) {
|
||||
self.surface = surface
|
||||
super.init(frame: CGRect(origin: .zero, size: surface.imageSize))
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
surface.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.needsDisplay = true
|
||||
self.delegate?.canvasDidChangeContent(self)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
override var isFlipped: Bool { true }
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
CGSize(width: surface.imageSize.width * zoom, height: surface.imageSize.height * zoom)
|
||||
}
|
||||
|
||||
func imagePoint(from viewPoint: CGPoint) -> CGPoint {
|
||||
CGPoint(x: viewPoint.x / zoom, y: viewPoint.y / zoom)
|
||||
}
|
||||
|
||||
func viewRect(from imageRect: CGRect) -> CGRect {
|
||||
CGRect(x: imageRect.minX * zoom, y: imageRect.minY * zoom,
|
||||
width: imageRect.width * zoom, height: imageRect.height * zoom)
|
||||
}
|
||||
|
||||
// MARK: - Drawing
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
|
||||
context.saveGState()
|
||||
context.scaleBy(x: zoom, y: zoom)
|
||||
context.interpolationQuality = zoom > 1 ? .none : .high
|
||||
drawFlipped(surface.baseImage, in: CGRect(origin: .zero, size: surface.imageSize), context: context)
|
||||
for element in surface.elements where !(element is CropElement) {
|
||||
element.draw(in: context, baseImage: surface.baseImage, imageSize: surface.imageSize)
|
||||
}
|
||||
context.restoreGState()
|
||||
|
||||
if let crop = surface.cropElement {
|
||||
drawCropOverlay(crop)
|
||||
}
|
||||
if let selection = surface.selection, !(selection is CropElement) {
|
||||
drawSelection(selection)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawSelection(_ element: Element) {
|
||||
let rect = viewRect(from: element.normalizedFrame).insetBy(dx: -2, dy: -2)
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
let path = NSBezierPath(rect: rect)
|
||||
path.lineWidth = 1
|
||||
path.setLineDash([4, 3], count: 2, phase: 0)
|
||||
path.stroke()
|
||||
|
||||
guard element.isResizable else { return }
|
||||
for handle in ResizeHandle.allCases {
|
||||
let point = handle.point(in: rect)
|
||||
let box = CGRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)
|
||||
NSColor.white.setFill()
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
let handlePath = NSBezierPath(rect: box)
|
||||
handlePath.fill()
|
||||
handlePath.lineWidth = 1
|
||||
handlePath.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private func drawCropOverlay(_ crop: CropElement) {
|
||||
let rect = viewRect(from: crop.normalizedFrame)
|
||||
NSColor(calibratedWhite: 0, alpha: 0.45).setFill()
|
||||
let path = NSBezierPath(rect: bounds)
|
||||
path.append(NSBezierPath(rect: rect))
|
||||
path.windingRule = .evenOdd
|
||||
path.fill()
|
||||
|
||||
NSColor.systemGreen.setStroke()
|
||||
let border = NSBezierPath(rect: rect)
|
||||
border.lineWidth = 1.5
|
||||
border.stroke()
|
||||
|
||||
for handle in ResizeHandle.allCases {
|
||||
let point = handle.point(in: rect)
|
||||
NSColor.systemGreen.setFill()
|
||||
NSBezierPath(rect: CGRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)).fill()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cursor
|
||||
|
||||
override func resetCursorRects() {
|
||||
let cursor: NSCursor
|
||||
switch tool {
|
||||
case .select: cursor = .arrow
|
||||
case .text: cursor = .iBeam
|
||||
default: cursor = .crosshair
|
||||
}
|
||||
addCursorRect(bounds, cursor: cursor)
|
||||
}
|
||||
|
||||
// MARK: - Mouse
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
endTextEditing()
|
||||
let point = imagePoint(from: convert(event.locationInWindow, from: nil))
|
||||
|
||||
switch tool {
|
||||
case .select:
|
||||
handleSelectMouseDown(at: point, event: event)
|
||||
case .crop:
|
||||
beginCrop(at: point)
|
||||
case .freehand:
|
||||
let element = FreehandElement(points: [point], style: style)
|
||||
surface.add(element)
|
||||
interaction = .drawingFreehand(element)
|
||||
case .text:
|
||||
let element = TextElement(frame: CGRect(x: point.x, y: point.y, width: 180, height: style.fontSize + 12),
|
||||
style: style)
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .none
|
||||
// The tool must stay on .text until editing finishes: switching tools calls
|
||||
// endTextEditing(), which would discard the element before anything is typed.
|
||||
beginTextEditing(element)
|
||||
case .counter:
|
||||
let element = CounterElement(center: point, number: surface.nextCounterNumber, style: style)
|
||||
surface.nextCounterNumber += 1
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .none
|
||||
case .rectangle, .ellipse, .line, .arrow, .highlight, .obfuscate:
|
||||
let element = makeElement(for: tool, at: point)
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .creating(element)
|
||||
}
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
|
||||
private func makeElement(for tool: ToolKind, at point: CGPoint) -> Element {
|
||||
let frame = CGRect(origin: point, size: .zero)
|
||||
switch tool {
|
||||
case .rectangle: return RectangleElement(frame: frame, style: style)
|
||||
case .ellipse: return EllipseElement(frame: frame, style: style)
|
||||
case .line: return LineElement(frame: frame, style: style)
|
||||
case .arrow: return ArrowElement(frame: frame, style: style)
|
||||
case .highlight: return HighlightElement(frame: frame, style: style)
|
||||
case .obfuscate: return ObfuscateElement(frame: frame, style: style)
|
||||
default: return RectangleElement(frame: frame, style: style)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectMouseDown(at point: CGPoint, event: NSEvent) {
|
||||
if let selection = surface.selection, selection.isResizable {
|
||||
let rect = selection.normalizedFrame
|
||||
for handle in ResizeHandle.allCases {
|
||||
let handlePoint = handle.point(in: rect)
|
||||
if hypot(handlePoint.x - point.x, handlePoint.y - point.y) <= 6 / zoom {
|
||||
surface.registerGeometryUndo(for: selection)
|
||||
interaction = .resizing(selection, handle)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let element = surface.element(at: point) {
|
||||
surface.selection = element
|
||||
if event.clickCount == 2, let text = element as? TextElement {
|
||||
beginTextEditing(text)
|
||||
interaction = .none
|
||||
return
|
||||
}
|
||||
surface.registerGeometryUndo(for: element)
|
||||
interaction = .moving(element, point)
|
||||
} else {
|
||||
surface.selection = nil
|
||||
interaction = .none
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCrop(at point: CGPoint) {
|
||||
if let existing = surface.cropElement {
|
||||
surface.remove(existing)
|
||||
}
|
||||
let crop = CropElement(frame: CGRect(origin: point, size: .zero), style: style)
|
||||
surface.add(crop)
|
||||
surface.selection = crop
|
||||
interaction = .creating(crop)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
let point = imagePoint(from: convert(event.locationInWindow, from: nil))
|
||||
let constrain = event.modifierFlags.contains(.shift)
|
||||
|
||||
switch interaction {
|
||||
case .creating(let element):
|
||||
var target = point
|
||||
if constrain {
|
||||
if element is LineElement {
|
||||
target = constrainedLineEnd(from: element.frame.origin, to: point)
|
||||
} else {
|
||||
let side = max(abs(point.x - element.frame.origin.x), abs(point.y - element.frame.origin.y))
|
||||
target = CGPoint(x: element.frame.origin.x + side * (point.x < element.frame.origin.x ? -1 : 1),
|
||||
y: element.frame.origin.y + side * (point.y < element.frame.origin.y ? -1 : 1))
|
||||
}
|
||||
}
|
||||
element.frame = CGRect(x: element.frame.origin.x,
|
||||
y: element.frame.origin.y,
|
||||
width: target.x - element.frame.origin.x,
|
||||
height: target.y - element.frame.origin.y)
|
||||
surface.changed()
|
||||
case .moving(let element, let origin):
|
||||
element.move(by: CGVector(dx: point.x - origin.x, dy: point.y - origin.y))
|
||||
interaction = .moving(element, point)
|
||||
surface.changed()
|
||||
case .resizing(let element, let handle):
|
||||
element.resize(to: handle.resized(element.normalizedFrame, to: point))
|
||||
surface.changed()
|
||||
case .drawingFreehand(let element):
|
||||
element.append(point)
|
||||
surface.changed()
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private func constrainedLineEnd(from start: CGPoint, to point: CGPoint) -> CGPoint {
|
||||
let dx = point.x - start.x
|
||||
let dy = point.y - start.y
|
||||
let angle = (atan2(dy, dx) / (.pi / 4)).rounded() * (.pi / 4)
|
||||
let length = hypot(dx, dy)
|
||||
return CGPoint(x: start.x + cos(angle) * length, y: start.y + sin(angle) * length)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
switch interaction {
|
||||
case .creating(let element):
|
||||
let rect = element.normalizedFrame
|
||||
let tooSmall = element is LineElement
|
||||
? hypot(element.frame.width, element.frame.height) < 4
|
||||
: (rect.width < 4 || rect.height < 4)
|
||||
if tooSmall {
|
||||
surface.remove(element)
|
||||
} else if !(element is LineElement) {
|
||||
element.frame = rect
|
||||
if element is CropElement { surface.cropElement = element as? CropElement }
|
||||
}
|
||||
if !(element is CropElement) {
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
}
|
||||
case .moving, .resizing, .drawingFreehand, .none:
|
||||
break
|
||||
}
|
||||
interaction = .none
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
|
||||
// MARK: - Keyboard
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
switch event.keyCode {
|
||||
case 51, 117: // Delete, forward delete
|
||||
if surface.selection != nil {
|
||||
surface.removeSelection()
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
return
|
||||
}
|
||||
case 53: // Escape
|
||||
if let crop = surface.cropElement {
|
||||
surface.remove(crop)
|
||||
}
|
||||
surface.selection = nil
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
return
|
||||
case 36: // Return applies a pending crop
|
||||
if let crop = surface.cropElement {
|
||||
surface.applyCrop(to: crop.normalizedFrame)
|
||||
refreshFrame()
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
return
|
||||
}
|
||||
case 123, 124, 125, 126: // Arrow keys nudge the selection
|
||||
if let selection = surface.selection {
|
||||
let step: CGFloat = event.modifierFlags.contains(.shift) ? 10 : 1
|
||||
let delta: CGVector
|
||||
switch event.keyCode {
|
||||
case 123: delta = CGVector(dx: -step, dy: 0)
|
||||
case 124: delta = CGVector(dx: step, dy: 0)
|
||||
case 125: delta = CGVector(dx: 0, dy: step)
|
||||
default: delta = CGVector(dx: 0, dy: -step)
|
||||
}
|
||||
surface.registerGeometryUndo(for: selection)
|
||||
selection.move(by: delta)
|
||||
surface.changed()
|
||||
return
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
// MARK: - Inline text editing
|
||||
|
||||
func beginTextEditing(_ element: TextElement) {
|
||||
endTextEditing()
|
||||
let rect = viewRect(from: element.normalizedFrame)
|
||||
let textView = NSTextView(frame: rect)
|
||||
textView.delegate = self
|
||||
textView.isRichText = false
|
||||
textView.font = element.style.font
|
||||
textView.textColor = element.style.lineColor
|
||||
textView.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85)
|
||||
textView.drawsBackground = true
|
||||
textView.string = element.text
|
||||
textView.isVerticallyResizable = true
|
||||
textView.textContainerInset = CGSize(width: 2, height: 2)
|
||||
addSubview(textView)
|
||||
window?.makeFirstResponder(textView)
|
||||
|
||||
textEditor = textView
|
||||
editingElement = element
|
||||
}
|
||||
|
||||
func endTextEditing() {
|
||||
guard let textEditor, let editingElement else { return }
|
||||
editingElement.text = textEditor.string
|
||||
if editingElement.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
surface.remove(editingElement)
|
||||
} else {
|
||||
let size = editingElement.fittingSize(maxWidth: max(80, editingElement.normalizedFrame.width))
|
||||
editingElement.frame = CGRect(origin: editingElement.normalizedFrame.origin,
|
||||
size: CGSize(width: max(size.width, editingElement.normalizedFrame.width),
|
||||
height: max(size.height, editingElement.normalizedFrame.height)))
|
||||
}
|
||||
textEditor.removeFromSuperview()
|
||||
self.textEditor = nil
|
||||
self.editingElement = nil
|
||||
window?.makeFirstResponder(self)
|
||||
surface.changed()
|
||||
needsDisplay = true
|
||||
|
||||
if tool == .text {
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
}
|
||||
}
|
||||
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
endTextEditing()
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
|
||||
endTextEditing()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Style application
|
||||
|
||||
/// Applies the current style to the selected element, or stores it for the next one.
|
||||
func applyStyleToSelection(_ mutate: (inout ElementStyle) -> Void) {
|
||||
mutate(&style)
|
||||
if let selection = surface.selection {
|
||||
var elementStyle = selection.style
|
||||
mutate(&elementStyle)
|
||||
selection.style = elementStyle
|
||||
surface.changed()
|
||||
}
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// EditorWindowController.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's ImageEditorForm.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
||||
EditorCanvasDelegate, StyleBarDelegate {
|
||||
|
||||
private static var openEditors: [EditorWindowController] = []
|
||||
|
||||
/// The one-click palette in the toolbar, matching Greenshot's swatch row.
|
||||
private static let palette: [NSColor] = [
|
||||
.systemGreen, .systemRed, .systemYellow, .systemBlue,
|
||||
.systemPurple, .cyan, .black, .white
|
||||
]
|
||||
|
||||
let surface: Surface
|
||||
private let canvas: EditorCanvasView
|
||||
private let scrollView = NSScrollView()
|
||||
private let toolBar = NSStackView()
|
||||
private let styleBar = StyleBarView()
|
||||
private var toolButtons: [ToolKind: NSButton] = [:]
|
||||
private var deleteButton: NSButton?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@discardableResult
|
||||
static func open(with capture: Capture) -> EditorWindowController {
|
||||
let controller = EditorWindowController(capture: capture)
|
||||
openEditors.append(controller)
|
||||
controller.showWindow(nil)
|
||||
controller.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return controller
|
||||
}
|
||||
|
||||
init(capture: Capture) {
|
||||
surface = Surface(capture: capture)
|
||||
canvas = EditorCanvasView(surface: surface)
|
||||
|
||||
let size = surface.imageSize
|
||||
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
|
||||
let contentWidth = min(max(720, size.width), maxSize.width - 80)
|
||||
let contentHeight = min(max(400, size.height + 96), maxSize.height - 80)
|
||||
|
||||
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = capture.details.title.isEmpty
|
||||
? "Screenshot"
|
||||
: "Screenshot — \(capture.details.title)"
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
super.init(window: window)
|
||||
|
||||
window.delegate = self
|
||||
buildInterface()
|
||||
canvas.delegate = self
|
||||
select(tool: .select)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
||||
surface.undoManager
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
canvas.endTextEditing()
|
||||
EditorWindowController.openEditors.removeAll { $0 === self }
|
||||
}
|
||||
|
||||
func windowDidResize(_ notification: Notification) {
|
||||
// Nothing to do; the canvas keeps its own size. Fit-to-window is explicit.
|
||||
}
|
||||
|
||||
// MARK: - Interface
|
||||
|
||||
private func buildInterface() {
|
||||
guard let window, let contentView = window.contentView else { return }
|
||||
|
||||
toolBar.orientation = .horizontal
|
||||
toolBar.spacing = 4
|
||||
toolBar.alignment = .centerY
|
||||
toolBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
|
||||
// Destinations first, as in the Greenshot toolbar.
|
||||
for destination in [DestinationID.file, .email, .clipboard, .fileWithDialog, .printer, .share] {
|
||||
toolBar.addArrangedSubview(destinationButton(for: destination))
|
||||
}
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Drawing tools.
|
||||
for tool in ToolKind.allCases {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
|
||||
button.target = self
|
||||
button.action = #selector(toolButtonClicked(_:))
|
||||
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
|
||||
toolButtons[tool] = button
|
||||
toolBar.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Delete the selected element.
|
||||
let delete = NSButton()
|
||||
EditorWindowController.configure(delete, symbol: "minus.circle", fallbackTitle: "Delete")
|
||||
delete.contentTintColor = .systemRed
|
||||
delete.bezelStyle = .texturedRounded
|
||||
delete.toolTip = "Delete the selected element (⌫)"
|
||||
delete.target = self
|
||||
delete.action = #selector(deleteSelection(_:))
|
||||
delete.isEnabled = false
|
||||
deleteButton = delete
|
||||
toolBar.addArrangedSubview(delete)
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// One-click colour palette.
|
||||
for (index, color) in EditorWindowController.palette.enumerated() {
|
||||
let swatch = NSButton()
|
||||
swatch.image = EditorWindowController.swatchImage(color)
|
||||
swatch.imagePosition = .imageOnly
|
||||
swatch.isBordered = false
|
||||
swatch.toolTip = "Set colour"
|
||||
swatch.target = self
|
||||
swatch.action = #selector(colorSwatchClicked(_:))
|
||||
swatch.tag = index
|
||||
swatch.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
toolBar.addArrangedSubview(swatch)
|
||||
}
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Zoom controls.
|
||||
let zoomControls: [(String, String, Selector)] = [
|
||||
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
|
||||
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
|
||||
("1.magnifyingglass", "Actual size (⌘0)", #selector(actualSize(_:))),
|
||||
("arrow.up.left.and.down.right.magnifyingglass", "Fit to window", #selector(fitToWindow(_:)))
|
||||
]
|
||||
for (symbol, tip, action) in zoomControls {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.toolTip = tip
|
||||
button.target = self
|
||||
button.action = action
|
||||
toolBar.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
styleBar.delegate = self
|
||||
|
||||
scrollView.documentView = canvas
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = true
|
||||
scrollView.allowsMagnification = false
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.backgroundColor = .underPageBackgroundColor
|
||||
scrollView.drawsBackground = true
|
||||
|
||||
let stack = NSStackView(views: [toolBar, styleBar, scrollView])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 0
|
||||
stack.alignment = .leading
|
||||
stack.distribution = .fill
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
toolBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
toolBar.trailingAnchor.constraint(lessThanOrEqualTo: stack.trailingAnchor),
|
||||
styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
|
||||
])
|
||||
|
||||
window.makeFirstResponder(canvas)
|
||||
}
|
||||
|
||||
private func destinationButton(for destination: DestinationID) -> NSButton {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.toolTip = destination.title
|
||||
button.target = self
|
||||
button.action = #selector(destinationButtonClicked(_:))
|
||||
button.tag = DestinationID.allCases.firstIndex(of: destination) ?? 0
|
||||
return button
|
||||
}
|
||||
|
||||
/// Uses the SF Symbol when the running system has it, and a short text label otherwise,
|
||||
/// so a missing symbol never leaves an invisible toolbar button.
|
||||
private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) {
|
||||
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle) {
|
||||
button.image = image
|
||||
button.imagePosition = .imageOnly
|
||||
} else {
|
||||
button.title = fallbackTitle
|
||||
button.imagePosition = .noImage
|
||||
button.font = NSFont.systemFont(ofSize: 11)
|
||||
}
|
||||
}
|
||||
|
||||
private static func swatchImage(_ color: NSColor) -> NSImage {
|
||||
let size = NSSize(width: 14, height: 14)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5))
|
||||
color.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
func select(tool: ToolKind) {
|
||||
canvas.tool = tool
|
||||
for (kind, button) in toolButtons {
|
||||
button.state = kind == tool ? .on : .off
|
||||
}
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
@objc private func toolButtonClicked(_ sender: NSButton) {
|
||||
let tool = ToolKind.allCases[min(sender.tag, ToolKind.allCases.count - 1)]
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
@objc private func destinationButtonClicked(_ sender: NSButton) {
|
||||
let destination = DestinationID.allCases[min(sender.tag, DestinationID.allCases.count - 1)]
|
||||
export(to: destination, anchor: sender)
|
||||
}
|
||||
|
||||
@objc private func colorSwatchClicked(_ sender: NSButton) {
|
||||
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
|
||||
let color = EditorWindowController.palette[sender.tag]
|
||||
|
||||
// The highlighter has its own colour field; everything else uses the line colour.
|
||||
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
|
||||
canvas.applyStyleToSelection { style in
|
||||
if isHighlight {
|
||||
style.highlightColor = color
|
||||
} else {
|
||||
style.lineColor = color
|
||||
}
|
||||
}
|
||||
if isHighlight {
|
||||
Configuration.shared.editorHighlightColor = color
|
||||
} else {
|
||||
Configuration.shared.editorLineColor = color
|
||||
}
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
private func updateStyleBar() {
|
||||
let capabilities: StyleCapabilities
|
||||
let style: ElementStyle
|
||||
if let selection = surface.selection, !(selection is CropElement) {
|
||||
capabilities = selection.capabilities
|
||||
style = selection.style
|
||||
} else {
|
||||
capabilities = capabilitiesForTool(canvas.tool)
|
||||
style = canvas.style
|
||||
}
|
||||
styleBar.update(capabilities: capabilities,
|
||||
style: style,
|
||||
showCrop: surface.cropElement != nil)
|
||||
deleteButton?.isEnabled = surface.selection != nil
|
||||
}
|
||||
|
||||
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
|
||||
switch tool {
|
||||
case .select: return []
|
||||
case .rectangle, .ellipse: return [.lineColor, .fillColor, .lineThickness, .shadow]
|
||||
case .line, .arrow, .freehand: return [.lineColor, .lineThickness, .shadow]
|
||||
case .text: return [.lineColor, .fillColor, .font, .shadow]
|
||||
case .counter: return [.lineColor, .fillColor, .font, .shadow]
|
||||
case .highlight: return [.highlightColor]
|
||||
case .obfuscate: return [.obfuscation]
|
||||
case .crop: return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canvas delegate
|
||||
|
||||
func canvasDidChangeSelection(_ canvas: EditorCanvasView) {
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
func canvasDidChangeContent(_ canvas: EditorCanvasView) {
|
||||
canvas.invalidateIntrinsicContentSize()
|
||||
window?.isDocumentEdited = surface.hasUnsavedChanges
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind) {
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
// MARK: - Style bar delegate
|
||||
|
||||
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
|
||||
canvas.applyStyleToSelection(mutate)
|
||||
}
|
||||
|
||||
func styleBarDidApplyCrop(_ bar: StyleBarView) {
|
||||
applyCrop(nil)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@IBAction func applyCrop(_ sender: Any?) {
|
||||
guard let crop = surface.cropElement else { return }
|
||||
surface.applyCrop(to: crop.normalizedFrame)
|
||||
canvas.refreshFrame()
|
||||
select(tool: .select)
|
||||
}
|
||||
|
||||
func export(to destination: DestinationID, anchor: NSView? = nil) {
|
||||
canvas.endTextEditing()
|
||||
let capture = surface.renderedCapture()
|
||||
Destinations.export(capture, to: destination, anchor: anchor ?? canvas)
|
||||
if destination == .file || destination == .fileWithDialog {
|
||||
surface.hasUnsavedChanges = false
|
||||
window?.isDocumentEdited = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func saveDocument(_ sender: Any?) { export(to: .file) }
|
||||
@IBAction func saveDocumentAs(_ sender: Any?) { export(to: .fileWithDialog) }
|
||||
@IBAction func copy(_ sender: Any?) { export(to: .clipboard) }
|
||||
@IBAction func printDocument(_ sender: Any?) { export(to: .printer) }
|
||||
@IBAction func shareDocument(_ sender: Any?) { export(to: .share, anchor: canvas) }
|
||||
@IBAction func sendAsEmail(_ sender: Any?) { export(to: .email) }
|
||||
|
||||
@IBAction func zoomIn(_ sender: Any?) { canvas.zoom = min(8, canvas.zoom * 1.25) }
|
||||
@IBAction func zoomOut(_ sender: Any?) { canvas.zoom = max(0.1, canvas.zoom / 1.25) }
|
||||
@IBAction func actualSize(_ sender: Any?) { canvas.zoom = 1 }
|
||||
|
||||
@IBAction func fitToWindow(_ sender: Any?) {
|
||||
let available = scrollView.contentView.bounds.size
|
||||
guard available.width > 1, available.height > 1,
|
||||
surface.imageSize.width > 1, surface.imageSize.height > 1 else { return }
|
||||
let fit = min(available.width / surface.imageSize.width,
|
||||
available.height / surface.imageSize.height)
|
||||
canvas.zoom = min(1, max(0.1, fit))
|
||||
}
|
||||
|
||||
@IBAction func selectTool(_ sender: NSMenuItem) {
|
||||
guard let raw = sender.representedObject as? String, let tool = ToolKind(rawValue: raw) else { return }
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
@IBAction func deleteSelection(_ sender: Any?) {
|
||||
surface.removeSelection()
|
||||
canvas.needsDisplay = true
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
@IBAction func duplicateSelection(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.duplicate(selection)
|
||||
canvas.needsDisplay = true
|
||||
}
|
||||
|
||||
@IBAction func bringToFront(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.bringToFront(selection)
|
||||
}
|
||||
|
||||
@IBAction func sendToBack(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.sendToBack(selection)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSBox {
|
||||
static func separator() -> NSBox {
|
||||
let box = NSBox()
|
||||
box.translatesAutoresizingMaskIntoConstraints = false
|
||||
box.boxType = .separator
|
||||
box.widthAnchor.constraint(equalToConstant: 12).isActive = true
|
||||
return box
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Elements.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's DrawableContainer hierarchy. Every element lives in
|
||||
// image point coordinates with a top-left origin, matching the flipped canvas.
|
||||
|
||||
import AppKit
|
||||
import CoreImage
|
||||
|
||||
enum ToolKind: String, CaseIterable {
|
||||
case select
|
||||
case rectangle
|
||||
case ellipse
|
||||
case line
|
||||
case arrow
|
||||
case freehand
|
||||
case text
|
||||
case highlight
|
||||
case obfuscate
|
||||
case counter
|
||||
case crop
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .select: return "Select"
|
||||
case .rectangle: return "Rectangle"
|
||||
case .ellipse: return "Ellipse"
|
||||
case .line: return "Line"
|
||||
case .arrow: return "Arrow"
|
||||
case .freehand: return "Freehand"
|
||||
case .text: return "Text"
|
||||
case .highlight: return "Highlight"
|
||||
case .obfuscate: return "Obfuscate"
|
||||
case .counter: return "Step counter"
|
||||
case .crop: return "Crop"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .select: return "cursorarrow"
|
||||
case .rectangle: return "rectangle"
|
||||
case .ellipse: return "circle"
|
||||
case .line: return "line.diagonal"
|
||||
case .arrow: return "arrow.up.right"
|
||||
case .freehand: return "scribble"
|
||||
case .text: return "textformat"
|
||||
case .highlight: return "highlighter"
|
||||
case .obfuscate: return "square.grid.3x3.fill"
|
||||
case .counter: return "1.circle"
|
||||
case .crop: return "crop"
|
||||
}
|
||||
}
|
||||
|
||||
var keyEquivalent: String {
|
||||
switch self {
|
||||
case .select: return "v"
|
||||
case .rectangle: return "r"
|
||||
case .ellipse: return "e"
|
||||
case .line: return "l"
|
||||
case .arrow: return "a"
|
||||
case .freehand: return "f"
|
||||
case .text: return "t"
|
||||
case .highlight: return "h"
|
||||
case .obfuscate: return "o"
|
||||
case .counter: return "c"
|
||||
case .crop: return "k"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ObfuscateMode: String, CaseIterable {
|
||||
case pixelate
|
||||
case blur
|
||||
|
||||
var title: String { self == .pixelate ? "Pixelate" : "Blur" }
|
||||
}
|
||||
|
||||
/// The drawing fields Greenshot keeps per element (FieldType in the original).
|
||||
struct ElementStyle {
|
||||
var lineColor: NSColor = Configuration.shared.editorLineColor
|
||||
var fillColor: NSColor = Configuration.shared.editorFillColor
|
||||
var lineThickness: CGFloat = CGFloat(Configuration.shared.editorLineThickness)
|
||||
var highlightColor: NSColor = Configuration.shared.editorHighlightColor
|
||||
var fontFamily: String = Configuration.shared.editorFontFamily
|
||||
var fontSize: CGFloat = CGFloat(Configuration.shared.editorFontSize)
|
||||
var bold: Bool = false
|
||||
var italic: Bool = false
|
||||
var blurRadius: CGFloat = CGFloat(Configuration.shared.editorBlurRadius)
|
||||
var pixelSize: CGFloat = CGFloat(Configuration.shared.editorPixelSize)
|
||||
var obfuscateMode: ObfuscateMode = .pixelate
|
||||
var dropShadow: Bool = Configuration.shared.editorDropShadow
|
||||
|
||||
var font: NSFont {
|
||||
var traits: NSFontDescriptor.SymbolicTraits = []
|
||||
if bold { traits.insert(.bold) }
|
||||
if italic { traits.insert(.italic) }
|
||||
let descriptor = NSFontDescriptor(name: fontFamily, size: fontSize).withSymbolicTraits(traits)
|
||||
return NSFont(descriptor: descriptor, size: fontSize)
|
||||
?? NSFont.systemFont(ofSize: fontSize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which style fields a tool exposes in the style bar.
|
||||
struct StyleCapabilities: OptionSet, Hashable {
|
||||
let rawValue: Int
|
||||
static let lineColor = StyleCapabilities(rawValue: 1 << 0)
|
||||
static let fillColor = StyleCapabilities(rawValue: 1 << 1)
|
||||
static let lineThickness = StyleCapabilities(rawValue: 1 << 2)
|
||||
static let font = StyleCapabilities(rawValue: 1 << 3)
|
||||
static let obfuscation = StyleCapabilities(rawValue: 1 << 4)
|
||||
static let highlightColor = StyleCapabilities(rawValue: 1 << 5)
|
||||
static let shadow = StyleCapabilities(rawValue: 1 << 6)
|
||||
}
|
||||
|
||||
// MARK: - Base element
|
||||
|
||||
class Element {
|
||||
var frame: CGRect
|
||||
var style: ElementStyle
|
||||
|
||||
init(frame: CGRect, style: ElementStyle) {
|
||||
self.frame = frame
|
||||
self.style = style
|
||||
}
|
||||
|
||||
var capabilities: StyleCapabilities { [.lineColor, .fillColor, .lineThickness, .shadow] }
|
||||
|
||||
/// Elements that read the pixels underneath them (obfuscate, highlight) need the base image.
|
||||
var needsBaseImage: Bool { false }
|
||||
|
||||
var isResizable: Bool { true }
|
||||
|
||||
func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {}
|
||||
|
||||
func hitTest(_ point: CGPoint) -> Bool {
|
||||
frame.insetBy(dx: -4, dy: -4).contains(point)
|
||||
}
|
||||
|
||||
func move(by delta: CGVector) {
|
||||
frame = frame.offsetBy(dx: delta.dx, dy: delta.dy)
|
||||
}
|
||||
|
||||
func resize(to rect: CGRect) {
|
||||
frame = rect
|
||||
}
|
||||
|
||||
func copyElement() -> Element {
|
||||
let copy = Element(frame: frame, style: style)
|
||||
return copy
|
||||
}
|
||||
|
||||
// MARK: Drawing helpers
|
||||
|
||||
func applyShadow(_ context: CGContext) {
|
||||
guard style.dropShadow else { return }
|
||||
context.setShadow(offset: CGSize(width: 1.5, height: 1.5),
|
||||
blur: 3,
|
||||
color: NSColor.black.withAlphaComponent(0.5).cgColor)
|
||||
}
|
||||
|
||||
var normalizedFrame: CGRect {
|
||||
CGRect(x: min(frame.minX, frame.maxX),
|
||||
y: min(frame.minY, frame.maxY),
|
||||
width: abs(frame.width),
|
||||
height: abs(frame.height))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shapes
|
||||
|
||||
final class RectangleElement: Element {
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
|
||||
if style.fillColor.alphaComponent > 0 {
|
||||
context.setFillColor(style.fillColor.cgColor)
|
||||
context.fill(rect)
|
||||
}
|
||||
if style.lineThickness > 0 {
|
||||
context.setStrokeColor(style.lineColor.cgColor)
|
||||
context.setLineWidth(style.lineThickness)
|
||||
context.stroke(rect)
|
||||
}
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { RectangleElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
final class EllipseElement: Element {
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
|
||||
if style.fillColor.alphaComponent > 0 {
|
||||
context.setFillColor(style.fillColor.cgColor)
|
||||
context.fillEllipse(in: rect)
|
||||
}
|
||||
if style.lineThickness > 0 {
|
||||
context.setStrokeColor(style.lineColor.cgColor)
|
||||
context.setLineWidth(style.lineThickness)
|
||||
context.strokeEllipse(in: rect)
|
||||
}
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint) -> Bool {
|
||||
let rect = normalizedFrame.insetBy(dx: -4, dy: -4)
|
||||
guard rect.width > 0, rect.height > 0 else { return false }
|
||||
let dx = (point.x - rect.midX) / (rect.width / 2)
|
||||
let dy = (point.y - rect.midY) / (rect.height / 2)
|
||||
return dx * dx + dy * dy <= 1
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { EllipseElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
/// A straight line; `frame` stores start in origin and end in origin + size, so the size may be negative.
|
||||
class LineElement: Element {
|
||||
var start: CGPoint { frame.origin }
|
||||
var end: CGPoint { CGPoint(x: frame.origin.x + frame.width, y: frame.origin.y + frame.height) }
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
context.setStrokeColor(style.lineColor.cgColor)
|
||||
context.setLineWidth(max(1, style.lineThickness))
|
||||
context.setLineCap(.round)
|
||||
context.move(to: start)
|
||||
context.addLine(to: end)
|
||||
context.strokePath()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint) -> Bool {
|
||||
distance(from: point, toSegment: start, end) <= max(6, style.lineThickness + 4)
|
||||
}
|
||||
|
||||
func distance(from point: CGPoint, toSegment a: CGPoint, _ b: CGPoint) -> CGFloat {
|
||||
let dx = b.x - a.x
|
||||
let dy = b.y - a.y
|
||||
if dx == 0 && dy == 0 { return hypot(point.x - a.x, point.y - a.y) }
|
||||
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)
|
||||
t = max(0, min(1, t))
|
||||
return hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy))
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { LineElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
final class ArrowElement: LineElement {
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
let width = max(1, style.lineThickness)
|
||||
let headLength = max(10, width * 4)
|
||||
let angle = atan2(end.y - start.y, end.x - start.x)
|
||||
let length = hypot(end.x - start.x, end.y - start.y)
|
||||
let shaftEnd = CGPoint(x: end.x - cos(angle) * min(headLength * 0.8, length),
|
||||
y: end.y - sin(angle) * min(headLength * 0.8, length))
|
||||
|
||||
context.setStrokeColor(style.lineColor.cgColor)
|
||||
context.setFillColor(style.lineColor.cgColor)
|
||||
context.setLineWidth(width)
|
||||
context.setLineCap(.round)
|
||||
context.move(to: start)
|
||||
context.addLine(to: shaftEnd)
|
||||
context.strokePath()
|
||||
|
||||
let spread = CGFloat.pi / 7
|
||||
context.move(to: end)
|
||||
context.addLine(to: CGPoint(x: end.x - cos(angle - spread) * headLength,
|
||||
y: end.y - sin(angle - spread) * headLength))
|
||||
context.addLine(to: CGPoint(x: end.x - cos(angle + spread) * headLength,
|
||||
y: end.y - sin(angle + spread) * headLength))
|
||||
context.closePath()
|
||||
context.fillPath()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { ArrowElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
final class FreehandElement: Element {
|
||||
private(set) var points: [CGPoint]
|
||||
|
||||
init(points: [CGPoint], style: ElementStyle) {
|
||||
self.points = points
|
||||
super.init(frame: FreehandElement.bounds(of: points), style: style)
|
||||
}
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
|
||||
override var isResizable: Bool { false }
|
||||
|
||||
func append(_ point: CGPoint) {
|
||||
points.append(point)
|
||||
frame = FreehandElement.bounds(of: points)
|
||||
}
|
||||
|
||||
static func bounds(of points: [CGPoint]) -> CGRect {
|
||||
guard let first = points.first else { return .zero }
|
||||
var rect = CGRect(origin: first, size: .zero)
|
||||
for point in points.dropFirst() {
|
||||
rect = rect.union(CGRect(origin: point, size: .zero))
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
guard points.count > 1 else { return }
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
context.setStrokeColor(style.lineColor.cgColor)
|
||||
context.setLineWidth(max(1, style.lineThickness))
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
context.move(to: points[0])
|
||||
for point in points.dropFirst() { context.addLine(to: point) }
|
||||
context.strokePath()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint) -> Bool {
|
||||
let tolerance = max(6, style.lineThickness + 4)
|
||||
for index in 1..<max(1, points.count) {
|
||||
let a = points[index - 1]
|
||||
let b = points[index]
|
||||
let dx = b.x - a.x, dy = b.y - a.y
|
||||
var t: CGFloat = 0
|
||||
if dx != 0 || dy != 0 {
|
||||
t = max(0, min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)))
|
||||
}
|
||||
if hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy)) <= tolerance { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func move(by delta: CGVector) {
|
||||
points = points.map { CGPoint(x: $0.x + delta.dx, y: $0.y + delta.dy) }
|
||||
frame = FreehandElement.bounds(of: points)
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { FreehandElement(points: points, style: style) }
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// ElementsFilters.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Text, highlight, obfuscation, step counter and crop elements.
|
||||
|
||||
import AppKit
|
||||
import CoreImage
|
||||
|
||||
/// Draws a CGImage the right way up inside a top-left-origin (flipped) context.
|
||||
func drawFlipped(_ image: CGImage, in rect: CGRect, context: CGContext) {
|
||||
context.saveGState()
|
||||
context.translateBy(x: rect.minX, y: rect.minY + rect.height)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
context.draw(image, in: CGRect(origin: .zero, size: rect.size))
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
final class TextElement: Element {
|
||||
var text: String
|
||||
|
||||
init(frame: CGRect, style: ElementStyle, text: String = "") {
|
||||
self.text = text
|
||||
super.init(frame: frame, style: style)
|
||||
}
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .fillColor, .font, .shadow] }
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .left
|
||||
paragraph.lineBreakMode = .byWordWrapping
|
||||
return [
|
||||
.font: style.font,
|
||||
.foregroundColor: style.lineColor,
|
||||
.paragraphStyle: paragraph
|
||||
]
|
||||
}
|
||||
|
||||
/// Size the text needs for the current width.
|
||||
func fittingSize(maxWidth: CGFloat) -> CGSize {
|
||||
let bounding = (text as NSString).boundingRect(
|
||||
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||
attributes: attributes)
|
||||
return CGSize(width: ceil(bounding.width) + 8, height: ceil(bounding.height) + 6)
|
||||
}
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
|
||||
context.saveGState()
|
||||
if style.fillColor.alphaComponent > 0 {
|
||||
context.setFillColor(style.fillColor.cgColor)
|
||||
context.fill(rect)
|
||||
}
|
||||
applyShadow(context)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
(text as NSString).draw(in: rect.insetBy(dx: 4, dy: 3), withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { TextElement(frame: frame, style: style, text: text) }
|
||||
}
|
||||
|
||||
// MARK: - Highlight
|
||||
|
||||
final class HighlightElement: Element {
|
||||
override var capabilities: StyleCapabilities { [.highlightColor] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 0, rect.height > 0 else { return }
|
||||
context.saveGState()
|
||||
context.setBlendMode(.multiply)
|
||||
context.setFillColor(style.highlightColor.withAlphaComponent(1).cgColor)
|
||||
context.fill(rect)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { HighlightElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
// MARK: - Obfuscate
|
||||
|
||||
final class ObfuscateElement: Element {
|
||||
private static let ciContext = CIContext(options: [.useSoftwareRenderer: false])
|
||||
|
||||
override var capabilities: StyleCapabilities { [.obfuscation] }
|
||||
override var needsBaseImage: Bool { true }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
guard let baseImage, imageSize.width > 0 else {
|
||||
context.setFillColor(NSColor.black.cgColor)
|
||||
context.fill(rect)
|
||||
return
|
||||
}
|
||||
|
||||
let scale = CGFloat(baseImage.width) / imageSize.width
|
||||
let pixelRect = CGRect(x: rect.minX * scale,
|
||||
y: rect.minY * scale,
|
||||
width: rect.width * scale,
|
||||
height: rect.height * scale)
|
||||
.intersection(CGRect(x: 0, y: 0, width: CGFloat(baseImage.width), height: CGFloat(baseImage.height)))
|
||||
guard pixelRect.width >= 1, pixelRect.height >= 1,
|
||||
let region = baseImage.cropping(to: pixelRect) else { return }
|
||||
|
||||
let input = CIImage(cgImage: region)
|
||||
let output: CIImage?
|
||||
|
||||
switch style.obfuscateMode {
|
||||
case .pixelate:
|
||||
let filter = CIFilter(name: "CIPixellate")
|
||||
filter?.setValue(input, forKey: kCIInputImageKey)
|
||||
filter?.setValue(max(2, style.pixelSize * scale), forKey: kCIInputScaleKey)
|
||||
filter?.setValue(CIVector(x: input.extent.midX, y: input.extent.midY), forKey: kCIInputCenterKey)
|
||||
output = filter?.outputImage
|
||||
case .blur:
|
||||
let filter = CIFilter(name: "CIGaussianBlur")
|
||||
// Clamp first so the blur does not fade out at the edges of the region.
|
||||
filter?.setValue(input.clampedToExtent(), forKey: kCIInputImageKey)
|
||||
filter?.setValue(max(1, style.blurRadius * scale / 2), forKey: kCIInputRadiusKey)
|
||||
output = filter?.outputImage?.cropped(to: input.extent)
|
||||
}
|
||||
|
||||
guard let output,
|
||||
let filtered = ObfuscateElement.ciContext.createCGImage(output, from: input.extent) else { return }
|
||||
drawFlipped(filtered, in: rect, context: context)
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { ObfuscateElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
// MARK: - Step counter
|
||||
|
||||
final class CounterElement: Element {
|
||||
var number: Int
|
||||
|
||||
init(center: CGPoint, number: Int, style: ElementStyle) {
|
||||
self.number = number
|
||||
let diameter = max(24, style.fontSize * 2)
|
||||
super.init(frame: CGRect(x: center.x - diameter / 2,
|
||||
y: center.y - diameter / 2,
|
||||
width: diameter,
|
||||
height: diameter),
|
||||
style: style)
|
||||
}
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .fillColor, .font, .shadow] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
let fill = style.fillColor.alphaComponent > 0 ? style.fillColor : style.lineColor
|
||||
context.setFillColor(fill.cgColor)
|
||||
context.fillEllipse(in: rect)
|
||||
context.restoreGState()
|
||||
|
||||
let textColor = fill.isDark ? NSColor.white : NSColor.black
|
||||
let font = NSFont.boldSystemFont(ofSize: max(10, rect.height * 0.55))
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
|
||||
let label = String(number) as NSString
|
||||
let size = label.size(withAttributes: attributes)
|
||||
let origin = CGPoint(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
label.draw(at: origin, withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint) -> Bool {
|
||||
let rect = normalizedFrame
|
||||
let dx = (point.x - rect.midX) / max(1, rect.width / 2)
|
||||
let dy = (point.y - rect.midY) / max(1, rect.height / 2)
|
||||
return dx * dx + dy * dy <= 1.2
|
||||
}
|
||||
|
||||
override func copyElement() -> Element {
|
||||
CounterElement(center: CGPoint(x: normalizedFrame.midX, y: normalizedFrame.midY),
|
||||
number: number,
|
||||
style: style)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSColor {
|
||||
var isDark: Bool {
|
||||
guard let rgb = usingColorSpace(.sRGB) else { return true }
|
||||
let luminance = 0.299 * rgb.redComponent + 0.587 * rgb.greenComponent + 0.114 * rgb.blueComponent
|
||||
return luminance < 0.6
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
/// Not rendered into the output: it marks the area the surface will be cropped to.
|
||||
final class CropElement: Element {
|
||||
override var capabilities: StyleCapabilities { [] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
// Drawn by the canvas as an overlay, never baked into the image.
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { CropElement(frame: frame, style: style) }
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// StyleBarView.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// The context sensitive style controls, equivalent to Greenshot's editor field toolbar.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
protocol StyleBarDelegate: AnyObject {
|
||||
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void)
|
||||
func styleBarDidApplyCrop(_ bar: StyleBarView)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class StyleBarView: NSView {
|
||||
|
||||
weak var delegate: StyleBarDelegate?
|
||||
|
||||
private let stack = NSStackView()
|
||||
private let lineColorWell = NSColorWell()
|
||||
private let fillColorWell = NSColorWell()
|
||||
private let highlightColorWell = NSColorWell()
|
||||
private let thicknessSlider = NSSlider()
|
||||
private let thicknessLabel = NSTextField(labelWithString: "2 px")
|
||||
private let fontPopUp = NSPopUpButton()
|
||||
private let fontSizeField = NSTextField()
|
||||
private let boldButton = NSButton()
|
||||
private let italicButton = NSButton()
|
||||
private let obfuscatePopUp = NSPopUpButton()
|
||||
private let obfuscateSlider = NSSlider()
|
||||
private let shadowButton = NSButton()
|
||||
private let cropButton = NSButton()
|
||||
|
||||
private var labelledPairs: [StyleCapabilities: [NSView]] = [:]
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
build()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
private func build() {
|
||||
wantsLayer = true
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
stack.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
lineColorWell.color = Configuration.shared.editorLineColor
|
||||
lineColorWell.target = self
|
||||
lineColorWell.action = #selector(lineColorChanged)
|
||||
let lineLabel = NSTextField(labelWithString: "Line")
|
||||
labelledPairs[.lineColor] = [lineLabel, lineColorWell]
|
||||
|
||||
fillColorWell.color = Configuration.shared.editorFillColor
|
||||
fillColorWell.target = self
|
||||
fillColorWell.action = #selector(fillColorChanged)
|
||||
let fillLabel = NSTextField(labelWithString: "Fill")
|
||||
labelledPairs[.fillColor] = [fillLabel, fillColorWell]
|
||||
|
||||
highlightColorWell.color = Configuration.shared.editorHighlightColor
|
||||
highlightColorWell.target = self
|
||||
highlightColorWell.action = #selector(highlightColorChanged)
|
||||
let highlightLabel = NSTextField(labelWithString: "Highlight")
|
||||
labelledPairs[.highlightColor] = [highlightLabel, highlightColorWell]
|
||||
|
||||
thicknessSlider.minValue = 0
|
||||
thicknessSlider.maxValue = 20
|
||||
thicknessSlider.doubleValue = Double(Configuration.shared.editorLineThickness)
|
||||
thicknessSlider.target = self
|
||||
thicknessSlider.action = #selector(thicknessChanged)
|
||||
thicknessSlider.widthAnchor.constraint(equalToConstant: 90).isActive = true
|
||||
labelledPairs[.lineThickness] = [NSTextField(labelWithString: "Width"), thicknessSlider, thicknessLabel]
|
||||
|
||||
for family in ["Helvetica", "Helvetica Neue", "Menlo", "SF Pro", "Times New Roman", "Arial", "Georgia"] {
|
||||
fontPopUp.addItem(withTitle: family)
|
||||
}
|
||||
fontPopUp.selectItem(withTitle: Configuration.shared.editorFontFamily)
|
||||
fontPopUp.target = self
|
||||
fontPopUp.action = #selector(fontChanged)
|
||||
|
||||
fontSizeField.stringValue = String(Configuration.shared.editorFontSize)
|
||||
fontSizeField.target = self
|
||||
fontSizeField.action = #selector(fontChanged)
|
||||
fontSizeField.widthAnchor.constraint(equalToConstant: 46).isActive = true
|
||||
|
||||
configureToggle(boldButton, title: "B", action: #selector(fontChanged))
|
||||
boldButton.font = NSFont.boldSystemFont(ofSize: 12)
|
||||
configureToggle(italicButton, title: "I", action: #selector(fontChanged))
|
||||
labelledPairs[.font] = [fontPopUp, fontSizeField, boldButton, italicButton]
|
||||
|
||||
obfuscatePopUp.addItems(withTitles: ObfuscateMode.allCases.map(\.title))
|
||||
obfuscatePopUp.target = self
|
||||
obfuscatePopUp.action = #selector(obfuscationChanged)
|
||||
obfuscateSlider.minValue = 2
|
||||
obfuscateSlider.maxValue = 40
|
||||
obfuscateSlider.doubleValue = Double(Configuration.shared.editorPixelSize)
|
||||
obfuscateSlider.target = self
|
||||
obfuscateSlider.action = #selector(obfuscationChanged)
|
||||
obfuscateSlider.widthAnchor.constraint(equalToConstant: 90).isActive = true
|
||||
labelledPairs[.obfuscation] = [obfuscatePopUp, NSTextField(labelWithString: "Strength"), obfuscateSlider]
|
||||
|
||||
configureToggle(shadowButton, title: "Shadow", action: #selector(shadowChanged))
|
||||
shadowButton.state = Configuration.shared.editorDropShadow ? .on : .off
|
||||
labelledPairs[.shadow] = [shadowButton]
|
||||
|
||||
cropButton.title = "Apply crop"
|
||||
cropButton.bezelStyle = .rounded
|
||||
cropButton.target = self
|
||||
cropButton.action = #selector(applyCrop)
|
||||
}
|
||||
|
||||
private func configureToggle(_ button: NSButton, title: String, action: Selector) {
|
||||
button.title = title
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.bezelStyle = .rounded
|
||||
button.target = self
|
||||
button.action = action
|
||||
}
|
||||
|
||||
/// Rebuilds the bar so it shows only the fields the current tool or selection supports.
|
||||
func update(capabilities: StyleCapabilities, style: ElementStyle, showCrop: Bool) {
|
||||
for view in stack.arrangedSubviews {
|
||||
stack.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
lineColorWell.color = style.lineColor
|
||||
fillColorWell.color = style.fillColor
|
||||
highlightColorWell.color = style.highlightColor
|
||||
thicknessSlider.doubleValue = Double(style.lineThickness)
|
||||
thicknessLabel.stringValue = "\(Int(style.lineThickness)) px"
|
||||
fontPopUp.selectItem(withTitle: style.fontFamily)
|
||||
fontSizeField.stringValue = String(Int(style.fontSize))
|
||||
boldButton.state = style.bold ? .on : .off
|
||||
italicButton.state = style.italic ? .on : .off
|
||||
obfuscatePopUp.selectItem(withTitle: style.obfuscateMode.title)
|
||||
obfuscateSlider.doubleValue = Double(style.obfuscateMode == .pixelate ? style.pixelSize : style.blurRadius)
|
||||
shadowButton.state = style.dropShadow ? .on : .off
|
||||
|
||||
for capability in [StyleCapabilities.lineColor, .fillColor, .highlightColor,
|
||||
.lineThickness, .font, .obfuscation, .shadow] {
|
||||
guard capabilities.contains(capability), let views = labelledPairs[capability] else { continue }
|
||||
for view in views { stack.addArrangedSubview(view) }
|
||||
}
|
||||
if showCrop {
|
||||
stack.addArrangedSubview(cropButton)
|
||||
}
|
||||
isHidden = stack.arrangedSubviews.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func lineColorChanged() {
|
||||
let color = lineColorWell.color
|
||||
Configuration.shared.editorLineColor = color
|
||||
delegate?.styleBar(self) { $0.lineColor = color }
|
||||
}
|
||||
|
||||
@objc private func fillColorChanged() {
|
||||
let color = fillColorWell.color
|
||||
Configuration.shared.editorFillColor = color
|
||||
delegate?.styleBar(self) { $0.fillColor = color }
|
||||
}
|
||||
|
||||
@objc private func highlightColorChanged() {
|
||||
let color = highlightColorWell.color
|
||||
Configuration.shared.editorHighlightColor = color
|
||||
delegate?.styleBar(self) { $0.highlightColor = color }
|
||||
}
|
||||
|
||||
@objc private func thicknessChanged() {
|
||||
let thickness = CGFloat(thicknessSlider.doubleValue.rounded())
|
||||
thicknessLabel.stringValue = "\(Int(thickness)) px"
|
||||
Configuration.shared.editorLineThickness = Int(thickness)
|
||||
delegate?.styleBar(self) { $0.lineThickness = thickness }
|
||||
}
|
||||
|
||||
@objc private func fontChanged() {
|
||||
let family = fontPopUp.titleOfSelectedItem ?? "Helvetica"
|
||||
let size = CGFloat(max(6, Int(fontSizeField.stringValue) ?? 14))
|
||||
let bold = boldButton.state == .on
|
||||
let italic = italicButton.state == .on
|
||||
Configuration.shared.editorFontFamily = family
|
||||
Configuration.shared.editorFontSize = Int(size)
|
||||
delegate?.styleBar(self) { style in
|
||||
style.fontFamily = family
|
||||
style.fontSize = size
|
||||
style.bold = bold
|
||||
style.italic = italic
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func obfuscationChanged() {
|
||||
let mode = ObfuscateMode.allCases[max(0, obfuscatePopUp.indexOfSelectedItem)]
|
||||
let value = CGFloat(obfuscateSlider.doubleValue.rounded())
|
||||
if mode == .pixelate {
|
||||
Configuration.shared.editorPixelSize = Int(value)
|
||||
} else {
|
||||
Configuration.shared.editorBlurRadius = Int(value)
|
||||
}
|
||||
delegate?.styleBar(self) { style in
|
||||
style.obfuscateMode = mode
|
||||
if mode == .pixelate { style.pixelSize = value } else { style.blurRadius = value }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func shadowChanged() {
|
||||
let enabled = shadowButton.state == .on
|
||||
Configuration.shared.editorDropShadow = enabled
|
||||
delegate?.styleBar(self) { $0.dropShadow = enabled }
|
||||
}
|
||||
|
||||
@objc private func applyCrop() {
|
||||
delegate?.styleBarDidApplyCrop(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Surface.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's Surface: the image plus its drawable elements, undo and rendering.
|
||||
|
||||
import AppKit
|
||||
|
||||
final class Surface {
|
||||
|
||||
private(set) var baseImage: CGImage
|
||||
/// Size of the image in points; elements live in this coordinate space.
|
||||
private(set) var imageSize: CGSize
|
||||
private(set) var elements: [Element] = []
|
||||
|
||||
var details: CaptureDetails
|
||||
var selection: Element?
|
||||
var cropElement: CropElement?
|
||||
var nextCounterNumber: Int = 1
|
||||
var hasUnsavedChanges = false
|
||||
|
||||
let undoManager = UndoManager()
|
||||
var onChange: (() -> Void)?
|
||||
|
||||
init(capture: Capture) {
|
||||
self.baseImage = capture.image
|
||||
self.imageSize = capture.pointSize
|
||||
self.details = capture.details
|
||||
}
|
||||
|
||||
var scale: CGFloat { CGFloat(baseImage.width) / max(1, imageSize.width) }
|
||||
|
||||
// MARK: - Element management
|
||||
|
||||
func add(_ element: Element) {
|
||||
elements.append(element)
|
||||
if let crop = element as? CropElement { cropElement = crop }
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.remove(element)
|
||||
}
|
||||
undoManager.setActionName("Add \(String(describing: type(of: element)))")
|
||||
changed()
|
||||
}
|
||||
|
||||
func remove(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.remove(at: index)
|
||||
if element === cropElement { cropElement = nil }
|
||||
if element === selection { selection = nil }
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.add(element)
|
||||
}
|
||||
changed()
|
||||
}
|
||||
|
||||
func removeSelection() {
|
||||
guard let selection else { return }
|
||||
remove(selection)
|
||||
}
|
||||
|
||||
func bringToFront(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.append(elements.remove(at: index))
|
||||
changed()
|
||||
}
|
||||
|
||||
func sendToBack(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.insert(elements.remove(at: index), at: 0)
|
||||
changed()
|
||||
}
|
||||
|
||||
func duplicate(_ element: Element) {
|
||||
let copy = element.copyElement()
|
||||
copy.move(by: CGVector(dx: 12, dy: 12))
|
||||
add(copy)
|
||||
selection = copy
|
||||
}
|
||||
|
||||
/// Records the state of an element before an interactive change so undo can restore it.
|
||||
func registerGeometryUndo(for element: Element) {
|
||||
let previousFrame = element.frame
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.registerGeometryUndo(for: element)
|
||||
element.frame = previousFrame
|
||||
surface.changed()
|
||||
}
|
||||
}
|
||||
|
||||
func element(at point: CGPoint) -> Element? {
|
||||
elements.reversed().first { !($0 is CropElement) && $0.hitTest(point) }
|
||||
}
|
||||
|
||||
func changed() {
|
||||
hasUnsavedChanges = true
|
||||
onChange?()
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
func applyCrop(to rect: CGRect) {
|
||||
let clamped = rect.intersection(CGRect(origin: .zero, size: imageSize))
|
||||
guard clamped.width >= 1, clamped.height >= 1 else { return }
|
||||
|
||||
let previousImage = baseImage
|
||||
let previousSize = imageSize
|
||||
let previousElements = elements
|
||||
|
||||
let pixelRect = CGRect(x: (clamped.minX * scale).rounded(.down),
|
||||
y: (clamped.minY * scale).rounded(.down),
|
||||
width: (clamped.width * scale).rounded(),
|
||||
height: (clamped.height * scale).rounded())
|
||||
guard let cropped = baseImage.cropping(to: pixelRect) else { return }
|
||||
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.restore(image: previousImage, size: previousSize, elements: previousElements)
|
||||
}
|
||||
undoManager.setActionName("Crop")
|
||||
|
||||
baseImage = cropped
|
||||
imageSize = clamped.size
|
||||
elements.removeAll { $0 is CropElement }
|
||||
cropElement = nil
|
||||
for element in elements {
|
||||
element.move(by: CGVector(dx: -clamped.minX, dy: -clamped.minY))
|
||||
}
|
||||
selection = nil
|
||||
changed()
|
||||
}
|
||||
|
||||
private func restore(image: CGImage, size: CGSize, elements restored: [Element]) {
|
||||
let currentImage = baseImage
|
||||
let currentSize = imageSize
|
||||
let currentElements = elements
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.restore(image: currentImage, size: currentSize, elements: currentElements)
|
||||
}
|
||||
baseImage = image
|
||||
imageSize = size
|
||||
elements = restored
|
||||
cropElement = nil
|
||||
selection = nil
|
||||
changed()
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
/// Renders the base image with every element baked in, at full pixel resolution.
|
||||
func render() -> CGImage {
|
||||
let width = baseImage.width
|
||||
let height = baseImage.height
|
||||
guard width > 0, height > 0,
|
||||
let context = CGContext(data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else { return baseImage }
|
||||
|
||||
// Flip to a top-left origin and work in points.
|
||||
context.translateBy(x: 0, y: CGFloat(height))
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
context.interpolationQuality = .high
|
||||
|
||||
drawFlipped(baseImage, in: CGRect(origin: .zero, size: imageSize), context: context)
|
||||
|
||||
for element in elements where !(element is CropElement) {
|
||||
element.draw(in: context, baseImage: baseImage, imageSize: imageSize)
|
||||
}
|
||||
return context.makeImage() ?? baseImage
|
||||
}
|
||||
|
||||
func renderedCapture() -> Capture {
|
||||
let capture = Capture(image: render(), details: details)
|
||||
capture.details.scaleFactor = scale
|
||||
return capture
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// HotkeyRecorder.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// A small AppKit control that records the next key combination, wrapped for SwiftUI.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Carbon.HIToolbox
|
||||
|
||||
final class HotkeyRecorderControl: NSButton {
|
||||
|
||||
var onRecord: ((HotkeySpec) -> Void)?
|
||||
var spec: HotkeySpec = .none {
|
||||
didSet { refreshTitle() }
|
||||
}
|
||||
|
||||
private var isRecording = false {
|
||||
didSet { refreshTitle() }
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
bezelStyle = .rounded
|
||||
setButtonType(.momentaryPushIn)
|
||||
target = self
|
||||
action = #selector(startRecording)
|
||||
refreshTitle()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
private func refreshTitle() {
|
||||
title = isRecording ? "Press a key…" : HotkeyManager.description(of: spec)
|
||||
}
|
||||
|
||||
@objc private func startRecording() {
|
||||
isRecording = true
|
||||
window?.makeFirstResponder(self)
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard isRecording else { return super.keyDown(with: event) }
|
||||
|
||||
if event.keyCode == UInt16(kVK_Escape) {
|
||||
isRecording = false
|
||||
return
|
||||
}
|
||||
if event.keyCode == UInt16(kVK_Delete) {
|
||||
isRecording = false
|
||||
spec = .none
|
||||
onRecord?(.none)
|
||||
return
|
||||
}
|
||||
|
||||
let modifiers = HotkeyManager.carbonModifiers(from: event.modifierFlags)
|
||||
guard modifiers != 0 else {
|
||||
NSSound.beep() // a global hotkey without modifiers would swallow normal typing
|
||||
return
|
||||
}
|
||||
let recorded = HotkeySpec(keyCode: UInt32(event.keyCode), modifiers: modifiers)
|
||||
isRecording = false
|
||||
spec = recorded
|
||||
onRecord?(recorded)
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
isRecording = false
|
||||
return super.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
struct HotkeyRecorder: NSViewRepresentable {
|
||||
let action: HotkeyAction
|
||||
|
||||
func makeNSView(context: Context) -> HotkeyRecorderControl {
|
||||
let control = HotkeyRecorderControl()
|
||||
control.spec = HotkeyBindings.shared.spec(for: action)
|
||||
control.onRecord = { spec in
|
||||
HotkeyBindings.shared.set(spec, for: action)
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: HotkeyRecorderControl, context: Context) {
|
||||
nsView.spec = HotkeyBindings.shared.spec(for: action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// PreferencesView.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's SettingsForm.
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct PreferencesView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralPreferences()
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
OutputPreferences()
|
||||
.tabItem { Label("Output", systemImage: "square.and.arrow.down") }
|
||||
HotkeyPreferences()
|
||||
.tabItem { Label("Hotkeys", systemImage: "keyboard") }
|
||||
EditorPreferences()
|
||||
.tabItem { Label("Editor", systemImage: "pencil.tip.crop.circle") }
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 520, height: 380)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeneralPreferences: View {
|
||||
@AppStorage(Configuration.Keys.defaultDestination) private var destination = DestinationID.editor.rawValue
|
||||
@AppStorage(Configuration.Keys.captureMousepointer) private var captureMousePointer = false
|
||||
@AppStorage(Configuration.Keys.captureDelayMillis) private var captureDelay = 0
|
||||
@AppStorage(Configuration.Keys.playCameraSound) private var cameraSound = true
|
||||
@AppStorage(Configuration.Keys.showFlashAnimation) private var flash = true
|
||||
@AppStorage(Configuration.Keys.showMagnifier) private var magnifier = true
|
||||
@AppStorage(Configuration.Keys.showSelectionSize) private var selectionSize = true
|
||||
@AppStorage(Configuration.Keys.windowCaptureMode) private var windowMode = WindowCaptureMode.window.rawValue
|
||||
@State private var launchAtLogin = LaunchAtLogin.isEnabled
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("After capture:", selection: $destination) {
|
||||
ForEach(DestinationID.allCases.filter { $0 != .openWith }, id: \.rawValue) { item in
|
||||
Text(item.title).tag(item.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Window capture:", selection: $windowMode) {
|
||||
Text("Window only").tag(WindowCaptureMode.window.rawValue)
|
||||
Text("Window with shadow").tag(WindowCaptureMode.windowWithShadow.rawValue)
|
||||
Text("Crop from screen").tag(WindowCaptureMode.screen.rawValue)
|
||||
}
|
||||
Toggle("Capture the mouse pointer", isOn: $captureMousePointer)
|
||||
Toggle("Play a shutter sound", isOn: $cameraSound)
|
||||
Toggle("Flash the captured area", isOn: $flash)
|
||||
Toggle("Show magnifier while selecting", isOn: $magnifier)
|
||||
Toggle("Show selection size while selecting", isOn: $selectionSize)
|
||||
Toggle("Launch at login", isOn: $launchAtLogin)
|
||||
.onChange(of: launchAtLogin) { _, newValue in
|
||||
LaunchAtLogin.isEnabled = newValue
|
||||
}
|
||||
HStack {
|
||||
Text("Capture delay:")
|
||||
Slider(value: Binding(get: { Double(captureDelay) },
|
||||
set: { captureDelay = Int($0) }),
|
||||
in: 0...5000, step: 250)
|
||||
Text("\(captureDelay) ms").frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OutputPreferences: View {
|
||||
@AppStorage(Configuration.Keys.outputFilePath) private var outputPath = ""
|
||||
@AppStorage(Configuration.Keys.outputFileFilenamePattern) private var pattern = ""
|
||||
@AppStorage(Configuration.Keys.outputFileFormat) private var format = OutputFormat.png.rawValue
|
||||
@AppStorage(Configuration.Keys.outputFileJpegQuality) private var jpegQuality = 80
|
||||
@AppStorage(Configuration.Keys.copyPathToClipboard) private var copyPath = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
HStack {
|
||||
Text("Save to:")
|
||||
Text(outputPath).lineLimit(1).truncationMode(.middle)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Choose…", action: chooseFolder)
|
||||
}
|
||||
TextField("Filename pattern:", text: $pattern)
|
||||
Text("Variables: ${capturetime}, ${title}, ${appname}, ${user}, ${hostname}, ${mode}, ${NUM}. "
|
||||
+ "Example: ${capturetime:d\"yyyy-MM-dd HH_mm_ss\"}-${title}")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Format:", selection: $format) {
|
||||
ForEach(OutputFormat.allCases, id: \.rawValue) { item in
|
||||
Text(item.displayName).tag(item.rawValue)
|
||||
}
|
||||
}
|
||||
if format == OutputFormat.jpg.rawValue {
|
||||
HStack {
|
||||
Text("JPEG quality:")
|
||||
Slider(value: Binding(get: { Double(jpegQuality) },
|
||||
set: { jpegQuality = Int($0) }),
|
||||
in: 10...100, step: 5)
|
||||
Text("\(jpegQuality)%").frame(width: 50, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
Toggle("Copy the file path to the clipboard after saving", isOn: $copyPath)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: outputPath, isDirectory: true)
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
outputPath = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HotkeyPreferences: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
ForEach(HotkeyAction.allCases, id: \.rawValue) { action in
|
||||
HStack {
|
||||
Text(action.title)
|
||||
Spacer()
|
||||
HotkeyRecorder(action: action)
|
||||
.frame(width: 150, height: 24)
|
||||
}
|
||||
}
|
||||
Text("Click a shortcut, then press the new combination. Backspace clears it, Esc cancels. "
|
||||
+ "A modifier key is required.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditorPreferences: View {
|
||||
@AppStorage(Configuration.Keys.editorLineThickness) private var lineThickness = 2
|
||||
@AppStorage(Configuration.Keys.editorFontFamily) private var fontFamily = "Helvetica"
|
||||
@AppStorage(Configuration.Keys.editorFontSize) private var fontSize = 14
|
||||
@AppStorage(Configuration.Keys.editorPixelSize) private var pixelSize = 8
|
||||
@AppStorage(Configuration.Keys.editorBlurRadius) private var blurRadius = 12
|
||||
@AppStorage(Configuration.Keys.editorDropShadow) private var dropShadow = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Stepper("Default line thickness: \(lineThickness) px", value: $lineThickness, in: 0...20)
|
||||
Picker("Default font:", selection: $fontFamily) {
|
||||
ForEach(["Helvetica", "Helvetica Neue", "Menlo", "Times New Roman", "Arial", "Georgia"], id: \.self) {
|
||||
Text($0).tag($0)
|
||||
}
|
||||
}
|
||||
Stepper("Default font size: \(fontSize) pt", value: $fontSize, in: 6...96)
|
||||
Stepper("Pixelation size: \(pixelSize) px", value: $pixelSize, in: 2...40)
|
||||
Stepper("Blur radius: \(blurRadius) px", value: $blurRadius, in: 1...40)
|
||||
Toggle("Drop shadow on new elements", isOn: $dropShadow)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// PreferencesWindowController.swift
|
||||
// GreenshotMac
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PreferencesWindowController: NSWindowController {
|
||||
|
||||
convenience init() {
|
||||
let hosting = NSHostingController(rootView: PreferencesView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = "GreenshotMac Preferences"
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.setContentSize(NSSize(width: 552, height: 412))
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
self.init(window: window)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<true/>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>GPL-3.0. Derived from the Greenshot project.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,117 @@
|
||||
# GreenshotMac
|
||||
|
||||
A native, Apple-silicon macOS screenshot tool modelled on [Greenshot](https://github.com/greenshot/greenshot)
|
||||
for Windows. Written in Swift with AppKit, SwiftUI and ScreenCaptureKit — no Mono, no Wine,
|
||||
no .NET runtime.
|
||||
|
||||
The original C# sources are kept in `reference-greenshot/` purely as the behavioural reference
|
||||
for the port. GreenshotMac is a rewrite, not a transpile: Greenshot's Win32 foundations
|
||||
(GDI+, WinForms, `RegisterHotKey`, the shell tray API) have no direct macOS counterparts.
|
||||
|
||||
## Requirements
|
||||
|
||||
* macOS 14 (Sonoma) or later
|
||||
* Xcode 16 or later
|
||||
* Apple silicon (the target builds `arm64` only)
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
open GreenshotMac.xcodeproj # then ⌘R
|
||||
# or
|
||||
xcodebuild -project GreenshotMac.xcodeproj -scheme GreenshotMac -configuration Release build
|
||||
```
|
||||
|
||||
The target is configured for ad-hoc signing (`CODE_SIGN_IDENTITY = "-"`) so it builds without a
|
||||
developer account. Set your own team in *Signing & Capabilities* if you want a stable signature —
|
||||
macOS ties Screen Recording permission to the signature, so an ad-hoc build may ask for permission
|
||||
again after some rebuilds.
|
||||
|
||||
On first launch macOS asks for **Screen Recording** permission
|
||||
(System Settings › Privacy & Security › Screen Recording). Nothing can be captured until it is granted.
|
||||
|
||||
## Using it
|
||||
|
||||
GreenshotMac is a menu bar app (`LSUIElement`), so it has no Dock icon. The camera icon in the
|
||||
menu bar holds every command.
|
||||
|
||||
Default global hotkeys — Greenshot's PrintScreen-based defaults don't exist on a Mac keyboard,
|
||||
so these use combinations macOS leaves free:
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Capture region | ⌥⇧⌘4 |
|
||||
| Capture full screen | ⌥⇧⌘3 |
|
||||
| Capture window | ⌥⇧⌘5 |
|
||||
| Capture last region | ⌥⇧⌘6 |
|
||||
|
||||
All four are re-recordable in Preferences › Hotkeys.
|
||||
|
||||
During a capture the desktop freezes under a dimmed overlay: drag to select a region, press
|
||||
**Space** to switch to window mode and click a window, **Esc** to cancel. A magnifier and a live
|
||||
pixel-size readout follow the cursor.
|
||||
|
||||
### Editor
|
||||
|
||||
| Tool | Key |
|
||||
| --- | --- |
|
||||
| Select | V |
|
||||
| Rectangle / Ellipse | R / E |
|
||||
| Line / Arrow | L / A |
|
||||
| Freehand | F |
|
||||
| Text | T |
|
||||
| Highlight | H |
|
||||
| Obfuscate (pixelate or blur) | O |
|
||||
| Step counter | C |
|
||||
| Crop | K |
|
||||
|
||||
Shift constrains shapes to squares/circles and lines to 45° steps. Arrow keys nudge the selection
|
||||
(⇧ for 10 px steps). ⌘Z / ⇧⌘Z undo and redo, including crops.
|
||||
|
||||
### Destinations
|
||||
|
||||
After a capture the destination picker appears: a preview of what you grabbed, one button per
|
||||
destination, ⌘1…⌘9 to pick from the keyboard, Esc to discard, and "Always use this destination"
|
||||
to skip the dialog next time. The buttons default to Edit / Save to Disk / Copy to Clipboard /
|
||||
Send as Mail, matching Greenshot.
|
||||
|
||||
The full destination set is clipboard, save with a filename pattern, Save As…, print, email,
|
||||
the macOS share sheet, and "open with". Set a destination as the default (skipping the picker)
|
||||
in Preferences › General, from the menu bar, or via the picker's checkbox.
|
||||
|
||||
Filename patterns use Greenshot's syntax, ported verbatim:
|
||||
|
||||
```
|
||||
${capturetime:d"yyyy-MM-dd HH_mm_ss"}-${title}
|
||||
```
|
||||
|
||||
Supported variables: `${capturetime}`, `${now}`, `${title}`, `${appname}`, `${user}`,
|
||||
`${hostname}`, `${mode}`, `${NUM}` (with `:d4` for zero padding), plus the `:t`, `:u`, `:l`,
|
||||
`:s`, `:r` parameters.
|
||||
|
||||
## How the port maps onto Greenshot
|
||||
|
||||
| Greenshot (Windows) | GreenshotMac |
|
||||
| --- | --- |
|
||||
| `MainForm` + tray icon | `StatusMenuController` (`NSStatusItem`) |
|
||||
| `CaptureForm` | `CaptureOverlay` (one borderless window per display) |
|
||||
| `WindowCapture` / GDI+ | `CaptureEngine` (ScreenCaptureKit) |
|
||||
| `HotkeyControl` / `RegisterHotKey` | `HotkeyManager` (Carbon `RegisterEventHotKey`) |
|
||||
| `Surface` + `DrawableContainer` | `Surface` + `Element` hierarchy |
|
||||
| `ImageEditorForm` | `EditorWindowController` + `EditorCanvasView` |
|
||||
| `FilenameHelper` | `FilenamePattern` |
|
||||
| `ImageIO` | `ImageExporter` |
|
||||
| `IDestination` implementations | `Destinations` |
|
||||
| `SettingsForm` | `PreferencesView` (SwiftUI) |
|
||||
| `IniConfig` / `greenshot.ini` | `Configuration` (`UserDefaults`) |
|
||||
|
||||
## Not ported
|
||||
|
||||
The plugin destinations (Imgur, Box, Dropbox, Confluence, Jira, Office, OCR, external command),
|
||||
the speech-bubble and SVG/emoji containers, `.gst` templates, and Greenshot's language packs.
|
||||
The destination layer is a single `switch` in `Destinations.swift`, so adding an upload target is
|
||||
a small, self-contained change.
|
||||
|
||||
## Licence
|
||||
|
||||
Greenshot is GPL-3.0; this port keeps the same licence.
|
||||
Submodule
+1
Submodule reference-greenshot added at d2ba2cdb6c
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds GreenshotMac and drops the .app in build/Release.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
xcodebuild \
|
||||
-project GreenshotMac.xcodeproj \
|
||||
-scheme GreenshotMac \
|
||||
-configuration "${1:-Release}" \
|
||||
-destination 'platform=macOS,arch=arm64' \
|
||||
-derivedDataPath build/DerivedData \
|
||||
build
|
||||
|
||||
APP="build/DerivedData/Build/Products/${1:-Release}/GreenshotMac.app"
|
||||
echo
|
||||
echo "Built: $APP"
|
||||
echo "Run it with: open \"$APP\""
|
||||
Reference in New Issue
Block a user