Files
SnipMC/Sources/AppCoordinator.swift
T
marcoandClaude Opus 4.6 062285abbf Add image editor with annotation tools
Integrates an editor accessible via "Letzte Aufnahme bearbeiten…" or
"Bild bearbeiten…" in the menu bar. Supports arrow, rectangle, ellipse,
and text annotations with color picker, line width, undo/redo, and
export to file or clipboard.

New files in Sources/Editor/:
- AnnotationItem/Document: data model and state management
- AnnotationCanvasView: NSView with CG rendering and mouse interaction
- AnnotationRenderer: shared rendering for canvas and image export
- EditorToolbarView: SwiftUI toolbar with tool selection and actions
- EditorWindowController: window lifecycle following SettingsWindowController pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 17:44:20 +02:00

235 lines
8.9 KiB
Swift

import AppKit
import Foundation
/// Central state + logic for the app: persisted settings, hot key
/// registration, and running screencapture to save/copy screenshots.
final class AppCoordinator: ObservableObject {
private enum Keys {
static let outputAction = "outputAction"
static let imageFormat = "imageFormat"
static let saveFolder = "saveFolder"
static func hotKeyCode(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).keyCode" }
static func hotKeyMods(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).modifiers" }
}
@Published var outputAction: OutputAction {
didSet { UserDefaults.standard.set(outputAction.rawValue, forKey: Keys.outputAction) }
}
@Published var imageFormat: ImageFormat {
didSet { UserDefaults.standard.set(imageFormat.rawValue, forKey: Keys.imageFormat) }
}
@Published var saveFolder: URL {
didSet { UserDefaults.standard.set(saveFolder.path, forKey: Keys.saveFolder) }
}
@Published var hotKeyFullScreen: HotKeyCombo {
didSet { persistAndRegister(mode: .fullScreen, combo: hotKeyFullScreen) }
}
@Published var hotKeyWindow: HotKeyCombo {
didSet { persistAndRegister(mode: .window, combo: hotKeyWindow) }
}
@Published var hotKeyRegion: HotKeyCombo {
didSet { persistAndRegister(mode: .region, combo: hotKeyRegion) }
}
@Published var lastCapturedURL: URL?
private var editorController: EditorWindowController?
init() {
let defaults = UserDefaults.standard
outputAction = OutputAction(rawValue: defaults.string(forKey: Keys.outputAction) ?? "") ?? .both
imageFormat = ImageFormat(rawValue: defaults.string(forKey: Keys.imageFormat) ?? "") ?? .png
if let path = defaults.string(forKey: Keys.saveFolder) {
saveFolder = URL(fileURLWithPath: path)
} else {
saveFolder = Self.defaultSaveFolder()
}
hotKeyFullScreen = Self.loadCombo(mode: .fullScreen, default: .defaultFullScreen)
hotKeyWindow = Self.loadCombo(mode: .window, default: .defaultWindow)
hotKeyRegion = Self.loadCombo(mode: .region, default: .defaultRegion)
registerHotKey(mode: .fullScreen, combo: hotKeyFullScreen)
registerHotKey(mode: .window, combo: hotKeyWindow)
registerHotKey(mode: .region, combo: hotKeyRegion)
}
private static func defaultSaveFolder() -> URL {
let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser
return pictures.appendingPathComponent("Screenshots", isDirectory: true)
}
private static func loadCombo(mode: CaptureMode, default defaultValue: HotKeyCombo) -> HotKeyCombo {
let defaults = UserDefaults.standard
let codeKey = Keys.hotKeyCode(mode)
let modsKey = Keys.hotKeyMods(mode)
guard defaults.object(forKey: codeKey) != nil, defaults.object(forKey: modsKey) != nil else {
return defaultValue
}
let code = UInt32(defaults.integer(forKey: codeKey))
let mods = UInt32(defaults.integer(forKey: modsKey))
return HotKeyCombo(keyCode: code, carbonModifiers: mods)
}
private func persistAndRegister(mode: CaptureMode, combo: HotKeyCombo) {
let defaults = UserDefaults.standard
defaults.set(Int(combo.keyCode), forKey: Keys.hotKeyCode(mode))
defaults.set(Int(combo.carbonModifiers), forKey: Keys.hotKeyMods(mode))
registerHotKey(mode: mode, combo: combo)
}
private func registerHotKey(mode: CaptureMode, combo: HotKeyCombo) {
HotKeyManager.shared.register(
id: mode.hotKeyID,
keyCode: combo.keyCode,
modifiers: combo.carbonModifiers
) { [weak self] in
self?.capture(mode: mode)
}
}
// MARK: - External triggers (URL scheme)
/// Handles `snippingtool://<mode>` URLs so external tools (Logitech
/// Options, Shortcuts, Stream Deck, `open` from the command line, …)
/// can trigger a specific capture mode instead of just launching the app.
/// Accepts the mode either as the host (`snippingtool://region`) or as
/// a query item (`snippingtool://capture?mode=region`).
func handle(url: URL) {
guard url.scheme?.caseInsensitiveCompare("snippingtool") == .orderedSame else { return }
let modeString = url.host?.lowercased()
?? URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name.caseInsensitiveCompare("mode") == .orderedSame })?
.value?
.lowercased()
switch modeString {
case "fullscreen", "full", "full-screen", "screen":
capture(mode: .fullScreen)
case "window":
capture(mode: .window)
case "region", "area", "selection":
capture(mode: .region)
default:
NSLog("SnippingTool: unrecognized URL trigger \(url.absoluteString)")
}
}
// MARK: - Capture
func capture(mode: CaptureMode) {
let saves = outputAction.savesToFile
let copies = outputAction.copiesToClipboard
let ext = imageFormat.fileExtension
let targetURL: URL
if saves {
try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true)
targetURL = saveFolder.appendingPathComponent(Self.filename(ext: ext))
} else {
targetURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(Self.filename(ext: ext))
}
var arguments = mode.screencaptureArguments
arguments += ["-t", imageFormat.rawValue, targetURL.path]
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
process.arguments = arguments
process.terminationHandler = { [weak self] _ in
DispatchQueue.main.async {
self?.handleCaptured(at: targetURL, saves: saves, copies: copies)
}
}
do {
try process.run()
} catch {
NSLog("SnippingTool: failed to launch screencapture: \(error)")
}
}
private func handleCaptured(at url: URL, saves: Bool, copies: Bool) {
guard FileManager.default.fileExists(atPath: url.path) else { return }
lastCapturedURL = saves ? url : nil
if copies, let image = NSImage(contentsOf: url) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
if !saves {
try? FileManager.default.removeItem(at: url)
}
}
// MARK: - Editor
func openEditorForLastCapture() {
guard let url = lastCapturedURL,
FileManager.default.fileExists(atPath: url.path),
let image = NSImage(contentsOf: url) else { return }
openEditor(image: image, sourceURL: url)
}
func openEditorForFile() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp]
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url,
let image = NSImage(contentsOf: url) else { return }
openEditor(image: image, sourceURL: url)
}
private func openEditor(image: NSImage, sourceURL: URL) {
let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in
guard let self, let annotatedImage else { return }
self.saveAnnotatedImage(annotatedImage, replacingFileAt: sourceURL)
}
editorController = controller
controller.showEditor()
}
private func saveAnnotatedImage(_ image: NSImage, replacingFileAt url: URL) {
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData) else { return }
let fileType: NSBitmapImageRep.FileType = imageFormat == .jpg ? .jpeg : .png
let properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg
? [.compressionFactor: 0.9] : [:]
guard let data = bitmap.representation(using: fileType, properties: properties) else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
NSLog("SnippingTool: failed to save annotated image: \(error)")
}
if outputAction.copiesToClipboard {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
}
private static func filename(ext: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd 'um' HH.mm.ss"
return "Bildschirmfoto \(formatter.string(from: Date())).\(ext)"
}
}