diff --git a/Snapper/App/AppDelegate.swift b/Snapper/App/AppDelegate.swift index 540fa3f..0c813d6 100644 --- a/Snapper/App/AppDelegate.swift +++ b/Snapper/App/AppDelegate.swift @@ -7,13 +7,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var hotkeyManager: HotkeyManager? private var captureCoordinator: CaptureCoordinator? private var quickAccessManager: QuickAccessManager? + private var pinnedScreenshotManager: PinnedScreenshotManager? private var historyManager: HistoryManager? private var historyBrowserWindow: HistoryBrowserWindow? private var ocrCaptureController: OCRCaptureController? private var timerCaptureController: TimerCaptureController? + private var allInOneHUDPanel: AllInOneHUDPanel? private var updateManager: UpdateManager? private var onboardingWindowController: OnboardingWindowController? private var settingsWindow: NSWindow? + private var observerTokens: [NSObjectProtocol] = [] + + deinit { + for token in observerTokens { + NotificationCenter.default.removeObserver(token) + } + } func applicationDidFinishLaunching(_ notification: Notification) { ensureSingleInstance() @@ -36,14 +45,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Overlay & Pinned quickAccessManager = QuickAccessManager(appState: appState) + pinnedScreenshotManager = PinnedScreenshotManager(appState: appState) // History historyManager = HistoryManager() - historyBrowserWindow = HistoryBrowserWindow(historyManager: historyManager!) + if let historyManager { + historyBrowserWindow = HistoryBrowserWindow(historyManager: historyManager) + } // Advanced capture modes ocrCaptureController = OCRCaptureController() timerCaptureController = TimerCaptureController() + allInOneHUDPanel = AllInOneHUDPanel() // Updates updateManager = UpdateManager() @@ -68,6 +81,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { enforceReachability() } + func applicationWillTerminate(_ notification: Notification) { + appState.flushDefaults() + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if !appState.menuBarVisible { showSettingsWindow() @@ -99,29 +116,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func observeNotifications() { - NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { [weak self] _ in + let showHistoryToken = NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { [weak self] _ in self?.historyBrowserWindow?.show() } + observerTokens.append(showHistoryToken) - NotificationCenter.default.addObserver(forName: .showSettings, object: nil, queue: .main) { [weak self] _ in + let showSettingsToken = NotificationCenter.default.addObserver(forName: .showSettings, object: nil, queue: .main) { [weak self] _ in self?.showSettingsWindow() } + observerTokens.append(showSettingsToken) - NotificationCenter.default.addObserver(forName: .showOnboarding, object: nil, queue: .main) { [weak self] _ in + let showOnboardingToken = NotificationCenter.default.addObserver(forName: .showOnboarding, object: nil, queue: .main) { [weak self] _ in self?.showOnboarding() } + observerTokens.append(showOnboardingToken) - NotificationCenter.default.addObserver(forName: .requestPermissions, object: nil, queue: .main) { [weak self] _ in + let requestPermissionsToken = NotificationCenter.default.addObserver(forName: .requestPermissions, object: nil, queue: .main) { [weak self] _ in self?.requestPermissions() } + observerTokens.append(requestPermissionsToken) - NotificationCenter.default.addObserver(forName: .openEditor, object: nil, queue: .main) { notification in + let openEditorToken = NotificationCenter.default.addObserver(forName: .openEditor, object: nil, queue: .main) { notification in if let wrapper = notification.object as? ImageWrapper { AnnotationEditorWindow.open(with: wrapper.image) } } + observerTokens.append(openEditorToken) - NotificationCenter.default.addObserver(forName: .menuBarVisibilityChanged, object: nil, queue: .main) { [weak self] notification in + let menuVisibilityToken = NotificationCenter.default.addObserver( + forName: .menuBarVisibilityChanged, + object: nil, + queue: .main + ) { [weak self] notification in guard let isVisible = notification.object as? Bool else { return } guard let self else { return } @@ -138,42 +164,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.menuBarController?.hide() } } + observerTokens.append(menuVisibilityToken) - NotificationCenter.default.addObserver(forName: .historyRetentionChanged, object: nil, queue: .main) { [weak self] notification in + let historyRetentionToken = NotificationCenter.default.addObserver( + forName: .historyRetentionChanged, + object: nil, + queue: .main + ) { [weak self] notification in guard let days = notification.object as? Int else { return } guard days > 0 else { return } - Task { @MainActor in - self?.historyManager?.deleteOlderThan(days: days) - } + self?.historyManager?.deleteOlderThan(days: days) } - - NotificationCenter.default.addObserver(forName: .clearHistoryRequested, object: nil, queue: .main) { [weak self] _ in - Task { @MainActor in - self?.historyManager?.clearAll() - } + observerTokens.append(historyRetentionToken) + + let clearHistoryToken = NotificationCenter.default.addObserver( + forName: .clearHistoryRequested, + object: nil, + queue: .main + ) { [weak self] _ in + self?.historyManager?.clearAll() } + observerTokens.append(clearHistoryToken) // Save captures to history - NotificationCenter.default.addObserver(forName: .captureCompleted, object: nil, queue: .main) { [weak self] notification in + let captureCompletedToken = NotificationCenter.default.addObserver( + forName: .captureCompleted, + object: nil, + queue: .main + ) { [weak self] notification in guard let info = notification.object as? CaptureCompletedInfo, let historyManager = self?.historyManager else { return } - let recordID = UUID() - let image = info.result.image - DispatchQueue.global(qos: .utility).async { - let thumbnailImage = ImageUtils.generateThumbnail(image) ?? image - let thumbnailURL = historyManager.saveThumbnail(thumbnailImage, for: recordID) - Task { @MainActor in - historyManager.saveCapture( - result: info.result, - savedURL: info.savedURL, - thumbnailURL: thumbnailURL, - recordID: recordID - ) - } + let thumbnailURL = info.thumbnail.flatMap { historyManager.saveThumbnail($0, for: info.recordID) } + historyManager.saveCapture( + result: info.result, + savedURL: info.savedURL, + thumbnailURL: thumbnailURL, + recordID: info.recordID, + fileSize: info.fileSize + ) } } + observerTokens.append(captureCompletedToken) + + let deleteHistoryRecordToken = NotificationCenter.default.addObserver( + forName: .deleteHistoryRecord, + object: nil, + queue: .main + ) { [weak self] notification in + guard let recordID = notification.object as? UUID else { return } + self?.historyManager?.delete(recordID: recordID) + } + observerTokens.append(deleteHistoryRecordToken) } private func showSettingsWindow() { diff --git a/Snapper/App/AppState.swift b/Snapper/App/AppState.swift index dbaf32c..6dc62b2 100644 --- a/Snapper/App/AppState.swift +++ b/Snapper/App/AppState.swift @@ -5,82 +5,91 @@ import SwiftUI @Observable final class AppState { private let defaults: UserDefaults + private var pendingDefaults: [String: Any] = [:] + private var flushWorkItem: DispatchWorkItem? var isFirstRun: Bool { - didSet { defaults.set(!isFirstRun, forKey: Constants.Keys.hasLaunchedBefore) } + didSet { enqueueDefault(!isFirstRun, forKey: Constants.Keys.hasLaunchedBefore) } } var isCapturing = false var menuBarVisible: Bool { - didSet { defaults.set(menuBarVisible, forKey: Constants.Keys.menuBarVisible) } + didSet { enqueueDefault(menuBarVisible, forKey: Constants.Keys.menuBarVisible) } } var launchAtLogin: Bool { - didSet { defaults.set(launchAtLogin, forKey: Constants.Keys.launchAtLogin) } + didSet { enqueueDefault(launchAtLogin, forKey: Constants.Keys.launchAtLogin) } } var captureSound: Bool { - didSet { defaults.set(captureSound, forKey: Constants.Keys.captureSound) } + didSet { enqueueDefault(captureSound, forKey: Constants.Keys.captureSound) } } var captureSoundName: CaptureSound { - didSet { defaults.set(captureSoundName.rawValue, forKey: Constants.Keys.captureSoundName) } + didSet { enqueueDefault(captureSoundName.rawValue, forKey: Constants.Keys.captureSoundName) } } var copyToClipboard: Bool { - didSet { defaults.set(copyToClipboard, forKey: Constants.Keys.copyToClipboard) } + didSet { enqueueDefault(copyToClipboard, forKey: Constants.Keys.copyToClipboard) } } var saveToFile: Bool { - didSet { defaults.set(saveToFile, forKey: Constants.Keys.saveToFile) } + didSet { enqueueDefault(saveToFile, forKey: Constants.Keys.saveToFile) } } var saveDirectory: URL { - didSet { defaults.set(saveDirectory.path, forKey: Constants.Keys.saveDirectory) } + didSet { enqueueDefault(saveDirectory.path, forKey: Constants.Keys.saveDirectory) } } var imageFormat: ImageFormat { - didSet { defaults.set(imageFormat.rawValue, forKey: Constants.Keys.imageFormat) } + didSet { enqueueDefault(imageFormat.rawValue, forKey: Constants.Keys.imageFormat) } } var jpegQuality: Double { - didSet { defaults.set(jpegQuality, forKey: Constants.Keys.jpegQuality) } + didSet { enqueueDefault(jpegQuality, forKey: Constants.Keys.jpegQuality) } } var filenamePattern: String { - didSet { defaults.set(filenamePattern, forKey: Constants.Keys.filenamePattern) } + didSet { enqueueDefault(filenamePattern, forKey: Constants.Keys.filenamePattern) } } var showCrosshair: Bool { - didSet { defaults.set(showCrosshair, forKey: Constants.Keys.showCrosshair) } + didSet { enqueueDefault(showCrosshair, forKey: Constants.Keys.showCrosshair) } } var showMagnifier: Bool { - didSet { defaults.set(showMagnifier, forKey: Constants.Keys.showMagnifier) } + didSet { enqueueDefault(showMagnifier, forKey: Constants.Keys.showMagnifier) } } var freezeScreen: Bool { - didSet { defaults.set(freezeScreen, forKey: Constants.Keys.freezeScreen) } + didSet { enqueueDefault(freezeScreen, forKey: Constants.Keys.freezeScreen) } } var retina2x: Bool { - didSet { defaults.set(retina2x, forKey: Constants.Keys.retina2x) } + didSet { enqueueDefault(retina2x, forKey: Constants.Keys.retina2x) } + } + + var windowCaptureIncludeShadow: Bool { + didSet { enqueueDefault(windowCaptureIncludeShadow, forKey: Constants.Keys.windowCaptureIncludeShadow) } } var overlayCorner: OverlayCorner { didSet { - defaults.set(overlayCorner.rawValue, forKey: Constants.Keys.overlayCorner) + enqueueDefault(overlayCorner.rawValue, forKey: Constants.Keys.overlayCorner) NotificationCenter.default.post(name: .overlayCornerChanged, object: nil) } } var historyRetentionDays: Int { - didSet { defaults.set(historyRetentionDays, forKey: Constants.Keys.historyRetentionDays) } + didSet { enqueueDefault(historyRetentionDays, forKey: Constants.Keys.historyRetentionDays) } } var defaultPinnedOpacity: Double { - didSet { defaults.set(defaultPinnedOpacity, forKey: Constants.Keys.defaultPinnedOpacity) } + didSet { + enqueueDefault(defaultPinnedOpacity, forKey: Constants.Keys.defaultPinnedOpacity) + NotificationCenter.default.post(name: .pinnedOpacityChanged, object: nil) + } } init(defaults: UserDefaults = .standard) { @@ -102,7 +111,8 @@ final class AppState { if let path = defaults.string(forKey: Constants.Keys.saveDirectory) { self.saveDirectory = URL(fileURLWithPath: path) } else { - self.saveDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first! + self.saveDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()) } if let raw = defaults.string(forKey: Constants.Keys.imageFormat), @@ -115,9 +125,10 @@ final class AppState { self.jpegQuality = defaults.object(forKey: Constants.Keys.jpegQuality) as? Double ?? 0.9 self.filenamePattern = defaults.string(forKey: Constants.Keys.filenamePattern) ?? Constants.Defaults.filenamePattern self.showCrosshair = defaults.object(forKey: Constants.Keys.showCrosshair) as? Bool ?? true - self.showMagnifier = defaults.object(forKey: Constants.Keys.showMagnifier) as? Bool ?? true + self.showMagnifier = defaults.object(forKey: Constants.Keys.showMagnifier) as? Bool ?? false self.freezeScreen = defaults.bool(forKey: Constants.Keys.freezeScreen) self.retina2x = defaults.object(forKey: Constants.Keys.retina2x) as? Bool ?? true + self.windowCaptureIncludeShadow = defaults.object(forKey: Constants.Keys.windowCaptureIncludeShadow) as? Bool ?? true if let raw = defaults.string(forKey: Constants.Keys.overlayCorner), let corner = OverlayCorner(rawValue: raw) { @@ -129,6 +140,26 @@ final class AppState { self.historyRetentionDays = defaults.object(forKey: Constants.Keys.historyRetentionDays) as? Int ?? 30 self.defaultPinnedOpacity = defaults.object(forKey: Constants.Keys.defaultPinnedOpacity) as? Double ?? 1.0 } + + private func enqueueDefault(_ value: Any, forKey key: String) { + pendingDefaults[key] = value + flushWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + self?.flushDefaults() + } + flushWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item) + } + + func flushDefaults() { + flushWorkItem?.cancel() + flushWorkItem = nil + guard !pendingDefaults.isEmpty else { return } + for (key, value) in pendingDefaults { + defaults.set(value, forKey: key) + } + pendingDefaults.removeAll() + } } enum ImageFormat: String, CaseIterable { diff --git a/Snapper/Capture/AllInOneHUD/AllInOneHUDPanel.swift b/Snapper/Capture/AllInOneHUD/AllInOneHUDPanel.swift index 614af36..655ae95 100644 --- a/Snapper/Capture/AllInOneHUD/AllInOneHUDPanel.swift +++ b/Snapper/Capture/AllInOneHUD/AllInOneHUDPanel.swift @@ -3,9 +3,10 @@ import SwiftUI final class AllInOneHUDPanel { private var panel: NSPanel? + private var observerToken: NSObjectProtocol? init() { - NotificationCenter.default.addObserver( + observerToken = NotificationCenter.default.addObserver( forName: .showAllInOneHUD, object: nil, queue: .main @@ -14,6 +15,12 @@ final class AllInOneHUDPanel { } } + deinit { + if let observerToken { + NotificationCenter.default.removeObserver(observerToken) + } + } + func show() { if let existing = panel { existing.makeKeyAndOrderFront(nil) diff --git a/Snapper/Capture/AreaSelector/AreaSelectorOverlayView.swift b/Snapper/Capture/AreaSelector/AreaSelectorOverlayView.swift index 1f145f4..99e8eac 100644 --- a/Snapper/Capture/AreaSelector/AreaSelectorOverlayView.swift +++ b/Snapper/Capture/AreaSelector/AreaSelectorOverlayView.swift @@ -3,14 +3,18 @@ import AppKit final class AreaSelectorOverlayView: NSView { var onSelectionComplete: ((CGRect) -> Void)? var onCancel: (() -> Void)? + var frozenImage: CGImage? + var showsMagnifier = false private var selectionStart: NSPoint? private var selectionRect: NSRect? private var isDragging = false + private var magnifierView: MagnifierView? private let overlayColor = NSColor.black.withAlphaComponent(0.3) private let selectionBorderColor = NSColor.white private let dimensionFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) + private let magnifierInset: CGFloat = 16 override var acceptsFirstResponder: Bool { true } @@ -34,6 +38,10 @@ final class AreaSelectorOverlayView: NSView { super.draw(dirtyRect) guard let context = NSGraphicsContext.current?.cgContext else { return } + if let frozenImage { + context.draw(frozenImage, in: bounds) + } + // Dark overlay context.setFillColor(overlayColor.cgColor) context.fill(bounds) @@ -83,6 +91,7 @@ final class AreaSelectorOverlayView: NSView { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) + updateMagnifier(at: point) selectionStart = point selectionRect = nil isDragging = true @@ -92,6 +101,7 @@ final class AreaSelectorOverlayView: NSView { override func mouseDragged(with event: NSEvent) { guard let start = selectionStart else { return } let current = convert(event.locationInWindow, from: nil) + updateMagnifier(at: current) selectionRect = NSRect( x: min(start.x, current.x), y: min(start.y, current.y), @@ -110,6 +120,17 @@ final class AreaSelectorOverlayView: NSView { } } + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + let point = convert(event.locationInWindow, from: nil) + updateMagnifier(at: point) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + magnifierView?.isHidden = true + } + @discardableResult func discardSelection() -> Bool { let hadSelection = selectionStart != nil || selectionRect != nil || isDragging @@ -137,4 +158,47 @@ final class AreaSelectorOverlayView: NSView { onCancel?() } } + + private func updateMagnifier(at point: NSPoint) { + guard showsMagnifier, let window else { + magnifierView?.isHidden = true + return + } + + let magnifier = ensureMagnifierView() + let screenPoint = NSPoint( + x: window.frame.origin.x + point.x, + y: window.frame.origin.y + point.y + ) + if let screen = window.screen { + magnifier.update(at: screenPoint, on: screen) + } + magnifier.isHidden = false + + var frame = magnifier.frame + frame.origin = NSPoint( + x: point.x + magnifierInset, + y: point.y + magnifierInset + ) + if frame.maxX > bounds.maxX { + frame.origin.x = point.x - magnifierInset - frame.width + } + if frame.maxY > bounds.maxY { + frame.origin.y = point.y - magnifierInset - frame.height + } + frame.origin.x = max(bounds.minX, min(frame.origin.x, bounds.maxX - frame.width)) + frame.origin.y = max(bounds.minY, min(frame.origin.y, bounds.maxY - frame.height)) + magnifier.frame = frame + } + + private func ensureMagnifierView() -> MagnifierView { + if let magnifierView { + return magnifierView + } + let magnifier = MagnifierView(frame: .zero) + magnifier.isHidden = true + addSubview(magnifier) + magnifierView = magnifier + return magnifier + } } diff --git a/Snapper/Capture/AreaSelector/AreaSelectorWindowController.swift b/Snapper/Capture/AreaSelector/AreaSelectorWindowController.swift index 1bc4559..f0541a3 100644 --- a/Snapper/Capture/AreaSelector/AreaSelectorWindowController.swift +++ b/Snapper/Capture/AreaSelector/AreaSelectorWindowController.swift @@ -11,7 +11,7 @@ final class AreaSelectorWindowController { self.completion = completion } - func show(freezeScreen: Bool) { + func show(freezeScreen: Bool, showMagnifier: Bool = false) { didFinish = false installKeyMonitor() NSApp.activate(ignoringOtherApps: true) @@ -32,6 +32,8 @@ final class AreaSelectorWindowController { window.hasShadow = false let overlayView = AreaSelectorOverlayView(frame: screen.frame) + overlayView.showsMagnifier = showMagnifier + overlayView.frozenImage = freezeScreen ? captureImage(for: screen) : nil overlayView.onSelectionComplete = { [weak self] rect in guard let self else { return } // Convert from view coordinates to screen coordinates @@ -61,7 +63,7 @@ final class AreaSelectorWindowController { self.localKeyMonitor = nil } for window in overlayWindows { - window.orderOut(nil) + window.close() } overlayWindows.removeAll() overlayViews.removeAll() @@ -90,6 +92,13 @@ final class AreaSelectorWindowController { close() completion(result) } + + private func captureImage(for screen: NSScreen) -> CGImage? { + guard let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + return nil + } + return CGDisplayCreateImage(displayID) + } } private final class AreaSelectorWindow: NSWindow { diff --git a/Snapper/Capture/CaptureCoordinator.swift b/Snapper/Capture/CaptureCoordinator.swift index 350b0cb..ae13a9e 100644 --- a/Snapper/Capture/CaptureCoordinator.swift +++ b/Snapper/Capture/CaptureCoordinator.swift @@ -6,14 +6,21 @@ final class CaptureCoordinator { private let captureService = ScreenCaptureService() private var areaSelectorController: AreaSelectorWindowController? private var windowSelectorController: WindowSelectorController? + private var observerTokens: [NSObjectProtocol] = [] init(appState: AppState) { self.appState = appState observeCaptureTriggers() } + deinit { + for token in observerTokens { + NotificationCenter.default.removeObserver(token) + } + } + private func observeCaptureTriggers() { - NotificationCenter.default.addObserver( + let token = NotificationCenter.default.addObserver( forName: .startCapture, object: nil, queue: .main @@ -21,6 +28,25 @@ final class CaptureCoordinator { guard let mode = notification.object as? CaptureMode else { return } self?.startCapture(mode: mode) } + observerTokens.append(token) + + let ocrFinishToken = NotificationCenter.default.addObserver( + forName: .ocrCaptureDidFinish, + object: nil, + queue: .main + ) { [weak self] _ in + self?.appState.isCapturing = false + } + observerTokens.append(ocrFinishToken) + + let timerFinishToken = NotificationCenter.default.addObserver( + forName: .timerCaptureDidFinish, + object: nil, + queue: .main + ) { [weak self] _ in + self?.appState.isCapturing = false + } + observerTokens.append(timerFinishToken) } func startCapture(mode: CaptureMode) { @@ -38,17 +64,15 @@ final class CaptureCoordinator { captureWindow(options: options) case .ocr: NotificationCenter.default.post(name: .startOCRCapture, object: options) - appState.isCapturing = false case .timer: NotificationCenter.default.post(name: .startTimerCapture, object: TimerCaptureRequest.default) - appState.isCapturing = false } } private func captureFullscreen(options: CaptureOptions) { Task { do { - let image = try await captureService.captureDisplay() + let image = try await captureService.captureDisplay(retinaScale: options.retina2x) let result = CaptureResult( image: image, mode: .fullscreen, @@ -65,6 +89,8 @@ final class CaptureCoordinator { } private func captureArea(options: CaptureOptions) { + areaSelectorController?.close() + areaSelectorController = nil areaSelectorController = AreaSelectorWindowController { [weak self] rect in guard let self else { return } self.areaSelectorController?.close() @@ -77,7 +103,7 @@ final class CaptureCoordinator { Task { do { - let image = try await self.captureService.captureRect(rect) + let image = try await self.captureService.captureRect(rect, retinaScale: options.retina2x) let result = CaptureResult( image: image, mode: .area, @@ -92,10 +118,12 @@ final class CaptureCoordinator { } } } - areaSelectorController?.show(freezeScreen: options.freezeScreen) + areaSelectorController?.show(freezeScreen: options.freezeScreen, showMagnifier: options.showMagnifier) } private func captureWindow(options: CaptureOptions) { + windowSelectorController?.close() + windowSelectorController = nil windowSelectorController = WindowSelectorController { [weak self] windowInfo in guard let self else { return } self.windowSelectorController?.close() @@ -108,7 +136,11 @@ final class CaptureCoordinator { Task { do { - let image = try await self.captureService.captureWindow(windowInfo.window) + let image = try await self.captureService.captureWindow( + windowInfo.window, + retinaScale: options.retina2x, + includeShadow: options.includeShadow + ) let result = CaptureResult( image: image, mode: .window, @@ -140,8 +172,10 @@ final class CaptureCoordinator { SoundPlayer.playCapture(options.captureSound) } + let recordID = UUID() DispatchQueue.global(qos: .userInitiated).async { var savedURL: URL? + var fileSize = 0 if options.saveToFile { let filename = FileNameGenerator.generate( pattern: options.filenamePattern, @@ -153,13 +187,21 @@ final class CaptureCoordinator { .appendingPathExtension(options.format.fileExtension) if ImageUtils.save(result.image, to: url, format: options.format, jpegQuality: options.jpegQuality) { savedURL = url + fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 } } + let thumbnail = ImageUtils.generateThumbnail(result.image) DispatchQueue.main.async { NotificationCenter.default.post( name: .captureCompleted, - object: CaptureCompletedInfo(result: result, savedURL: savedURL) + object: CaptureCompletedInfo( + recordID: recordID, + result: result, + savedURL: savedURL, + fileSize: fileSize, + thumbnail: thumbnail + ) ) } } @@ -179,12 +221,18 @@ final class CaptureCoordinator { } struct CaptureCompletedInfo { + let recordID: UUID let result: CaptureResult let savedURL: URL? + let fileSize: Int + let thumbnail: CGImage? } extension Notification.Name { static let showAllInOneHUD = Notification.Name("showAllInOneHUD") -static let startOCRCapture = Notification.Name("startOCRCapture") + static let startOCRCapture = Notification.Name("startOCRCapture") static let startTimerCapture = Notification.Name("startTimerCapture") + static let deleteHistoryRecord = Notification.Name("deleteHistoryRecord") + static let ocrCaptureDidFinish = Notification.Name("ocrCaptureDidFinish") + static let timerCaptureDidFinish = Notification.Name("timerCaptureDidFinish") } diff --git a/Snapper/Capture/CaptureOptions.swift b/Snapper/Capture/CaptureOptions.swift index 9a684e5..23dc83d 100644 --- a/Snapper/Capture/CaptureOptions.swift +++ b/Snapper/Capture/CaptureOptions.swift @@ -2,6 +2,7 @@ import Foundation struct CaptureOptions { var freezeScreen: Bool = false + var showMagnifier: Bool = false var playSound: Bool = false var captureSound: CaptureSound = .glass var copyToClipboard: Bool = true @@ -16,6 +17,7 @@ struct CaptureOptions { static func from(appState: AppState) -> CaptureOptions { CaptureOptions( freezeScreen: appState.freezeScreen, + showMagnifier: appState.showMagnifier, playSound: appState.captureSound, captureSound: appState.captureSoundName, copyToClipboard: appState.copyToClipboard, @@ -23,6 +25,7 @@ struct CaptureOptions { format: appState.imageFormat, saveDirectory: appState.saveDirectory, filenamePattern: appState.filenamePattern, + includeShadow: appState.windowCaptureIncludeShadow, retina2x: appState.retina2x, jpegQuality: appState.jpegQuality ) diff --git a/Snapper/Capture/OCRCapture/OCRCaptureController.swift b/Snapper/Capture/OCRCapture/OCRCaptureController.swift index 06167ac..986079a 100644 --- a/Snapper/Capture/OCRCapture/OCRCaptureController.swift +++ b/Snapper/Capture/OCRCapture/OCRCaptureController.swift @@ -3,32 +3,53 @@ import AppKit final class OCRCaptureController { private var areaSelectorController: AreaSelectorWindowController? private let captureService = ScreenCaptureService() + private var observerTokens: [NSObjectProtocol] = [] init() { - NotificationCenter.default.addObserver( + let token = NotificationCenter.default.addObserver( forName: .startOCRCapture, object: nil, queue: .main - ) { [weak self] _ in - self?.start() + ) { [weak self] notification in + let options = notification.object as? CaptureOptions + self?.start(options: options) } + observerTokens.append(token) } - func start() { + deinit { + for token in observerTokens { + NotificationCenter.default.removeObserver(token) + } + } + + func start(options: CaptureOptions? = nil) { + areaSelectorController?.close() + areaSelectorController = nil areaSelectorController = AreaSelectorWindowController { [weak self] rect in guard let self else { return } self.areaSelectorController?.close() self.areaSelectorController = nil - guard let rect else { return } + guard let rect else { + NotificationCenter.default.post(name: .ocrCaptureDidFinish, object: nil) + return + } Task { do { - let image = try await self.captureService.captureRect(rect) + let image = try await self.captureService.captureRect( + rect, + retinaScale: options?.retina2x ?? true + ) await MainActor.run { AnnotationEditorWindow.open(with: image) + NotificationCenter.default.post(name: .ocrCaptureDidFinish, object: nil) } } catch { + await MainActor.run { + NotificationCenter.default.post(name: .ocrCaptureDidFinish, object: nil) + } if let captureError = error as? CaptureError, captureError == .permissionDenied { await MainActor.run { PermissionChecker.promptForScreenRecordingInSettings() @@ -39,6 +60,9 @@ final class OCRCaptureController { } } } - areaSelectorController?.show(freezeScreen: false) + areaSelectorController?.show( + freezeScreen: options?.freezeScreen ?? false, + showMagnifier: options?.showMagnifier ?? false + ) } } diff --git a/Snapper/Capture/OCRCapture/OCRResultPanel.swift b/Snapper/Capture/OCRCapture/OCRResultPanel.swift index 677ae11..e8efdf4 100644 --- a/Snapper/Capture/OCRCapture/OCRResultPanel.swift +++ b/Snapper/Capture/OCRCapture/OCRResultPanel.swift @@ -5,6 +5,9 @@ final class OCRResultPanel { private static var panel: NSPanel? static func show(text: String) { + panel?.close() + panel = nil + let view = OCRResultView(text: text) let hostingView = NSHostingView(rootView: view) diff --git a/Snapper/Capture/ScreenCaptureService.swift b/Snapper/Capture/ScreenCaptureService.swift index 163dc9b..20fd9b8 100644 --- a/Snapper/Capture/ScreenCaptureService.swift +++ b/Snapper/Capture/ScreenCaptureService.swift @@ -9,14 +9,32 @@ final class ScreenCaptureService { fileprivate let configuration: SCStreamConfiguration } - func captureDisplay(_ display: SCDisplay? = nil) async throws -> CGImage { - try ensureScreenCapturePermission() + private var cachedContent: SCShareableContent? + private var cachedContentTimestamp: Date = .distantPast + private let contentCacheDuration: TimeInterval = 2.0 + + private func getCachedContent() async throws -> SCShareableContent { + let now = Date() + if let cached = cachedContent, now.timeIntervalSince(cachedContentTimestamp) < contentCacheDuration { + return cached + } let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - let targetDisplay = display ?? content.displays.first! + cachedContent = content + cachedContentTimestamp = now + return content + } + + func captureDisplay(_ display: SCDisplay? = nil, retinaScale: Bool = true) async throws -> CGImage { + try ensureScreenCapturePermission() + let content = try await getCachedContent() + let targetDisplay = display ?? preferredDisplay(in: content) + guard let targetDisplay else { + throw CaptureError.noDisplay + } let filter = SCContentFilter(display: targetDisplay, excludingWindows: []) let config = SCStreamConfiguration() - let scale = displayScaleFactor(for: targetDisplay) + let scale = retinaScale ? displayScaleFactor(for: targetDisplay) : 1.0 config.width = max(1, Int(CGFloat(targetDisplay.width) * scale)) config.height = max(1, Int(CGFloat(targetDisplay.height) * scale)) config.showsCursor = false @@ -29,17 +47,18 @@ final class ScreenCaptureService { return image } - func captureWindow(_ window: SCWindow) async throws -> CGImage { + func captureWindow(_ window: SCWindow, retinaScale: Bool = true, includeShadow: Bool = true) async throws -> CGImage { try ensureScreenCapturePermission() - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let content = try await getCachedContent() let filter = SCContentFilter(desktopIndependentWindow: window) let config = SCStreamConfiguration() - let scale = displayScale(for: window.frame, in: content) + let scale = retinaScale ? displayScale(for: window.frame, in: content) : 1.0 config.width = max(1, Int(window.frame.width * scale)) config.height = max(1, Int(window.frame.height * scale)) config.showsCursor = false config.captureResolution = .best config.shouldBeOpaque = false + config.ignoreShadowsSingleWindow = !includeShadow let image = try await SCScreenshotManager.captureImage( contentFilter: filter, @@ -48,23 +67,24 @@ final class ScreenCaptureService { return image } - func captureRect(_ rect: CGRect) async throws -> CGImage { + func captureRect(_ rect: CGRect, retinaScale: Bool = true) async throws -> CGImage { try ensureScreenCapturePermission() - let context = try await prepareRectCapture(for: rect) + let context = try await prepareRectCapture(for: rect, retinaScale: retinaScale) return try await captureRect(rect, using: context) } func prepareRectCapture( for rect: CGRect, content: SCShareableContent? = nil, - excludingWindowIDs: [CGWindowID] = [] + excludingWindowIDs: [CGWindowID] = [], + retinaScale: Bool = true ) async throws -> RectCaptureContext { try ensureScreenCapturePermission() let resolvedContent: SCShareableContent if let content { resolvedContent = content } else { - resolvedContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + resolvedContent = try await getCachedContent() } guard let display = resolveDisplay(for: rect, in: resolvedContent) else { throw CaptureError.noDisplay @@ -77,7 +97,7 @@ final class ScreenCaptureService { height: CGFloat(display.frame.height) ) - let scale = displayScaleFactor(for: display) + let scale = retinaScale ? displayScaleFactor(for: display) : 1.0 let excludedWindows: [SCWindow] if excludingWindowIDs.isEmpty { excludedWindows = [] @@ -128,7 +148,7 @@ final class ScreenCaptureService { func getShareableContent() async throws -> SCShareableContent { try ensureScreenCapturePermission() - return try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + return try await getCachedContent() } private func ensureScreenCapturePermission() throws { @@ -175,6 +195,16 @@ final class ScreenCaptureService { return 2.0 } + private func preferredDisplay(in content: SCShareableContent) -> SCDisplay? { + guard !content.displays.isEmpty else { return nil } + if let mainScreen = NSScreen.main, + let mainDisplayID = mainScreen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID, + let matchingDisplay = content.displays.first(where: { $0.displayID == mainDisplayID }) { + return matchingDisplay + } + return content.displays.first + } + } enum CaptureError: Error, LocalizedError { diff --git a/Snapper/Capture/TimerCapture/TimerCaptureController.swift b/Snapper/Capture/TimerCapture/TimerCaptureController.swift index 1533619..ff06a0b 100644 --- a/Snapper/Capture/TimerCapture/TimerCaptureController.swift +++ b/Snapper/Capture/TimerCapture/TimerCaptureController.swift @@ -18,9 +18,10 @@ struct TimerCaptureRequest { final class TimerCaptureController { private var countdownWindow: NSWindow? private var timer: Timer? + private var observerToken: NSObjectProtocol? init() { - NotificationCenter.default.addObserver( + observerToken = NotificationCenter.default.addObserver( forName: .startTimerCapture, object: nil, queue: .main @@ -30,8 +31,21 @@ final class TimerCaptureController { } } + deinit { + timer?.invalidate() + countdownWindow?.close() + countdownWindow = nil + if let observerToken { + NotificationCenter.default.removeObserver(observerToken) + } + } + func start(seconds: Int, mode: CaptureMode) { + let hadExistingCountdown = timer != nil cancelExistingCountdown() + if hadExistingCountdown { + NotificationCenter.default.post(name: .timerCaptureDidFinish, object: nil) + } showCountdown(seconds: seconds) { NotificationCenter.default.post(name: .startCapture, object: mode) } @@ -40,7 +54,7 @@ final class TimerCaptureController { private func cancelExistingCountdown() { timer?.invalidate() timer = nil - countdownWindow?.orderOut(nil) + countdownWindow?.close() countdownWindow = nil } @@ -72,7 +86,7 @@ final class TimerCaptureController { if remaining <= 0 { timer.invalidate() self?.timer = nil - self?.countdownWindow?.orderOut(nil) + self?.countdownWindow?.close() self?.countdownWindow = nil completion() } else { diff --git a/Snapper/Capture/WindowSelector/WindowSelectorController.swift b/Snapper/Capture/WindowSelector/WindowSelectorController.swift index c19eda2..17f9895 100644 --- a/Snapper/Capture/WindowSelector/WindowSelectorController.swift +++ b/Snapper/Capture/WindowSelector/WindowSelectorController.swift @@ -9,17 +9,19 @@ struct WindowInfo { } final class WindowSelectorController { - private var overlayWindow: NSWindow? - private var highlightOverlay: WindowHighlightOverlay? + private var overlayWindows: [NSWindow] = [] + private var highlightOverlays: [WindowHighlightOverlay] = [] private var windows: [SCWindow] = [] private let completion: (WindowInfo?) -> Void private var mouseMonitor: Any? + private var didFinish = false init(completion: @escaping (WindowInfo?) -> Void) { self.completion = completion } func show() { + didFinish = false Task { do { let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) @@ -27,33 +29,35 @@ final class WindowSelectorController { await MainActor.run { setupOverlay() } } catch { print("Failed to get windows: \(error)") - completion(nil) + finish(with: nil) } } } @MainActor private func setupOverlay() { - guard let screen = NSScreen.main else { return } + guard !NSScreen.screens.isEmpty else { return } - let window = NSWindow( - contentRect: screen.frame, - styleMask: .borderless, - backing: .buffered, - defer: false, - screen: screen - ) - window.level = .init(rawValue: Int(CGWindowLevelForKey(.maximumWindow)) - 1) - window.isOpaque = false - window.backgroundColor = NSColor.black.withAlphaComponent(0.01) - window.ignoresMouseEvents = false - window.acceptsMouseMovedEvents = true + for screen in NSScreen.screens { + let window = NSWindow( + contentRect: screen.frame, + styleMask: .borderless, + backing: .buffered, + defer: false, + screen: screen + ) + window.level = .init(rawValue: Int(CGWindowLevelForKey(.maximumWindow)) - 1) + window.isOpaque = false + window.backgroundColor = NSColor.black.withAlphaComponent(0.01) + window.ignoresMouseEvents = false + window.acceptsMouseMovedEvents = true - let overlay = WindowHighlightOverlay(frame: screen.frame) - window.contentView = overlay - window.makeKeyAndOrderFront(nil) - overlayWindow = window - highlightOverlay = overlay + let overlay = WindowHighlightOverlay(frame: screen.frame) + window.contentView = overlay + window.makeKeyAndOrderFront(nil) + overlayWindows.append(window) + highlightOverlays.append(overlay) + } mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { [weak self] event in self?.handleEvent(event) @@ -66,11 +70,15 @@ final class WindowSelectorController { case .mouseMoved: let mouseLocation = NSEvent.mouseLocation if let hoveredWindow = hitTestWindow(at: mouseLocation) { - highlightOverlay?.highlightFrame = hoveredWindow.frame - highlightOverlay?.needsDisplay = true + for overlay in highlightOverlays { + overlay.highlightFrame = hoveredWindow.frame + overlay.needsDisplay = true + } } else { - highlightOverlay?.highlightFrame = nil - highlightOverlay?.needsDisplay = true + for overlay in highlightOverlays { + overlay.highlightFrame = nil + overlay.needsDisplay = true + } } case .leftMouseDown: @@ -82,14 +90,14 @@ final class WindowSelectorController { title: selectedWindow.title, appName: selectedWindow.owningApplication?.applicationName ) - completion(info) + finish(with: info) } else { - completion(nil) + finish(with: nil) } case .keyDown: if event.keyCode == 53 { // Escape - completion(nil) + finish(with: nil) } default: @@ -108,13 +116,22 @@ final class WindowSelectorController { return nil } + private func finish(with result: WindowInfo?) { + guard !didFinish else { return } + didFinish = true + close() + completion(result) + } + func close() { if let monitor = mouseMonitor { NSEvent.removeMonitor(monitor) mouseMonitor = nil } - overlayWindow?.orderOut(nil) - overlayWindow = nil - highlightOverlay = nil + for window in overlayWindows { + window.close() + } + overlayWindows.removeAll() + highlightOverlays.removeAll() } } diff --git a/Snapper/Editor/Canvas/CanvasNSView.swift b/Snapper/Editor/Canvas/CanvasNSView.swift index 82d874a..141f7ae 100644 --- a/Snapper/Editor/Canvas/CanvasNSView.swift +++ b/Snapper/Editor/Canvas/CanvasNSView.swift @@ -15,6 +15,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { private var textRecognitionTask: Task? private var nextTextRecognitionRetryDate: Date = .distantPast private var recognizedTextBlocks: [OCRTextBlock] = [] + private var recognizedTextRectsByID: [UUID: CGRect] = [:] private var selectedTextBlockIDs: Set = [] private var selectedAnnotationIDs: Set = [] private var textSelectionStartPoint: CGPoint? @@ -75,6 +76,21 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { } } + private enum HighlightStyle { + case selected + case preview + + var fallbackAlpha: CGFloat { + switch self { + case .selected: return 0.28 + case .preview: return 0.17 + } + } + } + + private static let selectedHighlightGradient = makeHighlightGradient(topAlpha: 0.08, bottomAlpha: 0.28) + private static let previewHighlightGradient = makeHighlightGradient(topAlpha: 0.05, bottomAlpha: 0.17) + init(canvasState: CanvasState, toolManager: ToolManager) { self.canvasState = canvasState self.toolManager = toolManager @@ -299,6 +315,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { setSelectedAnnotationIDs([hitAnnotation.id], primaryID: hitAnnotation.id) } else { canvasState.selectedAnnotationID = hitAnnotation.id + canvasState.selectedAnnotationIDs = selectedAnnotationIDs } } @@ -618,6 +635,9 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { super.viewDidMoveToWindow() window?.acceptsMouseMovedEvents = true window?.makeFirstResponder(self) + resetViewportIfImageChanged() + configureInitialViewportIfNeeded() + syncCropModeState() } private func resetViewportIfImageChanged() { @@ -884,6 +904,9 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { private func synchronizeAnnotationSelectionState() { let validIDs = Set(canvasState.annotations.map(\.id)) + if !canvasState.selectedAnnotationIDs.isEmpty { + selectedAnnotationIDs.formUnion(canvasState.selectedAnnotationIDs) + } selectedAnnotationIDs = Set(selectedAnnotationIDs.filter { validIDs.contains($0) }) if let primaryID = canvasState.selectedAnnotationID { @@ -903,6 +926,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { if selectedAnnotationIDs.isEmpty { canvasState.selectedAnnotationID = nil } + canvasState.selectedAnnotationIDs = selectedAnnotationIDs } private func setSelectedAnnotationIDs(_ ids: Set, primaryID: UUID?) { @@ -912,6 +936,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { } else { canvasState.selectedAnnotationID = ids.first } + canvasState.selectedAnnotationIDs = selectedAnnotationIDs } private func toggleSelection(for annotationID: UUID) { @@ -928,6 +953,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { if selectedAnnotationIDs.isEmpty { canvasState.selectedAnnotationID = nil } + canvasState.selectedAnnotationIDs = selectedAnnotationIDs } private func beginAnnotationMarqueeSelection(at point: CGPoint, additive: Bool) { @@ -1084,6 +1110,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { let updated = cloneTextAnnotation(existing, text: finalText) replaceAnnotation(updated) canvasState.selectedAnnotationID = updated.id + canvasState.selectedAnnotationIDs = [updated.id] canvasState.undoManager.recordModify( oldAnnotation: previous, newAnnotation: updated.duplicate(), @@ -1099,14 +1126,20 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { return } - session.textField.frame = inlineEditorFrame(for: annotation) + session.textField.frame = inlineEditorFrame(for: annotation, liveText: session.textField.stringValue) session.textField.font = displayFont(for: annotation) session.textField.textColor = annotation.color } - private func inlineEditorFrame(for annotation: TextAnnotation) -> CGRect { + private func inlineEditorFrame(for annotation: TextAnnotation, liveText: String? = nil) -> CGRect { let zoom = max(canvasState.zoomLevel, 0.1) - let rect = annotation.boundingRect.standardized + let sizingAnnotation: TextAnnotation + if let liveText { + sizingAnnotation = cloneTextAnnotation(annotation, text: liveText) + } else { + sizingAnnotation = annotation + } + let rect = sizingAnnotation.boundingRect.standardized let minWidth = max(26, annotation.fontSize * zoom * 1.6) let minHeight = max(14, annotation.fontSize * zoom + 4) return CGRect( @@ -1151,6 +1184,16 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { finishInlineTextEditing(commit: true) } + func controlTextDidChange(_ obj: Notification) { + guard let session = inlineTextEditSession, + let field = obj.object as? NSTextField, + field === session.textField else { + return + } + updateInlineTextEditorFrameAndStyle() + needsDisplay = true + } + private func annotation(with id: UUID) -> (any Annotation)? { canvasState.annotations.first { $0.id == id } } @@ -1246,7 +1289,6 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { } private func drawAnnotationSelectionOverlay(in context: CGContext) { - synchronizeAnnotationSelectionState() let selectedIDs = selectedAnnotationIDs guard !selectedIDs.isEmpty else { return } @@ -1563,6 +1605,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { if let selectedID = canvasState.selectedAnnotationID, cropIDs.contains(selectedID) { canvasState.selectedAnnotationID = selectedAnnotationIDs.first } + canvasState.selectedAnnotationIDs = selectedAnnotationIDs } annotationEditSession = nil } @@ -1674,6 +1717,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { await MainActor.run { self.recognizedTextBlocks = blocks + self.recognizedTextRectsByID = Dictionary(uniqueKeysWithValues: blocks.map { ($0.id, $0.imageRect) }) self.selectedTextBlockIDs.removeAll() self.canvasState.recognizedTextRegionCount = blocks.count self.hasCompletedTextRecognition = true @@ -1687,6 +1731,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { await MainActor.run { self.recognizedTextBlocks = [] + self.recognizedTextRectsByID = [:] self.selectedTextBlockIDs.removeAll() self.canvasState.recognizedTextRegionCount = 0 self.canvasState.isOCRProcessing = false @@ -1710,6 +1755,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { hasCompletedTextRecognition = false nextTextRecognitionRetryDate = .distantPast recognizedTextBlocks = [] + recognizedTextRectsByID = [:] selectedTextBlockIDs.removeAll() clearTextSelection() annotationEditSession = nil @@ -1747,6 +1793,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { if selectedAnnotationIDs.isEmpty { canvasState.selectedAnnotationID = nil } + canvasState.selectedAnnotationIDs = selectedAnnotationIDs annotationEditSession = nil discarded = true } @@ -1930,8 +1977,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { rect, in: context, zoom: zoom, - topAlpha: 0.08, - bottomAlpha: 0.28 + style: .selected ) } @@ -1942,8 +1988,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { rect, in: context, zoom: zoom, - topAlpha: 0.05, - bottomAlpha: 0.17 + style: .preview ) } @@ -1967,8 +2012,7 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { _ rect: CGRect, in context: CGContext, zoom: CGFloat, - topAlpha: CGFloat, - bottomAlpha: CGFloat + style: HighlightStyle ) { let expanded = rect.insetBy(dx: -1 / zoom, dy: -0.5 / zoom) let path = CGPath( @@ -1979,16 +2023,17 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { ) let color = NSColor.systemBlue - guard let gradient = CGGradient( - colorsSpace: CGColorSpaceCreateDeviceRGB(), - colors: [ - color.withAlphaComponent(topAlpha).cgColor, - color.withAlphaComponent(bottomAlpha).cgColor, - ] as CFArray, - locations: [0, 1] - ) else { + let gradient: CGGradient? + switch style { + case .selected: + gradient = Self.selectedHighlightGradient + case .preview: + gradient = Self.previewHighlightGradient + } + + guard let gradient else { context.addPath(path) - context.setFillColor(color.withAlphaComponent(bottomAlpha).cgColor) + context.setFillColor(color.withAlphaComponent(style.fallbackAlpha).cgColor) context.fillPath() return } @@ -2005,12 +2050,23 @@ final class CanvasNSView: NSView, NSTextFieldDelegate { context.restoreGState() } + private static func makeHighlightGradient(topAlpha: CGFloat, bottomAlpha: CGFloat) -> CGGradient? { + let color = NSColor.systemBlue + return CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [ + color.withAlphaComponent(topAlpha).cgColor, + color.withAlphaComponent(bottomAlpha).cgColor, + ] as CFArray, + locations: [0, 1] + ) + } + private func mergedHighlightRects(for blockIDs: Set) -> [CGRect] { guard !blockIDs.isEmpty else { return [] } - let rects = recognizedTextBlocks - .filter { blockIDs.contains($0.id) } - .map(\.imageRect) + let rects = blockIDs + .compactMap { recognizedTextRectsByID[$0] } .sorted { let yDelta = $0.midY - $1.midY if abs(yDelta) > 2 { return yDelta > 0 } diff --git a/Snapper/Editor/Canvas/CanvasState.swift b/Snapper/Editor/Canvas/CanvasState.swift index a794016..7a967b0 100644 --- a/Snapper/Editor/Canvas/CanvasState.swift +++ b/Snapper/Editor/Canvas/CanvasState.swift @@ -6,6 +6,7 @@ final class CanvasState { let baseImage: CGImage let annotations: [any Annotation] let selectedAnnotationID: UUID? + let selectedAnnotationIDs: Set } var baseImage: CGImage @@ -15,6 +16,7 @@ final class CanvasState { var zoomLevel: CGFloat = 1.0 var panOffset: CGPoint = .zero var selectedAnnotationID: UUID? + var selectedAnnotationIDs: Set = [] let undoManager = UndoRedoManager() var imageWidth: Int { baseImage.width } @@ -33,6 +35,10 @@ final class CanvasState { func removeAnnotation(id: UUID) { if let idx = annotations.firstIndex(where: { $0.id == id }) { let annotation = annotations.remove(at: idx) + selectedAnnotationIDs.remove(id) + if selectedAnnotationID == id { + selectedAnnotationID = selectedAnnotationIDs.first + } undoManager.recordRemove(annotation: annotation, state: self) } } @@ -42,12 +48,19 @@ final class CanvasState { return annotations.first(where: { $0.id == selectedAnnotationID }) } + func selectedAnnotations() -> [any Annotation] { + let ids = effectiveSelectedAnnotationIDs() + guard !ids.isEmpty else { return [] } + return annotations.filter { ids.contains($0.id) } + } + func replaceAnnotation(_ updatedAnnotation: any Annotation, recordUndo: Bool = true) { guard let index = annotations.firstIndex(where: { $0.id == updatedAnnotation.id }) else { return } let previous = annotations[index].duplicate() annotations[index] = updatedAnnotation selectedAnnotationID = updatedAnnotation.id + selectedAnnotationIDs.insert(updatedAnnotation.id) if recordUndo { undoManager.recordModify( @@ -64,6 +77,7 @@ final class CanvasState { if index == annotations.count - 1 { selectedAnnotationID = id + selectedAnnotationIDs = [id] return true } @@ -74,6 +88,7 @@ final class CanvasState { annotation.zOrder = max(maxZOrder + 1, annotation.zOrder + 1) annotations.append(annotation) selectedAnnotationID = id + selectedAnnotationIDs = [id] if recordUndo, let oldSnapshot { let newSnapshot = makeSnapshot() @@ -86,25 +101,17 @@ final class CanvasState { func makeSnapshot() -> Snapshot { Snapshot( baseImage: baseImage, - annotations: annotations.map { annotation in - let copy = annotation.duplicate() - copy.zOrder = annotation.zOrder - copy.isVisible = annotation.isVisible - return copy - }, - selectedAnnotationID: selectedAnnotationID + annotations: annotations.map { $0.duplicate() }, + selectedAnnotationID: selectedAnnotationID, + selectedAnnotationIDs: selectedAnnotationIDs ) } func restore(from snapshot: Snapshot) { baseImage = snapshot.baseImage - annotations = snapshot.annotations.map { annotation in - let copy = annotation.duplicate() - copy.zOrder = annotation.zOrder - copy.isVisible = annotation.isVisible - return copy - } + annotations = snapshot.annotations.map { $0.duplicate() } selectedAnnotationID = snapshot.selectedAnnotationID + selectedAnnotationIDs = snapshot.selectedAnnotationIDs } @discardableResult @@ -182,11 +189,19 @@ final class CanvasState { baseImage = croppedBaseImage annotations = translatedAnnotations selectedAnnotationID = nil + selectedAnnotationIDs = [] let newSnapshot = makeSnapshot() undoManager.recordSnapshot(oldState: oldSnapshot, newState: newSnapshot, state: self) return true } + private func effectiveSelectedAnnotationIDs() -> Set { + if selectedAnnotationIDs.isEmpty, let selectedAnnotationID { + return [selectedAnnotationID] + } + return selectedAnnotationIDs + } + func renderFinalImage() -> CGImage? { let imageBounds = CGRect(x: 0, y: 0, width: baseImage.width, height: baseImage.height) let activeCropRect = annotations diff --git a/Snapper/Editor/Canvas/UndoRedoManager.swift b/Snapper/Editor/Canvas/UndoRedoManager.swift index 7d33172..7d69733 100644 --- a/Snapper/Editor/Canvas/UndoRedoManager.swift +++ b/Snapper/Editor/Canvas/UndoRedoManager.swift @@ -1,7 +1,9 @@ import Foundation +import CoreGraphics @Observable final class UndoRedoManager { + private let maxHistoryDepth = 200 private var undoStack: [UndoAction] = [] private var redoStack: [UndoAction] = [] @@ -9,35 +11,98 @@ final class UndoRedoManager { var canRedo: Bool { !redoStack.isEmpty } func recordAdd(annotation: any Annotation, state: CanvasState) { - undoStack.append(.add(annotation)) - redoStack.removeAll() + record(.add(annotation)) } func recordRemove(annotation: any Annotation, state: CanvasState) { - undoStack.append(.remove(annotation)) - redoStack.removeAll() + record(.remove(annotation)) } func recordModify(oldAnnotation: any Annotation, newAnnotation: any Annotation, state: CanvasState) { - undoStack.append(.modify(old: oldAnnotation, new: newAnnotation)) - redoStack.removeAll() + record(.modify(old: oldAnnotation, new: newAnnotation)) } func recordSnapshot(oldState: CanvasState.Snapshot, newState: CanvasState.Snapshot, state: CanvasState) { - undoStack.append(.snapshot(old: oldState, new: newState)) - redoStack.removeAll() + if isRedundantSnapshot(oldState: oldState, newState: newState) { + return + } + record(.snapshot(old: oldState, new: newState)) } func undo(state: CanvasState) { guard let action = undoStack.popLast() else { return } applyInverse(action, to: state) redoStack.append(action) + trim(&redoStack) } func redo(state: CanvasState) { guard let action = redoStack.popLast() else { return } apply(action, to: state) undoStack.append(action) + trim(&undoStack) + } + + private func record(_ action: UndoAction) { + undoStack.append(action) + trim(&undoStack) + redoStack.removeAll(keepingCapacity: true) + } + + private func trim(_ stack: inout [UndoAction]) { + let overflow = stack.count - maxHistoryDepth + guard overflow > 0 else { return } + stack.removeFirst(overflow) + } + + private func isRedundantSnapshot(oldState: CanvasState.Snapshot, newState: CanvasState.Snapshot) -> Bool { + if snapshotsEquivalent(oldState, newState) { + return true + } + + guard case .snapshot(_, let lastNew)? = undoStack.last else { + return false + } + + return snapshotsEquivalent(lastNew, newState) + } + + private func snapshotsEquivalent(_ lhs: CanvasState.Snapshot, _ rhs: CanvasState.Snapshot) -> Bool { + if lhs.selectedAnnotationID != rhs.selectedAnnotationID { + return false + } + if lhs.selectedAnnotationIDs != rhs.selectedAnnotationIDs { + return false + } + if lhs.annotations.count != rhs.annotations.count { + return false + } + if !isSameImage(lhs.baseImage, rhs.baseImage) { + return false + } + + for (left, right) in zip(lhs.annotations, rhs.annotations) { + if type(of: left) != type(of: right) { + return false + } + if left.id != right.id { + return false + } + if left.zOrder != right.zOrder || left.isVisible != right.isVisible { + return false + } + if left.boundingRect != right.boundingRect { + return false + } + } + + return true + } + + private func isSameImage(_ lhs: CGImage, _ rhs: CGImage) -> Bool { + let lhsPtr = Unmanaged.passUnretained(lhs).toOpaque() + let rhsPtr = Unmanaged.passUnretained(rhs).toOpaque() + return lhsPtr == rhsPtr } private func apply(_ action: UndoAction, to state: CanvasState) { diff --git a/Snapper/Editor/Tools/Annotations/Annotation.swift b/Snapper/Editor/Tools/Annotations/Annotation.swift index 5a6b55c..49cce39 100644 --- a/Snapper/Editor/Tools/Annotations/Annotation.swift +++ b/Snapper/Editor/Tools/Annotations/Annotation.swift @@ -89,6 +89,7 @@ enum AnnotationGeometry { || annotation is EllipseAnnotation || annotation is TextAnnotation || annotation is PencilAnnotation + || annotation is LineAnnotation } static func rotationDegrees(for annotation: any Annotation) -> CGFloat { @@ -370,6 +371,19 @@ enum AnnotationGeometry { strokeWidth: pencil.strokeWidth ) } + if let line = annotation as? LineAnnotation { + let frame = normalizedLineFrame(start: line.start, end: line.end) + let center = CGPoint(x: frame.midX, y: frame.midY) + let rotationRadians = rotationDegrees * (.pi / 180) + return LineAnnotation( + id: line.id, + start: rotate(line.start, around: center, by: rotationRadians), + end: rotate(line.end, around: center, by: rotationRadians), + color: line.color, + strokeWidth: line.strokeWidth, + isDashed: line.isDashed + ) + } return nil } diff --git a/Snapper/Editor/Tools/Annotations/BlurAnnotation.swift b/Snapper/Editor/Tools/Annotations/BlurAnnotation.swift index 2937a44..ae26db9 100644 --- a/Snapper/Editor/Tools/Annotations/BlurAnnotation.swift +++ b/Snapper/Editor/Tools/Annotations/BlurAnnotation.swift @@ -2,7 +2,21 @@ import AppKit import CoreImage final class BlurAnnotation: Annotation { + private final class CachedImageBox { + let image: CGImage + + init(_ image: CGImage) { + self.image = image + } + } + private static let ciContext = CIContext(options: [.cacheIntermediates: true]) + private static let processedImageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 6 + cache.totalCostLimit = 256 * 1024 * 1024 + return cache + }() let id: UUID let type: ToolType = .blur @@ -12,7 +26,7 @@ final class BlurAnnotation: Annotation { let rect: CGRect let intensity: CGFloat let sourceImage: CGImage - private var cachedRegionImage: CGImage? + private var cachedProcessedImage: CGImage? var boundingRect: CGRect { rect } @@ -30,12 +44,17 @@ final class BlurAnnotation: Annotation { let normalizedRect = rect.standardized.integral guard normalizedRect.width > 1, normalizedRect.height > 1 else { return } - if cachedRegionImage == nil { - cachedRegionImage = makeBlurredRegion(rect: normalizedRect) + if cachedProcessedImage == nil { + let imageBounds = CGRect(x: 0, y: 0, width: sourceImage.width, height: sourceImage.height) + let cropRect = normalizedRect.intersection(imageBounds) + guard cropRect.width > 1, cropRect.height > 1, + let croppedSource = sourceImage.cropping(to: cropRect) else { return } + cachedProcessedImage = Self.processedCroppedImage(for: croppedSource, intensity: intensity, cropRect: cropRect) } - guard let blurredRegion = cachedRegionImage else { return } + guard let processedImage = cachedProcessedImage else { return } - context.draw(blurredRegion, in: normalizedRect) + context.clip(to: normalizedRect) + context.draw(processedImage, in: normalizedRect) } func duplicate() -> any Annotation { @@ -50,40 +69,35 @@ final class BlurAnnotation: Annotation { return copy } - private func makeBlurredRegion(rect: CGRect) -> CGImage? { - let width = max(1, Int(rect.width.rounded())) - let height = max(1, Int(rect.height.rounded())) - - guard let regionContext = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: sourceImage.bitsPerComponent, - bytesPerRow: 0, - space: sourceImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { return nil } - - // Draw source image shifted into local region coordinates. - regionContext.translateBy(x: -rect.minX, y: -rect.minY) - regionContext.draw( - sourceImage, - in: CGRect( - x: 0, - y: 0, - width: sourceImage.width, - height: sourceImage.height - ) - ) - - guard let regionImage = regionContext.makeImage() else { return nil } + private static func processedCroppedImage(for croppedSource: CGImage, intensity: CGFloat, cropRect: CGRect) -> CGImage? { + let key = cacheKey(for: croppedSource, intensity: intensity, cropRect: cropRect) + if let cached = processedImageCache.object(forKey: key) { + return cached.image + } - let ciInput = CIImage(cgImage: regionImage) + let ciInput = CIImage(cgImage: croppedSource) guard let filter = CIFilter(name: "CIGaussianBlur") else { return nil } filter.setValue(ciInput, forKey: kCIInputImageKey) filter.setValue(max(0.5, intensity), forKey: kCIInputRadiusKey) guard let output = filter.outputImage?.cropped(to: ciInput.extent) else { return nil } - return Self.ciContext.createCGImage(output, from: ciInput.extent) + guard let processedImage = Self.ciContext.createCGImage(output, from: ciInput.extent) else { + return nil + } + + let estimatedCost = max(1, croppedSource.width * croppedSource.height * 4) + processedImageCache.setObject(CachedImageBox(processedImage), forKey: key, cost: estimatedCost) + return processedImage + } + + static func clearCache() { + processedImageCache.removeAllObjects() + } + + private static func cacheKey(for sourceImage: CGImage, intensity: CGFloat, cropRect: CGRect) -> NSString { + let pointer = UInt(bitPattern: Unmanaged.passUnretained(sourceImage).toOpaque()) + let normalizedIntensity = Int((max(0.5, intensity) * 10).rounded()) + let r = cropRect + return "\(pointer):\(sourceImage.width)x\(sourceImage.height):\(normalizedIntensity):\(Int(r.minX)),\(Int(r.minY)),\(Int(r.width)),\(Int(r.height))" as NSString } } diff --git a/Snapper/Editor/Tools/Annotations/PixelateAnnotation.swift b/Snapper/Editor/Tools/Annotations/PixelateAnnotation.swift index 462aff5..75af830 100644 --- a/Snapper/Editor/Tools/Annotations/PixelateAnnotation.swift +++ b/Snapper/Editor/Tools/Annotations/PixelateAnnotation.swift @@ -2,7 +2,21 @@ import AppKit import CoreImage final class PixelateAnnotation: Annotation { + private final class CachedImageBox { + let image: CGImage + + init(_ image: CGImage) { + self.image = image + } + } + private static let ciContext = CIContext(options: [.cacheIntermediates: true]) + private static let processedImageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 6 + cache.totalCostLimit = 256 * 1024 * 1024 + return cache + }() let id: UUID let type: ToolType = .pixelate @@ -12,7 +26,7 @@ final class PixelateAnnotation: Annotation { let rect: CGRect let blockSize: CGFloat let sourceImage: CGImage - private var cachedRegionImage: CGImage? + private var cachedProcessedImage: CGImage? var boundingRect: CGRect { rect } @@ -30,12 +44,17 @@ final class PixelateAnnotation: Annotation { let normalizedRect = rect.standardized.integral guard normalizedRect.width > 1, normalizedRect.height > 1 else { return } - if cachedRegionImage == nil { - cachedRegionImage = makePixelatedRegion(rect: normalizedRect) + if cachedProcessedImage == nil { + let imageBounds = CGRect(x: 0, y: 0, width: sourceImage.width, height: sourceImage.height) + let cropRect = normalizedRect.intersection(imageBounds) + guard cropRect.width > 1, cropRect.height > 1, + let croppedSource = sourceImage.cropping(to: cropRect) else { return } + cachedProcessedImage = Self.processedCroppedImage(for: croppedSource, blockSize: blockSize, cropRect: cropRect) } - guard let pixelatedRegion = cachedRegionImage else { return } + guard let processedImage = cachedProcessedImage else { return } - context.draw(pixelatedRegion, in: normalizedRect) + context.clip(to: normalizedRect) + context.draw(processedImage, in: normalizedRect) } func duplicate() -> any Annotation { @@ -50,40 +69,36 @@ final class PixelateAnnotation: Annotation { return copy } - private func makePixelatedRegion(rect: CGRect) -> CGImage? { - let width = max(1, Int(rect.width.rounded())) - let height = max(1, Int(rect.height.rounded())) - - guard let regionContext = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: sourceImage.bitsPerComponent, - bytesPerRow: 0, - space: sourceImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { return nil } - - regionContext.translateBy(x: -rect.minX, y: -rect.minY) - regionContext.draw( - sourceImage, - in: CGRect( - x: 0, - y: 0, - width: sourceImage.width, - height: sourceImage.height - ) - ) - - guard let regionImage = regionContext.makeImage() else { return nil } + private static func processedCroppedImage(for croppedSource: CGImage, blockSize: CGFloat, cropRect: CGRect) -> CGImage? { + let key = cacheKey(for: croppedSource, blockSize: blockSize, cropRect: cropRect) + if let cached = processedImageCache.object(forKey: key) { + return cached.image + } - let ciInput = CIImage(cgImage: regionImage) + let ciInput = CIImage(cgImage: croppedSource) guard let filter = CIFilter(name: "CIPixellate") else { return nil } filter.setValue(ciInput, forKey: kCIInputImageKey) filter.setValue(max(1, blockSize), forKey: kCIInputScaleKey) filter.setValue(CIVector(x: ciInput.extent.midX, y: ciInput.extent.midY), forKey: kCIInputCenterKey) guard let output = filter.outputImage?.cropped(to: ciInput.extent) else { return nil } - return Self.ciContext.createCGImage(output, from: ciInput.extent) + guard let processedImage = Self.ciContext.createCGImage(output, from: ciInput.extent) else { + return nil + } + + let estimatedCost = max(1, croppedSource.width * croppedSource.height * 4) + processedImageCache.setObject(CachedImageBox(processedImage), forKey: key, cost: estimatedCost) + return processedImage + } + + static func clearCache() { + processedImageCache.removeAllObjects() + } + + private static func cacheKey(for sourceImage: CGImage, blockSize: CGFloat, cropRect: CGRect) -> NSString { + let pointer = UInt(bitPattern: Unmanaged.passUnretained(sourceImage).toOpaque()) + let normalizedBlockSize = Int((max(1, blockSize) * 10).rounded()) + let r = cropRect + return "\(pointer):\(sourceImage.width)x\(sourceImage.height):\(normalizedBlockSize):\(Int(r.minX)),\(Int(r.minY)),\(Int(r.width)),\(Int(r.height))" as NSString } } diff --git a/Snapper/Editor/Tools/ToolOptionsView.swift b/Snapper/Editor/Tools/ToolOptionsView.swift index 1ce7dc6..70b3ca7 100644 --- a/Snapper/Editor/Tools/ToolOptionsView.swift +++ b/Snapper/Editor/Tools/ToolOptionsView.swift @@ -7,19 +7,42 @@ struct ToolOptionsView: View { @State private var hoveredTool: ToolType? private let textSizeRange: ClosedRange = 8...240 + private enum AnnotationSelectionMode { + case none + case single(annotation: any Annotation) + case multipleSameType(primary: any Annotation, count: Int) + case multipleMixed(count: Int) + } + var body: some View { HStack(spacing: 16) { switch primaryGroup { case .mouse: - if let selectedAnnotation { - selectedAnnotationEditor(selectedAnnotation) - } else { + switch selectionMode { + case .none: Label( "Mouse mode: select, move, resize, and double-click text to edit.", systemImage: "cursorarrow" ) .font(.caption) .foregroundStyle(.secondary) + + case .single(let annotation): + selectedAnnotationEditor(annotation) + + case .multipleSameType(let primary, let count): + Label("\(count) \(annotationTypeLabel(for: primary)) selected", systemImage: "square.on.square") + .font(.caption) + .foregroundStyle(.secondary) + selectedAnnotationEditor(primary) + + case .multipleMixed(let count): + Label( + "\(count) items selected. Move works for all; property editing works when all selected items share one type.", + systemImage: "square.on.square" + ) + .font(.caption) + .foregroundStyle(.secondary) } case .ocr: @@ -141,7 +164,64 @@ struct ToolOptionsView: View { } private var selectedAnnotation: (any Annotation)? { - canvasState.selectedAnnotation() + guard let selectedAnnotationID = canvasState.selectedAnnotationID else { + return selectedAnnotations.first + } + return canvasState.annotations.first(where: { $0.id == selectedAnnotationID }) + ?? selectedAnnotations.first + } + + private var selectedAnnotations: [any Annotation] { + canvasState.selectedAnnotations() + } + + private var editableSelectedAnnotations: [any Annotation] { + guard let selectedAnnotation else { return [] } + let annotations = selectedAnnotations + guard !annotations.isEmpty else { return [] } + + let selectedType = ObjectIdentifier(type(of: selectedAnnotation)) + guard annotations.allSatisfy({ ObjectIdentifier(type(of: $0)) == selectedType }) else { + return [] + } + + return [selectedAnnotation] + annotations.filter { $0.id != selectedAnnotation.id } + } + + private var selectionMode: AnnotationSelectionMode { + let annotations = selectedAnnotations + guard let selectedAnnotation else { return .none } + + if annotations.count <= 1 { + return .single(annotation: selectedAnnotation) + } + + let selectedType = ObjectIdentifier(type(of: selectedAnnotation)) + let isSameTypeSelection = annotations.allSatisfy { + ObjectIdentifier(type(of: $0)) == selectedType + } + + if isSameTypeSelection { + return .multipleSameType(primary: selectedAnnotation, count: annotations.count) + } + + return .multipleMixed(count: annotations.count) + } + + private func annotationTypeLabel(for annotation: any Annotation) -> String { + if annotation is TextAnnotation { return "text item(s)" } + if annotation is ArrowAnnotation { return "arrow(s)" } + if annotation is RectangleAnnotation { return "rectangle(s)" } + if annotation is EllipseAnnotation { return "ellipse(s)" } + if annotation is LineAnnotation { return "line(s)" } + if annotation is PencilAnnotation { return "pencil stroke(s)" } + if annotation is HighlighterAnnotation { return "highlight(s)" } + if annotation is CounterAnnotation { return "counter(s)" } + if annotation is BlurAnnotation { return "blur area(s)" } + if annotation is PixelateAnnotation { return "pixelate area(s)" } + if annotation is SpotlightAnnotation { return "spotlight area(s)" } + if annotation is CropAnnotation { return "crop area(s)" } + return "item(s)" } @ViewBuilder @@ -781,26 +861,46 @@ struct ToolOptionsView: View { ) -> Binding { Binding( get: { - guard let selectedAnnotation else { return fallback() } + guard let selectedAnnotation = editableSelectedAnnotations.first else { + return fallback() + } return get(selectedAnnotation) ?? fallback() }, set: { newValue in onSet?(newValue) - applyToSelectedAnnotation { annotation in + applyToEditableSelectedAnnotations { annotation in set(annotation, newValue) } } ) } - private func applyToSelectedAnnotation( + private func applyToEditableSelectedAnnotations( _ transform: ((any Annotation) -> (any Annotation)?) ) { - guard let selectedAnnotation else { return } - guard let updated = transform(selectedAnnotation) else { return } - updated.zOrder = selectedAnnotation.zOrder - updated.isVisible = selectedAnnotation.isVisible - canvasState.replaceAnnotation(updated, recordUndo: true) + let annotations = editableSelectedAnnotations + guard !annotations.isEmpty else { return } + + let primaryID = canvasState.selectedAnnotationID + var didApplyUpdate = false + + for source in annotations { + guard let updated = transform(source) else { continue } + updated.zOrder = source.zOrder + updated.isVisible = source.isVisible + canvasState.replaceAnnotation(updated, recordUndo: true) + didApplyUpdate = true + } + + guard didApplyUpdate else { return } + + let selectionIDs = Set(annotations.map { $0.id }) + canvasState.selectedAnnotationIDs = selectionIDs + if let primaryID, selectionIDs.contains(primaryID) { + canvasState.selectedAnnotationID = primaryID + } else { + canvasState.selectedAnnotationID = annotations.first?.id + } } private func makeTextAnnotation( diff --git a/Snapper/History/HistoryBrowserView.swift b/Snapper/History/HistoryBrowserView.swift index dc995a0..e149a27 100644 --- a/Snapper/History/HistoryBrowserView.swift +++ b/Snapper/History/HistoryBrowserView.swift @@ -1,14 +1,17 @@ import SwiftUI import SwiftData +private let historyThumbnailCache = NSCache() + struct HistoryBrowserView: View { let historyManager: HistoryManager @State private var records: [CaptureRecord] = [] @State private var searchText = "" + @State private var debouncedSearchText = "" + @State private var searchDebounceTask: Task? @State private var showGrid = true @State private var selectedFilter: CaptureMode? @State private var selectedRecords: Set = [] - private static let thumbnailCache = NSCache() private let columns = [GridItem(.adaptive(minimum: 200, maximum: 300), spacing: 12)] @@ -27,6 +30,18 @@ struct HistoryBrowserView: View { statusBar } .onAppear(perform: reloadRecords) + .onChange(of: searchText) { _, newValue in + searchDebounceTask?.cancel() + if newValue.isEmpty { + debouncedSearchText = "" + } else { + searchDebounceTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + debouncedSearchText = newValue + } + } + } .onReceive(NotificationCenter.default.publisher(for: .historyDidChange)) { _ in reloadRecords() } @@ -34,11 +49,12 @@ struct HistoryBrowserView: View { private var filteredRecords: [CaptureRecord] { var result = records - if !searchText.isEmpty { + if !debouncedSearchText.isEmpty { + let query = debouncedSearchText result = result.filter { - $0.ocrText?.localizedCaseInsensitiveContains(searchText) == true || - $0.applicationName?.localizedCaseInsensitiveContains(searchText) == true || - $0.captureType.localizedCaseInsensitiveContains(searchText) + $0.ocrText?.localizedCaseInsensitiveContains(query) == true || + $0.applicationName?.localizedCaseInsensitiveContains(query) == true || + $0.captureType.localizedCaseInsensitiveContains(query) } } if let filter = selectedFilter { @@ -132,23 +148,8 @@ struct HistoryBrowserView: View { } private func historyGridItem(_ record: CaptureRecord) -> some View { - let thumbnail = thumbnailImage(for: record.thumbnailPath) return VStack(alignment: .leading, spacing: 4) { - if let image = thumbnail { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } else { - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) - .aspectRatio(16/9, contentMode: .fit) - .overlay { - Image(systemName: "photo") - .font(.title) - .foregroundStyle(.tertiary) - } - } + HistoryThumbnailImageView(path: record.thumbnailPath, cornerRadius: 6) Text(record.captureType.capitalized) .font(.caption) @@ -197,15 +198,9 @@ struct HistoryBrowserView: View { } private func historyListRow(_ record: CaptureRecord) -> some View { - let thumbnail = thumbnailImage(for: record.thumbnailPath) return HStack(spacing: 12) { - if let image = thumbnail { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 80, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } + HistoryThumbnailImageView(path: record.thumbnailPath, cornerRadius: 4) + .frame(width: 80, height: 50) VStack(alignment: .leading) { Text(record.captureType.capitalized) @@ -247,22 +242,69 @@ struct HistoryBrowserView: View { selectedRecords = selectedRecords.intersection(validIDs) } - private func thumbnailImage(for path: String?) -> NSImage? { - guard let path else { return nil } - let key = path as NSString - if let cached = Self.thumbnailCache.object(forKey: key) { - return cached + private func removeThumbnailFromCache(for record: CaptureRecord) { + if let path = record.thumbnailPath { + historyThumbnailCache.removeObject(forKey: path as NSString) } - guard let image = NSImage(contentsOfFile: path) else { - return nil + } +} + +private struct HistoryThumbnailImageView: View { + let path: String? + let cornerRadius: CGFloat + @State private var image: NSImage? + @State private var loadingPath: String? + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.quaternary) + .aspectRatio(16/9, contentMode: .fit) + .overlay { + Image(systemName: "photo") + .font(.title) + .foregroundStyle(.tertiary) + } + } + } + .onAppear { + loadThumbnailIfNeeded() + } + .onChange(of: path) { _, _ in + image = nil + loadingPath = nil + loadThumbnailIfNeeded() } - Self.thumbnailCache.setObject(image, forKey: key) - return image } - private func removeThumbnailFromCache(for record: CaptureRecord) { - if let path = record.thumbnailPath { - Self.thumbnailCache.removeObject(forKey: path as NSString) + private func loadThumbnailIfNeeded() { + guard let path else { + image = nil + return + } + let key = path as NSString + if let cached = historyThumbnailCache.object(forKey: key) { + image = cached + return + } + guard loadingPath != path else { return } + loadingPath = path + + DispatchQueue.global(qos: .utility).async { + let loaded = NSImage(contentsOfFile: path) + if let loaded { + historyThumbnailCache.setObject(loaded, forKey: key) + } + DispatchQueue.main.async { + guard loadingPath == path else { return } + image = loaded + } } } } diff --git a/Snapper/History/HistoryManager.swift b/Snapper/History/HistoryManager.swift index a0265a9..1a06f61 100644 --- a/Snapper/History/HistoryManager.swift +++ b/Snapper/History/HistoryManager.swift @@ -3,6 +3,7 @@ import SwiftData final class HistoryManager { let modelContainer: ModelContainer + private let writeQueue = DispatchQueue(label: "com.snapper.history.write", qos: .utility) init() { let schema = Schema([CaptureRecord.self]) @@ -15,23 +16,44 @@ final class HistoryManager { } } - @MainActor - func saveCapture(result: CaptureResult, savedURL: URL?, thumbnailURL: URL?, recordID: UUID = UUID()) { - let record = CaptureRecord( - id: recordID, - captureType: result.mode.rawValue, - width: result.width, - height: result.height, - filePath: savedURL?.path ?? "", - thumbnailPath: thumbnailURL?.path, - fileSize: savedURL.flatMap { url in + func saveCapture( + result: CaptureResult, + savedURL: URL?, + thumbnailURL: URL?, + recordID: UUID = UUID(), + fileSize: Int? = nil + ) { + let resolvedFileSize: Int + if let fileSize { + resolvedFileSize = max(0, fileSize) + } else { + resolvedFileSize = savedURL.flatMap { url in (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 - } ?? 0, - applicationName: result.applicationName - ) - modelContainer.mainContext.insert(record) - try? modelContainer.mainContext.save() - NotificationCenter.default.post(name: .historyDidChange, object: nil) + } ?? 0 + } + + writeQueue.async { [modelContainer] in + let context = ModelContext(modelContainer) + let record = CaptureRecord( + id: recordID, + captureType: result.mode.rawValue, + width: result.width, + height: result.height, + filePath: savedURL?.path ?? "", + thumbnailPath: thumbnailURL?.path, + fileSize: resolvedFileSize, + applicationName: result.applicationName + ) + context.insert(record) + do { + try context.save() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .historyDidChange, object: nil) + } + } catch { + print("History save failed: \(error)") + } + } } func saveThumbnail(_ image: CGImage, for recordID: UUID) -> URL? { @@ -72,25 +94,46 @@ final class HistoryManager { return (try? modelContainer.mainContext.fetch(descriptor)) ?? [] } - @MainActor func delete(_ record: CaptureRecord) { - delete(record, saveChanges: true) - NotificationCenter.default.post(name: .historyDidChange, object: nil) + delete(recordID: record.id) + } + + func delete(recordID: UUID) { + writeQueue.async { [modelContainer] in + let context = ModelContext(modelContainer) + let predicate = #Predicate { $0.id == recordID } + let descriptor = FetchDescriptor(predicate: predicate) + guard let record = try? context.fetch(descriptor).first else { return } + + Self.removeFiles(for: record) + context.delete(record) + try? context.save() + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .historyDidChange, object: nil) + } + } } - @MainActor func deleteOlderThan(days: Int) { guard days > 0 else { return } let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date())! - let predicate = #Predicate { $0.timestamp < cutoff } - let descriptor = FetchDescriptor(predicate: predicate) - - guard let records = try? modelContainer.mainContext.fetch(descriptor), !records.isEmpty else { return } - for record in records { - delete(record, saveChanges: false) + writeQueue.async { [modelContainer] in + let context = ModelContext(modelContainer) + let predicate = #Predicate { $0.timestamp < cutoff } + let descriptor = FetchDescriptor(predicate: predicate) + guard let records = try? context.fetch(descriptor), !records.isEmpty else { return } + + for record in records { + Self.removeFiles(for: record) + context.delete(record) + } + try? context.save() + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .historyDidChange, object: nil) + } } - try? modelContainer.mainContext.save() - NotificationCenter.default.post(name: .historyDidChange, object: nil) } @MainActor @@ -99,30 +142,31 @@ final class HistoryManager { return records.reduce(0) { $0 + $1.fileSize } } - @MainActor func clearAll() { - let descriptor = FetchDescriptor() - guard let records = try? modelContainer.mainContext.fetch(descriptor), !records.isEmpty else { return } - - for record in records { - delete(record, saveChanges: false) + writeQueue.async { [modelContainer] in + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + guard let records = try? context.fetch(descriptor), !records.isEmpty else { return } + + for record in records { + Self.removeFiles(for: record) + context.delete(record) + } + try? context.save() + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .historyDidChange, object: nil) + } } - try? modelContainer.mainContext.save() - NotificationCenter.default.post(name: .historyDidChange, object: nil) } - @MainActor - private func delete(_ record: CaptureRecord, saveChanges: Bool) { + private static func removeFiles(for record: CaptureRecord) { if !record.filePath.isEmpty { try? FileManager.default.removeItem(atPath: record.filePath) } if let thumbPath = record.thumbnailPath { try? FileManager.default.removeItem(atPath: thumbPath) } - modelContainer.mainContext.delete(record) - if saveChanges { - try? modelContainer.mainContext.save() - } } } diff --git a/Snapper/Hotkeys/HotkeyManager.swift b/Snapper/Hotkeys/HotkeyManager.swift index 3137422..45e0953 100644 --- a/Snapper/Hotkeys/HotkeyManager.swift +++ b/Snapper/Hotkeys/HotkeyManager.swift @@ -14,6 +14,8 @@ final class HotkeyManager { private var fallbackLocalMonitor: Any? private var hasLoggedMissingPermission = false private var hasShownEventTapFailurePrompt = false + private var permissionRetryCount = 0 + private let maxPermissionRetries = 40 private let carbonSignature = OSType(0x534E5052) // "SNPR" var hasActiveGlobalHotkeys: Bool { @@ -24,10 +26,18 @@ final class HotkeyManager { self.appState = appState } + deinit { + stop() + } + func start() { installCarbonHotkeysIfNeeded() installEventTapIfAllowed() - installFallbackMonitorsIfNeeded() + if eventTap == nil { + installFallbackMonitorsIfNeeded() + } else { + removeFallbackMonitors() + } startPermissionRetry() } @@ -44,21 +54,25 @@ final class HotkeyManager { } eventTap = nil runLoopSource = nil - if let fallbackGlobalMonitor { - NSEvent.removeMonitor(fallbackGlobalMonitor) - self.fallbackGlobalMonitor = nil - } - if let fallbackLocalMonitor { - NSEvent.removeMonitor(fallbackLocalMonitor) - self.fallbackLocalMonitor = nil - } + removeFallbackMonitors() unregisterCarbonHotkeys() } private func startPermissionRetry() { guard permissionRetryTimer == nil, eventTap == nil else { return } - permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - self?.installEventTapIfAllowed() + permissionRetryCount = 0 + permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + self.permissionRetryCount += 1 + if self.permissionRetryCount >= self.maxPermissionRetries { + timer.invalidate() + self.permissionRetryTimer = nil + return + } + self.installEventTapIfAllowed() } } @@ -75,6 +89,7 @@ final class HotkeyManager { hasLoggedMissingPermission = false installEventTap() if eventTap != nil { + removeFallbackMonitors() permissionRetryTimer?.invalidate() permissionRetryTimer = nil startHealthCheck() @@ -143,6 +158,17 @@ final class HotkeyManager { } } + private func removeFallbackMonitors() { + if let fallbackGlobalMonitor { + NSEvent.removeMonitor(fallbackGlobalMonitor) + self.fallbackGlobalMonitor = nil + } + if let fallbackLocalMonitor { + NSEvent.removeMonitor(fallbackLocalMonitor) + self.fallbackLocalMonitor = nil + } + } + private func installCarbonHotkeysIfNeeded() { guard carbonHandlerRef == nil else { return } @@ -248,10 +274,23 @@ final class HotkeyManager { return } + let keyCode = Int(event.keyCode) + let hasCommand = event.modifierFlags.contains(.command) + let hasShift = event.modifierFlags.contains(.shift) + + if isCarbonHandledHotkey(keyCode: keyCode, hasCommand: hasCommand, hasShift: hasShift) { + return + } + + if isHUDHotkey(keyCode: keyCode, hasCommand: hasCommand, hasShift: hasShift) { + postShowAllInOneHUD() + return + } + let action = actionForHotkey( - keyCode: Int(event.keyCode), - hasCommand: event.modifierFlags.contains(.command), - hasShift: event.modifierFlags.contains(.shift) + keyCode: keyCode, + hasCommand: hasCommand, + hasShift: hasShift ) guard let action else { return } postAction(action) @@ -272,10 +311,34 @@ final class HotkeyManager { } } + private func isCarbonHandledHotkey(keyCode: Int, hasCommand: Bool, hasShift: Bool) -> Bool { + guard !carbonHotKeyRefs.isEmpty else { return false } + return hasCommand && hasShift && (keyCode == kVK_ANSI_3 || keyCode == kVK_ANSI_4) + } + + private func isHUDHotkey(keyCode: Int, hasCommand: Bool, hasShift: Bool) -> Bool { + hasCommand && hasShift && keyCode == kVK_ANSI_5 + } + + private func postShowAllInOneHUD() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .showAllInOneHUD, object: nil) + } + } + fileprivate func handleKeyEvent(_ event: CGEvent) -> CGEvent? { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags + if isHUDHotkey( + keyCode: Int(keyCode), + hasCommand: flags.contains(.maskCommand), + hasShift: flags.contains(.maskShift) + ) { + postShowAllInOneHUD() + return nil + } + let action = actionForHotkey( keyCode: Int(keyCode), hasCommand: flags.contains(.maskCommand), diff --git a/Snapper/MenuBar/MenuBarMenu.swift b/Snapper/MenuBar/MenuBarMenu.swift index 6462956..65c9850 100644 --- a/Snapper/MenuBar/MenuBarMenu.swift +++ b/Snapper/MenuBar/MenuBarMenu.swift @@ -22,6 +22,7 @@ final class MenuBarMenu: NSMenu { addItem(makeItem("Capture Fullscreen", action: #selector(captureFullscreen), key: "3", modifiers: [.command, .shift])) addItem(makeItem("Capture Area", action: #selector(captureArea), key: "4", modifiers: [.command, .shift])) + addItem(makeItem("All-in-One HUD", action: #selector(showAllInOneHUD), key: "5", modifiers: [.command, .shift])) addItem(makeItem("Capture Window", action: #selector(captureWindow), key: "", modifiers: [])) addItem(NSMenuItem.separator()) @@ -68,6 +69,10 @@ final class MenuBarMenu: NSMenu { NotificationCenter.default.post(name: .startCapture, object: CaptureMode.window) } + @objc private func showAllInOneHUD() { + NotificationCenter.default.post(name: .showAllInOneHUD, object: nil) + } + @objc private func timerCapture() { NotificationCenter.default.post(name: .startCapture, object: CaptureMode.timer) } diff --git a/Snapper/Overlay/QuickAccessOverlay/CaptureThumbnailView.swift b/Snapper/Overlay/QuickAccessOverlay/CaptureThumbnailView.swift index 2f89531..f646120 100644 --- a/Snapper/Overlay/QuickAccessOverlay/CaptureThumbnailView.swift +++ b/Snapper/Overlay/QuickAccessOverlay/CaptureThumbnailView.swift @@ -23,8 +23,7 @@ struct CaptureThumbnailView: View { .shadow(color: Color.black.opacity(0.25), radius: 6, y: 2) .contentShape(RoundedRectangle(cornerRadius: 10)) .onTapGesture { - NotificationCenter.default.post(name: .openEditor, object: ImageWrapper(capture.image)) - manager.removeCapture(capture.id) + openInEditorAndDismiss() } if isHovering { @@ -59,22 +58,30 @@ struct CaptureThumbnailView: View { private var actionButtons: some View { HStack(spacing: 4) { OverlayIconButton(icon: "doc.on.doc", tooltip: "Copy") { - PasteboardHelper.copyImage(capture.image) + if let image = capture.resolvedImage() { + PasteboardHelper.copyImage(image) + } } OverlayIconButton(icon: "square.and.arrow.down", tooltip: "Reveal File") { if let url = capture.savedURL { NSWorkspace.shared.activateFileViewerSelecting([url]) } } + OverlayIconButton(icon: "pin", tooltip: "Pin") { + if let image = capture.resolvedImage() { + NotificationCenter.default.post(name: .pinScreenshot, object: ImageWrapper(image)) + manager.removeCapture(capture.id) + } + } OverlayIconButton(icon: "pencil", tooltip: "Edit") { - NotificationCenter.default.post(name: .openEditor, object: ImageWrapper(capture.image)) - manager.removeCapture(capture.id) + openInEditorAndDismiss() } OverlayIconButton(icon: "trash", tooltip: "Delete") { manager.removeCapture(capture.id) if let url = capture.savedURL { try? FileManager.default.removeItem(at: url) } + NotificationCenter.default.post(name: .deleteHistoryRecord, object: capture.recordID) } } .padding(4) @@ -89,9 +96,12 @@ struct CaptureThumbnailView: View { return NSItemProvider(object: savedURL as NSURL) } + guard let image = capture.resolvedImage() else { + return NSItemProvider() + } let nsImage = NSImage( - cgImage: capture.image, - size: NSSize(width: capture.image.width, height: capture.image.height) + cgImage: image, + size: NSSize(width: image.width, height: image.height) ) let provider = NSItemProvider(object: nsImage) provider.suggestedName = "Snapper Screenshot" @@ -111,6 +121,12 @@ struct CaptureThumbnailView: View { return provider } + private func openInEditorAndDismiss() { + guard let image = capture.resolvedImage() else { return } + NotificationCenter.default.post(name: .openEditor, object: ImageWrapper(image)) + manager.removeCapture(capture.id) + } + } private struct OverlayIconButton: View { diff --git a/Snapper/Overlay/QuickAccessOverlay/QuickAccessManager.swift b/Snapper/Overlay/QuickAccessOverlay/QuickAccessManager.swift index 35634ec..bfa54a2 100644 --- a/Snapper/Overlay/QuickAccessOverlay/QuickAccessManager.swift +++ b/Snapper/Overlay/QuickAccessOverlay/QuickAccessManager.swift @@ -14,6 +14,7 @@ final class QuickAccessManager { private let thumbnailVerticalInset: CGFloat = 4 private let panelMaxHeight: CGFloat = 560 private let panelMinHeight: CGFloat = 120 + private let maxCaptures = 20 init(appState: AppState) { self.appState = appState @@ -60,20 +61,24 @@ final class QuickAccessManager { } func addCapture(_ info: CaptureCompletedInfo) { - let thumbnail = ImageUtils.generateThumbnail(info.result.image) + let thumbnail = info.thumbnail ?? info.result.image let capture = QuickAccessCapture( id: UUID(), - image: info.result.image, - thumbnail: thumbnail ?? info.result.image, + recordID: info.recordID, + inMemoryImage: info.savedURL == nil ? info.result.image : nil, + thumbnail: thumbnail, mode: info.result.mode, timestamp: info.result.timestamp, savedURL: info.savedURL, width: info.result.width, height: info.result.height, - fileSize: info.savedURL.flatMap { try? FileManager.default.attributesOfItem(atPath: $0.path)[.size] as? Int } ?? 0 + fileSize: info.fileSize ) withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.86, blendDuration: 0.12)) { captures.insert(capture, at: 0) + if captures.count > maxCaptures { + captures.removeLast(captures.count - maxCaptures) + } } showPanel() } @@ -118,6 +123,7 @@ final class QuickAccessManager { private func hidePanel() { panel?.orderOut(nil) + panel = nil } private func updatePanelLayout(animated: Bool) { @@ -186,7 +192,8 @@ final class QuickAccessManager { struct QuickAccessCapture: Identifiable { let id: UUID - let image: CGImage + let recordID: UUID + let inMemoryImage: CGImage? let thumbnail: CGImage let mode: CaptureMode let timestamp: Date @@ -198,4 +205,26 @@ struct QuickAccessCapture: Identifiable { var formattedFileSize: String { ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) } + + func resolvedImage() -> CGImage? { + if let inMemoryImage { + return inMemoryImage + } + + guard let savedURL else { return nil } + let cacheKey = savedURL.path as NSString + if let cachedImage = Self.loadedImageCache.object(forKey: cacheKey), + let cachedCG = cachedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) { + return cachedCG + } + + guard let nsImage = NSImage(contentsOf: savedURL), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + Self.loadedImageCache.setObject(nsImage, forKey: cacheKey) + return cgImage + } + + private static let loadedImageCache = NSCache() } diff --git a/Snapper/Settings/CaptureSettingsView.swift b/Snapper/Settings/CaptureSettingsView.swift index 46664ea..b0f6b8a 100644 --- a/Snapper/Settings/CaptureSettingsView.swift +++ b/Snapper/Settings/CaptureSettingsView.swift @@ -50,6 +50,7 @@ struct CaptureSettingsView: View { Toggle("Show magnifier", isOn: $appState.showMagnifier) Toggle("Freeze screen during selection", isOn: $appState.freezeScreen) Toggle("Save at Retina resolution (2x)", isOn: $appState.retina2x) + Toggle("Include window shadows", isOn: $appState.windowCaptureIncludeShadow) } } .formStyle(.grouped) diff --git a/Snapper/Settings/HistorySettingsView.swift b/Snapper/Settings/HistorySettingsView.swift index 8e94a0e..1117805 100644 --- a/Snapper/Settings/HistorySettingsView.swift +++ b/Snapper/Settings/HistorySettingsView.swift @@ -57,8 +57,8 @@ struct HistorySettingsView: View { } private func calculateStorageSize() { - DispatchQueue.global().async { - let size = directorySize(at: Constants.App.historyDirectory) + DispatchQueue.global(qos: .utility).async { + let size = StorageSizeCalculator.directorySize(at: Constants.App.historyDirectory) let formatted = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) DispatchQueue.main.async { storageSize = formatted @@ -66,17 +66,6 @@ struct HistorySettingsView: View { } } - private func directorySize(at url: URL) -> Int { - let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) - var total = 0 - while let fileURL = enumerator?.nextObject() as? URL { - if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - total += size - } - } - return total - } - private func clearHistory() { NotificationCenter.default.post(name: .clearHistoryRequested, object: nil) calculateStorageSize() diff --git a/Snapper/Settings/SettingsView.swift b/Snapper/Settings/SettingsView.swift index 176c131..62e4a75 100644 --- a/Snapper/Settings/SettingsView.swift +++ b/Snapper/Settings/SettingsView.swift @@ -297,6 +297,12 @@ struct SettingsView: View { subtitle: "Save captures at native Retina resolution.", isOn: appState.retina2x ) + + ToggleRow( + title: "Include Window Shadows", + subtitle: "Keep drop shadows when capturing windows.", + isOn: appState.windowCaptureIncludeShadow + ) } } @@ -315,6 +321,22 @@ struct SettingsView: View { Text("Preview thumbnails stay visible until you dismiss them.") .font(.caption) .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Text("Pinned Opacity") + Spacer() + Slider(value: appState.defaultPinnedOpacity, in: 0.1...1.0) + .frame(width: 180) + Text("\(Int(appState.defaultPinnedOpacity.wrappedValue * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 42, alignment: .trailing) + } + Text("Applies to newly pinned screenshots and can update existing ones.") + .font(.caption) + .foregroundStyle(.secondary) + } } } @@ -464,8 +486,8 @@ struct SettingsView: View { } private func calculateStorageSize() { - DispatchQueue.global().async { - let size = directorySize(at: Constants.App.historyDirectory) + DispatchQueue.global(qos: .utility).async { + let size = StorageSizeCalculator.directorySize(at: Constants.App.historyDirectory) let formatted = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) DispatchQueue.main.async { storageSize = formatted @@ -473,17 +495,6 @@ struct SettingsView: View { } } - private func directorySize(at url: URL) -> Int { - let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) - var total = 0 - while let fileURL = enumerator?.nextObject() as? URL { - if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - total += size - } - } - return total - } - private func clearHistory() { NotificationCenter.default.post(name: .clearHistoryRequested, object: nil) calculateStorageSize() diff --git a/Snapper/Updates/UpdateManager.swift b/Snapper/Updates/UpdateManager.swift index a133971..70fecd7 100644 --- a/Snapper/Updates/UpdateManager.swift +++ b/Snapper/Updates/UpdateManager.swift @@ -3,9 +3,10 @@ import Foundation final class UpdateManager { private let releasesURL = URL(string: "https://github.com/nickshatilo/snapper/releases") + private var observerToken: NSObjectProtocol? init() { - NotificationCenter.default.addObserver( + observerToken = NotificationCenter.default.addObserver( forName: .checkForUpdates, object: nil, queue: .main @@ -14,6 +15,12 @@ final class UpdateManager { } } + deinit { + if let observerToken { + NotificationCenter.default.removeObserver(observerToken) + } + } + func checkForUpdates() { guard let releasesURL else { return } NSWorkspace.shared.open(releasesURL) diff --git a/Snapper/Utilities/DesktopIconsHelper.swift b/Snapper/Utilities/DesktopIconsHelper.swift index bcbd8e9..40d7144 100644 --- a/Snapper/Utilities/DesktopIconsHelper.swift +++ b/Snapper/Utilities/DesktopIconsHelper.swift @@ -2,11 +2,13 @@ import Foundation enum DesktopIconsHelper { private static let finderBundleID = "com.apple.finder" - private static let plistPath = NSHomeDirectory() + "/Library/Preferences/com.apple.finder.plist" + private static let workerQueue = DispatchQueue(label: "com.snapper.desktopicons", qos: .utility) static var areIconsVisible: Bool { - let defaults = UserDefaults(suiteName: finderBundleID) - return defaults?.bool(forKey: "CreateDesktop") ?? true + guard let defaults = UserDefaults(suiteName: finderBundleID) else { + return true + } + return defaults.object(forKey: "CreateDesktop") as? Bool ?? true } static func toggle() { @@ -14,22 +16,37 @@ enum DesktopIconsHelper { setDesktopIcons(visible: !current) } - static func setDesktopIcons(visible: Bool) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") - process.arguments = ["write", finderBundleID, "CreateDesktop", "-bool", visible ? "true" : "false"] + static func setDesktopIcons(visible: Bool, completion: ((Bool) -> Void)? = nil) { + workerQueue.async { + if areIconsVisible == visible { + DispatchQueue.main.async { + completion?(true) + } + return + } - try? process.run() - process.waitUntilExit() + let defaults = UserDefaults(suiteName: finderBundleID) + defaults?.set(visible, forKey: "CreateDesktop") + CFPreferencesAppSynchronize(finderBundleID as CFString) + let didRestartFinder = restartFinder() - restartFinder() + DispatchQueue.main.async { + completion?(didRestartFinder) + } + } } - private static func restartFinder() { + @discardableResult + private static func restartFinder() -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") process.arguments = ["Finder"] - try? process.run() + do { + try process.run() + } catch { + return false + } process.waitUntilExit() + return process.terminationStatus == 0 } } diff --git a/Snapper/Utilities/FileNameGenerator.swift b/Snapper/Utilities/FileNameGenerator.swift index bfaf813..020ca01 100644 --- a/Snapper/Utilities/FileNameGenerator.swift +++ b/Snapper/Utilities/FileNameGenerator.swift @@ -1,27 +1,41 @@ import Foundation enum FileNameGenerator { + private static let lock = NSLock() private static var counter: Int = { UserDefaults.standard.integer(forKey: "fileNameCounter") }() + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH.mm.ss" + return formatter + }() static func generate(pattern: String, mode: CaptureMode, appName: String? = nil) -> String { let now = Date() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - let dateStr = dateFormatter.string(from: now) - - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "HH.mm.ss" - let timeStr = timeFormatter.string(from: now) + let dateStr: String + let timeStr: String + let currentCounter: Int + lock.lock() + defer { lock.unlock() } + dateStr = dateFormatter.string(from: now) + timeStr = timeFormatter.string(from: now) counter += 1 - UserDefaults.standard.set(counter, forKey: "fileNameCounter") + currentCounter = counter + UserDefaults.standard.set(currentCounter, forKey: "fileNameCounter") var result = pattern result = result.replacingOccurrences(of: "{date}", with: dateStr) result = result.replacingOccurrences(of: "{time}", with: timeStr) - result = result.replacingOccurrences(of: "{counter}", with: String(format: "%04d", counter)) + result = result.replacingOccurrences(of: "{counter}", with: String(format: "%04d", currentCounter)) result = result.replacingOccurrences(of: "{app}", with: appName ?? "Unknown") result = result.replacingOccurrences(of: "{type}", with: mode.displayName) diff --git a/Snapper/Utilities/StorageSizeCalculator.swift b/Snapper/Utilities/StorageSizeCalculator.swift new file mode 100644 index 0000000..282be7e --- /dev/null +++ b/Snapper/Utilities/StorageSizeCalculator.swift @@ -0,0 +1,14 @@ +import Foundation + +enum StorageSizeCalculator { + static func directorySize(at url: URL) -> Int { + let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) + var total = 0 + while let fileURL = enumerator?.nextObject() as? URL { + if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + total += size + } + } + return total + } +}