Initial commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 15:15:45 -05:00
commit 87fe7927c4
36 changed files with 5113 additions and 0 deletions
@@ -0,0 +1,180 @@
// DestinationPicker.swift
// GreenshotMac
//
// Port of Greenshot's destination picker dialog: a preview of the capture,
// one large button per destination, and "always use this destination".
import AppKit
@MainActor
final class DestinationPickerWindowController: NSWindowController, NSWindowDelegate {
private static var openPickers: [DestinationPickerWindowController] = []
private let capture: Capture
private let destinations: [DestinationID]
private let rememberCheckbox = NSButton(checkboxWithTitle: "Always use this destination (you can change it in Preferences)",
target: nil,
action: nil)
static func present(for capture: Capture) {
let controller = DestinationPickerWindowController(capture: capture)
openPickers.append(controller)
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private init(capture: Capture) {
self.capture = capture
let configured = Configuration.shared.pickerDestinations.filter { $0 != .picker }
self.destinations = configured.isEmpty ? [.editor, .file, .clipboard, .email] : configured
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 560, height: 560),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false)
window.title = "Choose Destination for Screenshot"
window.isReleasedWhenClosed = false
window.level = .floating
window.center()
super.init(window: window)
window.delegate = self
buildInterface()
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
func windowWillClose(_ notification: Notification) {
DestinationPickerWindowController.openPickers.removeAll { $0 === self }
}
// MARK: - Interface
private func buildInterface() {
guard let window, let contentView = window.contentView else { return }
let preview = NSImageView()
preview.image = capture.nsImage
preview.imageScaling = .scaleProportionallyDown
preview.imageAlignment = .alignCenter
preview.wantsLayer = true
preview.layer?.backgroundColor = NSColor.underPageBackgroundColor.cgColor
preview.layer?.borderColor = NSColor.separatorColor.cgColor
preview.layer?.borderWidth = 1
preview.layer?.cornerRadius = 4
preview.translatesAutoresizingMaskIntoConstraints = false
preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
preview.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
let buttonRow = NSStackView()
buttonRow.orientation = .horizontal
buttonRow.alignment = .top
buttonRow.distribution = .fillEqually
buttonRow.spacing = 8
buttonRow.translatesAutoresizingMaskIntoConstraints = false
for (index, destination) in destinations.enumerated() {
buttonRow.addArrangedSubview(makeButton(for: destination, index: index))
}
rememberCheckbox.target = self
rememberCheckbox.action = #selector(rememberToggled)
rememberCheckbox.translatesAutoresizingMaskIntoConstraints = false
let separator = NSBox()
separator.boxType = .separator
separator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(preview)
contentView.addSubview(separator)
contentView.addSubview(buttonRow)
contentView.addSubview(rememberCheckbox)
NSLayoutConstraint.activate([
preview.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
separator.topAnchor.constraint(equalTo: preview.bottomAnchor, constant: 14),
separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
buttonRow.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 14),
buttonRow.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
buttonRow.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
rememberCheckbox.topAnchor.constraint(equalTo: buttonRow.bottomAnchor, constant: 14),
rememberCheckbox.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
rememberCheckbox.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16)
])
// Size the window so the preview keeps the capture's aspect ratio, within sensible bounds.
let aspect = capture.pointSize.height / max(1, capture.pointSize.width)
let previewWidth: CGFloat = 528
let previewHeight = min(max(180, previewWidth * aspect), 380)
preview.heightAnchor.constraint(equalToConstant: previewHeight).isActive = true
window.setContentSize(NSSize(width: 560, height: previewHeight + 190))
window.center()
}
private func makeButton(for destination: DestinationID, index: Int) -> NSView {
let button = NSButton()
button.bezelStyle = .shadowlessSquare
button.isBordered = false
button.imagePosition = .imageAbove
button.title = destination.pickerTitle
button.font = NSFont.systemFont(ofSize: 11)
button.image = NSImage(systemSymbolName: destination.symbolName, accessibilityDescription: destination.title)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 34, weight: .regular))
button.imageScaling = .scaleNone
button.target = self
button.action = #selector(destinationClicked(_:))
button.tag = index
button.toolTip = destination.title
// ⌘1…⌘9 select a destination from the keyboard.
if index < 9 {
button.keyEquivalent = String(index + 1)
button.keyEquivalentModifierMask = [.command]
}
button.setContentHuggingPriority(.defaultLow, for: .horizontal)
button.heightAnchor.constraint(equalToConstant: 76).isActive = true
return button
}
// MARK: - Actions
@objc private func destinationClicked(_ sender: NSButton) {
guard sender.tag >= 0, sender.tag < destinations.count else { return }
let destination = destinations[sender.tag]
if rememberCheckbox.state == .on {
Configuration.shared.defaultDestination = destination
}
close()
Destinations.export(capture, to: destination)
}
@objc private func rememberToggled() {}
override func cancelOperation(_ sender: Any?) {
close()
}
}
extension DestinationID {
/// Shorter labels for the picker buttons, matching Greenshot's wording.
var pickerTitle: String {
switch self {
case .editor: return "Edit in Greenshot"
case .file: return "Save to Disk"
case .fileWithDialog: return "Save As…"
case .clipboard: return "Copy to Clipboard"
case .email: return "Send as Mail"
case .printer: return "Print"
case .share: return "Share…"
case .openWith: return "Open With…"
case .picker: return "Choose…"
}
}
}
@@ -0,0 +1,209 @@
// Destinations.swift
// GreenshotMac
//
// Port of Greenshot's IDestination implementations for macOS.
import AppKit
import UniformTypeIdentifiers
@MainActor
enum Destinations {
/// Sends a capture to a destination. `anchor` is used to position pickers and share sheets.
@discardableResult
static func export(_ capture: Capture,
to destination: DestinationID,
anchor: NSView? = nil) -> Bool {
switch destination {
case .picker:
DestinationPickerWindowController.present(for: capture)
return true
case .editor:
EditorWindowController.open(with: capture)
return true
case .clipboard:
copyToClipboard(capture)
Notifier.show(title: "Copied to clipboard", body: nil)
return true
case .file:
return saveToFile(capture)
case .fileWithDialog:
return saveWithDialog(capture)
case .printer:
printCapture(capture)
return true
case .share:
share(capture, anchor: anchor)
return true
case .email:
sendAsEmail(capture, anchor: anchor)
return true
case .openWith:
openWith(capture)
return true
}
}
// MARK: - Clipboard
static func copyToClipboard(_ capture: Capture) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
let image = capture.nsImage
pasteboard.writeObjects([image])
if let data = ImageExporter.data(from: capture.image, format: .png, jpegQuality: 100) {
pasteboard.setData(data, forType: .png)
}
}
// MARK: - Files
@discardableResult
static func saveToFile(_ capture: Capture) -> Bool {
let url = ImageExporter.suggestedURL(for: capture.details)
do {
try ImageExporter.write(capture.image, to: url, format: Configuration.shared.outputFileFormat)
capture.details.filePath = url.path
if Configuration.shared.copyPathToClipboard {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.path, forType: .string)
}
Notifier.show(title: "Screenshot saved", body: url.lastPathComponent, revealing: url)
return true
} catch {
presentError(error)
return false
}
}
@discardableResult
static func saveWithDialog(_ capture: Capture) -> Bool {
let panel = NSSavePanel()
let configuration = Configuration.shared
panel.nameFieldStringValue = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
details: capture.details,
format: configuration.outputFileFormat)
panel.directoryURL = URL(fileURLWithPath: configuration.outputFilePath, isDirectory: true)
panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp, .gif]
panel.canCreateDirectories = true
panel.isExtensionHidden = false
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK, let url = panel.url else { return false }
let format = ImageExporter.format(forPathExtension: url.pathExtension)
do {
try ImageExporter.write(capture.image, to: url, format: format)
capture.details.filePath = url.path
return true
} catch {
presentError(error)
return false
}
}
// MARK: - Print
static func printCapture(_ capture: Capture) {
let image = capture.nsImage
let info = NSPrintInfo.shared
info.horizontalPagination = .fit
info.verticalPagination = .fit
info.isHorizontallyCentered = true
info.isVerticallyCentered = true
info.orientation = image.size.width > image.size.height ? .landscape : .portrait
let imageView = NSImageView(frame: CGRect(origin: .zero, size: image.size))
imageView.image = image
imageView.imageScaling = .scaleProportionallyUpOrDown
let operation = NSPrintOperation(view: imageView, printInfo: info)
operation.showsPrintPanel = true
operation.showsProgressPanel = true
NSApp.activate(ignoringOtherApps: true)
operation.run()
}
// MARK: - Share
static func share(_ capture: Capture, anchor: NSView?) {
guard let url = writeTemporaryFile(capture) else { return }
let picker = NSSharingServicePicker(items: [url])
if let anchor {
picker.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY)
} else if let window = anchorWindow() {
let view = window.contentView ?? NSView()
picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
}
}
// MARK: - Open with
static func openWith(_ capture: Capture) {
guard let url = writeTemporaryFile(capture) else { return }
let panel = NSOpenPanel()
panel.message = "Choose an application to open the screenshot with"
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.allowedContentTypes = [.application]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK, let application = panel.url else { return }
NSWorkspace.shared.open([url],
withApplicationAt: application,
configuration: NSWorkspace.OpenConfiguration()) { _, error in
if let error {
DispatchQueue.main.async { presentError(error) }
}
}
}
// MARK: - Email
static func sendAsEmail(_ capture: Capture, anchor: NSView?) {
guard let url = writeTemporaryFile(capture) else { return }
let subject = capture.details.title.isEmpty ? "Screenshot" : "Screenshot: \(capture.details.title)"
if let service = NSSharingService(named: .composeEmail) {
service.subject = subject
service.perform(withItems: [url])
} else {
// No mail client is configured for the share service; fall back to the share sheet.
share(capture, anchor: anchor)
}
}
// MARK: - Helpers
static func writeTemporaryFile(_ capture: Capture) -> URL? {
let configuration = Configuration.shared
let name = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
details: capture.details,
format: configuration.outputFileFormat)
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("GreenshotMac", isDirectory: true)
let url = ImageExporter.uniqueURL(directory.appendingPathComponent(name))
do {
try ImageExporter.write(capture.image, to: url, format: configuration.outputFileFormat)
return url
} catch {
presentError(error)
return nil
}
}
private static func anchorWindow() -> NSWindow? {
NSApp.keyWindow ?? NSApp.windows.first { $0.isVisible }
}
static func presentError(_ error: Error) {
let alert = NSAlert()
alert.messageText = "GreenshotMac"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
}
}
+37
View File
@@ -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])
}
}