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