commit 87fe7927c4b770e71377d7a0fd5019ea38098d2d Author: Alex E. Tafoya Date: Thu Sep 17 15:15:45 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e03771 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +.DS_Store diff --git a/GreenshotMac.xcodeproj/project.pbxproj b/GreenshotMac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..297e266 --- /dev/null +++ b/GreenshotMac.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + A10000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* GreenshotMac.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/GreenshotMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/GreenshotMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/GreenshotMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/GreenshotMac.xcodeproj/xcshareddata/xcschemes/GreenshotMac.xcscheme b/GreenshotMac.xcodeproj/xcshareddata/xcschemes/GreenshotMac.xcscheme new file mode 100644 index 0000000..f010b20 --- /dev/null +++ b/GreenshotMac.xcodeproj/xcshareddata/xcschemes/GreenshotMac.xcscheme @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GreenshotMac/App/AppDelegate.swift b/GreenshotMac/App/AppDelegate.swift new file mode 100644 index 0000000..e9fe021 --- /dev/null +++ b/GreenshotMac/App/AppDelegate.swift @@ -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)) + } + } +} diff --git a/GreenshotMac/App/CaptureCoordinator.swift b/GreenshotMac/App/CaptureCoordinator.swift new file mode 100644 index 0000000..d2c5e86 --- /dev/null +++ b/GreenshotMac/App/CaptureCoordinator.swift @@ -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) + } + } +} diff --git a/GreenshotMac/App/HotkeyBindings.swift b/GreenshotMac/App/HotkeyBindings.swift new file mode 100644 index 0000000..364e8f0 --- /dev/null +++ b/GreenshotMac/App/HotkeyBindings.swift @@ -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") +} diff --git a/GreenshotMac/App/MainMenuBuilder.swift b/GreenshotMac/App/MainMenuBuilder.swift new file mode 100644 index 0000000..f46bfc2 --- /dev/null +++ b/GreenshotMac/App/MainMenuBuilder.swift @@ -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() } +} diff --git a/GreenshotMac/App/StatusMenuController.swift b/GreenshotMac/App/StatusMenuController.swift new file mode 100644 index 0000000..0bb442d --- /dev/null +++ b/GreenshotMac/App/StatusMenuController.swift @@ -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) + } +} diff --git a/GreenshotMac/App/main.swift b/GreenshotMac/App/main.swift new file mode 100644 index 0000000..7e5fed7 --- /dev/null +++ b/GreenshotMac/App/main.swift @@ -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() +} diff --git a/GreenshotMac/Capture/CaptureEngine.swift b/GreenshotMac/Capture/CaptureEngine.swift new file mode 100644 index 0000000..0b51c7f --- /dev/null +++ b/GreenshotMac/Capture/CaptureEngine.swift @@ -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 } + } + } + } +} diff --git a/GreenshotMac/Capture/CaptureOverlay.swift b/GreenshotMac/Capture/CaptureOverlay.swift new file mode 100644 index 0000000..a2155b4 --- /dev/null +++ b/GreenshotMac/Capture/CaptureOverlay.swift @@ -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() + } +} diff --git a/GreenshotMac/Capture/ScreenGeometry.swift b/GreenshotMac/Capture/ScreenGeometry.swift new file mode 100644 index 0000000..044c219 --- /dev/null +++ b/GreenshotMac/Capture/ScreenGeometry.swift @@ -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) + } +} diff --git a/GreenshotMac/Core/Capture.swift b/GreenshotMac/Core/Capture.swift new file mode 100644 index 0000000..7dc78a5 --- /dev/null +++ b/GreenshotMac/Core/Capture.swift @@ -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 + } + } +} diff --git a/GreenshotMac/Core/Configuration.swift b/GreenshotMac/Core/Configuration.swift new file mode 100644 index 0000000..3f6b801 --- /dev/null +++ b/GreenshotMac/Core/Configuration.swift @@ -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) + } +} diff --git a/GreenshotMac/Core/FilenamePattern.swift b/GreenshotMac/Core/FilenamePattern.swift new file mode 100644 index 0000000..cf9b0c9 --- /dev/null +++ b/GreenshotMac/Core/FilenamePattern.swift @@ -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, - substring +// :r, - replace +// :t - trim +// :u / :l - upper / lower case + +import Foundation + +enum FilenamePattern { + private static let variableRegex = try! NSRegularExpression( + pattern: #"\$\{(?[^:}]+)[:]?(?[^}]*)\}"#) + + 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.. [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)) + } +} diff --git a/GreenshotMac/Core/HotkeyManager.swift b/GreenshotMac/Core/HotkeyManager.swift new file mode 100644 index 0000000..438aca5 --- /dev/null +++ b/GreenshotMac/Core/HotkeyManager.swift @@ -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.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)) +} diff --git a/GreenshotMac/Core/ImageExporter.swift b/GreenshotMac/Core/ImageExporter.swift new file mode 100644 index 0000000..4386cf7 --- /dev/null +++ b/GreenshotMac/Core/ImageExporter.swift @@ -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 + } + } +} diff --git a/GreenshotMac/Core/LaunchAtLogin.swift b/GreenshotMac/Core/LaunchAtLogin.swift new file mode 100644 index 0000000..5c0f40f --- /dev/null +++ b/GreenshotMac/Core/LaunchAtLogin.swift @@ -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)") + } + } + } +} diff --git a/GreenshotMac/Destinations/DestinationPicker.swift b/GreenshotMac/Destinations/DestinationPicker.swift new file mode 100644 index 0000000..1ac040a --- /dev/null +++ b/GreenshotMac/Destinations/DestinationPicker.swift @@ -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…" + } + } +} diff --git a/GreenshotMac/Destinations/Destinations.swift b/GreenshotMac/Destinations/Destinations.swift new file mode 100644 index 0000000..d80e742 --- /dev/null +++ b/GreenshotMac/Destinations/Destinations.swift @@ -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() + } +} diff --git a/GreenshotMac/Destinations/Notifier.swift b/GreenshotMac/Destinations/Notifier.swift new file mode 100644 index 0000000..f21f7c4 --- /dev/null +++ b/GreenshotMac/Destinations/Notifier.swift @@ -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]) + } +} diff --git a/GreenshotMac/Editor/EditorCanvasView.swift b/GreenshotMac/Editor/EditorCanvasView.swift new file mode 100644 index 0000000..a6aa4b3 --- /dev/null +++ b/GreenshotMac/Editor/EditorCanvasView.swift @@ -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 + } +} diff --git a/GreenshotMac/Editor/EditorWindowController.swift b/GreenshotMac/Editor/EditorWindowController.swift new file mode 100644 index 0000000..2417a12 --- /dev/null +++ b/GreenshotMac/Editor/EditorWindowController.swift @@ -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 + } +} diff --git a/GreenshotMac/Editor/Elements.swift b/GreenshotMac/Editor/Elements.swift new file mode 100644 index 0000000..c616336 --- /dev/null +++ b/GreenshotMac/Editor/Elements.swift @@ -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.. Element { FreehandElement(points: points, style: style) } +} diff --git a/GreenshotMac/Editor/ElementsFilters.swift b/GreenshotMac/Editor/ElementsFilters.swift new file mode 100644 index 0000000..f97fccb --- /dev/null +++ b/GreenshotMac/Editor/ElementsFilters.swift @@ -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) } +} diff --git a/GreenshotMac/Editor/StyleBarView.swift b/GreenshotMac/Editor/StyleBarView.swift new file mode 100644 index 0000000..c78954d --- /dev/null +++ b/GreenshotMac/Editor/StyleBarView.swift @@ -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) + } +} diff --git a/GreenshotMac/Editor/Surface.swift b/GreenshotMac/Editor/Surface.swift new file mode 100644 index 0000000..16a7dd1 --- /dev/null +++ b/GreenshotMac/Editor/Surface.swift @@ -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 + } +} diff --git a/GreenshotMac/Preferences/HotkeyRecorder.swift b/GreenshotMac/Preferences/HotkeyRecorder.swift new file mode 100644 index 0000000..681f73d --- /dev/null +++ b/GreenshotMac/Preferences/HotkeyRecorder.swift @@ -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) + } +} diff --git a/GreenshotMac/Preferences/PreferencesView.swift b/GreenshotMac/Preferences/PreferencesView.swift new file mode 100644 index 0000000..06553d7 --- /dev/null +++ b/GreenshotMac/Preferences/PreferencesView.swift @@ -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) + } +} diff --git a/GreenshotMac/Preferences/PreferencesWindowController.swift b/GreenshotMac/Preferences/PreferencesWindowController.swift new file mode 100644 index 0000000..5f4d29f --- /dev/null +++ b/GreenshotMac/Preferences/PreferencesWindowController.swift @@ -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) + } +} diff --git a/GreenshotMac/Resources/GreenshotMac.entitlements b/GreenshotMac/Resources/GreenshotMac.entitlements new file mode 100644 index 0000000..12e11f3 --- /dev/null +++ b/GreenshotMac/Resources/GreenshotMac.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + diff --git a/GreenshotMac/Resources/Info.plist b/GreenshotMac/Resources/Info.plist new file mode 100644 index 0000000..3080928 --- /dev/null +++ b/GreenshotMac/Resources/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSMultipleInstancesProhibited + + LSUIElement + + NSHumanReadableCopyright + GPL-3.0. Derived from the Greenshot project. + NSPrincipalClass + NSApplication + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7191c35 --- /dev/null +++ b/README.md @@ -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. diff --git a/reference-greenshot b/reference-greenshot new file mode 160000 index 0000000..d2ba2cd --- /dev/null +++ b/reference-greenshot @@ -0,0 +1 @@ +Subproject commit d2ba2cdb6c7f42caeddad00e0292f45668e18cda diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..c005911 --- /dev/null +++ b/scripts/build.sh @@ -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\""