Files
shotscreen/ShotScreen/Sources/GridActionManager.swift
Nick Roodenrijs 0dabed11d2 🎉 ShotScreen v1.0 - Initial Release
🚀 First official release of ShotScreen with complete feature set:

 Core Features:
- Advanced screenshot capture system
- Multi-monitor support
- Professional UI/UX design
- Automated update system with Sparkle
- Apple notarized & code signed

🛠 Technical Excellence:
- Native Swift macOS application
- Professional build & deployment pipeline
- Comprehensive error handling
- Memory optimized performance

📦 Distribution Ready:
- Professional DMG packaging
- Apple notarization complete
- No security warnings for users
- Ready for public distribution

This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
2025-06-28 16:15:15 +02:00

443 lines
20 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Vision
import SwiftUI
// MARK: - Grid Action Manager
protocol GridActionManagerDelegate: AnyObject {
func showOrUpdateStash(with imageURL: URL)
func closePreviewWithAnimation(immediate: Bool)
func ensurePreviewVisible()
func getLastImage() -> NSImage?
func getTempURL() -> URL?
func getActivePreviewWindow() -> NSWindow?
func getRenameActionHandler() -> RenameActionHandler
func flashPreviewBorder()
func disableMonitoring()
func enableMonitoring()
func gridViewManagerHideGrid(monitorForReappear: Bool)
func getGridCurrentFrame() -> NSRect?
// 🎨 NEW: Background removal thumbnail workflow with original URL
func showBackgroundRemovalThumbnail(with image: NSImage, originalURL: URL)
}
class GridActionManager {
weak var delegate: GridActionManagerDelegate?
init(delegate: GridActionManagerDelegate) {
self.delegate = delegate
}
// MARK: - Grid Action Handler
func handleGridAction(imageURL: URL, cellIndex: Int, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("✅ GridActionManager: Handling action for cell \(cellIndex)")
// Get the action type based on the dynamic action order
let actionOrder = SettingsManager.shared.actionOrder
guard cellIndex < actionOrder.count else {
print(" Cell index \(cellIndex) out of range for action order.")
completion(false, false, false)
return
}
let actionType = actionOrder[cellIndex]
switch actionType {
case .rename:
print("📝 Rename action triggered for \(imageURL.path)")
handleRenameAction(imageURL: imageURL, completion: completion)
case .stash:
print("✨ Stash action triggered for \(imageURL.path)")
handleStashAction(imageURL: imageURL, completion: completion)
case .ocr:
print("📑 OCR action triggered for \(imageURL.path)")
handleOCRAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .clipboard:
print("📋 Clipboard action triggered for \(imageURL.path)")
handleClipboardAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .backgroundRemove:
print("🎨 Background Remove action triggered for \(imageURL.path)")
handleBackgroundRemoveAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .cancel:
print("🚫 Cancel action triggered")
handleCancelAction(completion: completion)
case .remove:
print("🗑 Remove action triggered")
handleRemoveAction(completion: completion)
}
}
// MARK: - Individual Action Handlers
private func handleRenameAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("🔍 DEBUG: handleRenameAction called with URL: \(imageURL)")
print("🔍 DEBUG: File exists at URL: \(FileManager.default.fileExists(atPath: imageURL.path))")
delegate?.getRenameActionHandler().promptAndRename(originalURL: imageURL) { responseForRename in
let successfulAction = (responseForRename != .alertThirdButtonReturn)
let saveToFolderTriggered = (responseForRename == .alertSecondButtonReturn)
print("🔍 DEBUG: Rename response: \(responseForRename), successful: \(successfulAction)")
completion(successfulAction, saveToFolderTriggered, false)
}
}
private func handleStashAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("✨ Stash action triggered for \(imageURL.path)")
// CRITICALLY IMPORTANT: Close main grid immediately after stash action
print("🔶 STASH ACTION: Closing main grid and disabling proximity monitoring")
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
delegate?.showOrUpdateStash(with: imageURL)
completion(true, false, true) // Pass true for isStashAction
}
private func handleOCRAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
delegate?.disableMonitoring()
performOcrAndCopy(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) { success in
// Completion for performOcrAndCopy is now async after panel closes
// The actual success of OCR (text found vs. no text) is handled by the message in the panel.
// For the grid action itself, we consider it 'successful' if the process ran.
self.delegate?.enableMonitoring()
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
completion(true, false, false) // OCR action always 'succeeds' in terms of grid interaction
}
}
private func handleClipboardAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
delegate?.disableMonitoring()
copyImageToClipboard(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) {
self.delegate?.enableMonitoring()
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
completion(true, false, false)
}
}
private func handleBackgroundRemoveAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
performBackgroundRemove(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow)
completion(true, false, false)
}
private func handleCancelAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
// Handle cancel action through delegate callback mechanism
NotificationCenter.default.post(name: .gridActionCancelRequested, object: nil)
completion(false, false, false)
}
private func handleRemoveAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
// Handle remove action through delegate callback mechanism
NotificationCenter.default.post(name: .gridActionRemoveRequested, object: nil)
completion(false, false, false)
}
// MARK: - OCR Implementation
private func performOcrAndCopy(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool) -> Void) {
// Intel Mac compatible Vision framework OCR
guard #available(macOS 10.15, *) else {
print("❌ OCR requires macOS 10.15 or later")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "OCR requires macOS 10.15+", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
guard let nsImage = NSImage(contentsOf: imageURL) else {
print("❌ OCR: Could not load image from URL: \(imageURL.path)")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("❌ OCR: Could not obtain CGImage")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Could not get image data for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
let request = VNRecognizeTextRequest { [weak self] request, error in
guard let self = self else { completion(false); return }
var message: String
var ocrDidFindText = false
if let err = error {
print("❌ OCR error: \(err.localizedDescription)")
message = "OCR error: \(err.localizedDescription)"
} else {
let observations = request.results as? [VNRecognizedTextObservation] ?? []
let recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n")
if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
message = "No text found"
} else {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(recognizedText, forType: .string)
message = "Text copied to clipboard!"
ocrDidFindText = true
}
}
DispatchQueue.main.async {
if let gridFrame = self.delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...")
panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(ocrDidFindText)
})
} else {
completion(ocrDidFindText) // Call completion if gridFrame is nil
}
}
}
request.recognitionLevel = VNRequestTextRecognitionLevel.accurate
request.usesLanguageCorrection = true
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch {
print("❌ OCR: Failed to perform request \(error.localizedDescription)")
DispatchQueue.main.async {
self.showToast(message: "OCR failed", near: dropPoint, gridWindow: gridWindow)
}
}
}
}
// MARK: - Clipboard Implementation
private func copyImageToClipboard(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping () -> Void) {
guard let nsImage = NSImage(contentsOf: imageURL) else {
print("❌ Clipboard: Could not load image from URL: \(imageURL.path)")
// Consider showing error with FeedbackBubblePanel if gridFrame is available
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0)
panel.closeWithAnimation { completion() }
} else {
completion()
}
return
}
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([nsImage])
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Copied!")
panel.show(aroundGridFrame: gridFrame, text: "Copied to clipboard!", autoCloseAfter: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
panel.closeWithAnimation {
completion()
}
}
} else {
print("⚠️ Could not get grid frame to show feedback bubble for clipboard.")
completion()
}
}
// MARK: - Background Remove Implementation
private func performBackgroundRemove(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) {
print("🎨 Background Remove action triggered for \(imageURL.path)")
// 🔄 ENHANCED: Better error handling and recovery for image loading
var finalImageURL = imageURL
var originalImage: NSImage? = nil
// First attempt: Direct load
originalImage = NSImage(contentsOf: finalImageURL)
// If failed, try recovery strategies
if originalImage == nil {
print("⚠️ Initial image load failed, attempting recovery...")
// Strategy 1: Check if file exists but has load issues
if FileManager.default.fileExists(atPath: imageURL.path) {
print("🔍 File exists but NSImage can't load it, trying alternative loading...")
// Try loading with different methods
if let imageData = try? Data(contentsOf: imageURL) {
originalImage = NSImage(data: imageData)
if originalImage != nil {
print("✅ RECOVERED: Successfully loaded image via Data method")
}
}
}
// Strategy 2: Check temp directory for file
if originalImage == nil {
let tempDir = FileManager.default.temporaryDirectory
let fileName = imageURL.lastPathComponent
let tempURL = tempDir.appendingPathComponent(fileName)
if FileManager.default.fileExists(atPath: tempURL.path) {
originalImage = NSImage(contentsOf: tempURL)
if originalImage != nil {
finalImageURL = tempURL
print("✅ RECOVERED: Found image in temp directory: \(tempURL.path)")
}
}
}
// Strategy 3: Check if it's a cache file issue and try alternative cache locations
if originalImage == nil {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let shotScreenDir = supportDir.appendingPathComponent("ShotScreen/Thumbnails")
let cacheName = imageURL.lastPathComponent
let cacheURL = shotScreenDir.appendingPathComponent(cacheName)
if FileManager.default.fileExists(atPath: cacheURL.path) {
originalImage = NSImage(contentsOf: cacheURL)
if originalImage != nil {
finalImageURL = cacheURL
print("✅ RECOVERED: Found image in cache: \(cacheURL.path)")
}
}
}
}
// Final check
guard let finalImage = originalImage else {
print("❌ Could not load image for background removal after all recovery attempts")
print("📍 Attempted URLs:")
print(" - Original: \(imageURL.path)")
print(" - File exists: \(FileManager.default.fileExists(atPath: imageURL.path))")
showToast(message: "Image load failed", near: dropPoint, gridWindow: gridWindow)
return
}
print("✅ Successfully loaded image for BGR: \(finalImageURL.lastPathComponent)")
print("🎨 Background Remove thumbnail UI starting...")
// 🎯 NEW: Use thumbnail-based BGR workflow via delegate with recovered URL
delegate?.showBackgroundRemovalThumbnail(with: finalImage, originalURL: finalImageURL)
}
// MARK: - Toast Notification System
private func showToast(message: String, near point: NSPoint, gridWindow: NSWindow?) {
let toastWidth: CGFloat = 180
let toastHeight: CGFloat = 40
// Kies vaste positie links (anders rechts) naast de grid
let margin: CGFloat = 5
var screenPoint: NSPoint
if let gw = gridWindow {
let gridFrame = gw.frame
let screenVisible = gw.screen?.visibleFrame ?? NSScreen.main!.visibleFrame
var x = gridFrame.minX - toastWidth - margin // links van grid
if x < screenVisible.minX + margin { // niet genoeg ruimte links rechts
x = gridFrame.maxX + margin
}
let y = gridFrame.midY - toastHeight / 2
screenPoint = NSPoint(x: x, y: y)
} else {
// Fallback: gebruik muislocatie zoals voorheen
screenPoint = NSEvent.mouseLocation
screenPoint.x -= toastWidth / 2
screenPoint.y += 20
}
let toastWindow = NSWindow(contentRect: NSRect(x: screenPoint.x, y: screenPoint.y, width: toastWidth, height: toastHeight),
styleMask: [.borderless], backing: .buffered, defer: false)
toastWindow.isOpaque = false
toastWindow.backgroundColor = .clear
toastWindow.level = .floating + 5
toastWindow.hasShadow = true
// Opbouw content
let container = NSView(frame: NSRect(x: 0, y: 0, width: toastWidth, height: toastHeight))
let blur = NSVisualEffectView(frame: container.bounds)
blur.blendingMode = .behindWindow
blur.material = .hudWindow
blur.state = .active
blur.alphaValue = 0.9
blur.wantsLayer = true
blur.layer?.cornerRadius = 12
blur.layer?.masksToBounds = true
let label = NSTextField(labelWithString: message)
label.textColor = .white
label.alignment = .center
label.font = .systemFont(ofSize: 13, weight: .medium)
label.frame = NSRect(x: 0, y: (toastHeight - 20) / 2, width: toastWidth, height: 20)
container.addSubview(blur)
container.addSubview(label)
toastWindow.contentView = container
toastWindow.alphaValue = 0
toastWindow.makeKeyAndOrderFront(nil as Any?)
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
toastWindow.animator().alphaValue = 1
}, completionHandler: {
let flyUp: CGFloat = 30
let targetOrigin = NSPoint(x: toastWindow.frame.origin.x, y: toastWindow.frame.origin.y + flyUp)
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 2
toastWindow.animator().alphaValue = 0
toastWindow.animator().setFrameOrigin(targetOrigin)
}, completionHandler: {
toastWindow.orderOut(nil as Any?)
})
})
}
// MARK: - Action Completion Handler
func handleActionCompletion(actionCompletedSuccessfully: Bool, wasSaveToFolder: Bool, isStashAction: Bool) {
print(" Action panel completion: Success - \(actionCompletedSuccessfully), WasSaveToFolder - \(wasSaveToFolder), IsStashAction - \(isStashAction)")
if actionCompletedSuccessfully {
if wasSaveToFolder {
// Save to folder actions handle their own preview closing via closeAfterSave setting.
print(" Save to folder action: Preview management handled by save logic.")
} else if isStashAction {
// CRITICAL: Ensure main grid stays closed after stash action
print("🔶 STASH ACTION COMPLETION: Ensuring main grid stays closed - no proximity monitoring")
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
// FIXED: For stash action, preview is already properly closed in main.swift _showOrUpdateStash
// No need to close again here or check tempURL - it's already moved to stash directory
print(" Stash action: Preview management handled by stash logic in main.swift")
} else {
if delegate?.getTempURL() != nil {
print(" Other successful grid action (not save/stash): Ensuring preview is visible.")
delegate?.ensurePreviewVisible()
}
}
} else {
if delegate?.getTempURL() != nil {
print(" Action not successful or cancelled: Ensuring preview is visible.")
delegate?.ensurePreviewVisible()
}
}
}
}
// MARK: - Notification Names
extension Notification.Name {
static let gridActionCancelRequested = Notification.Name("gridActionCancelRequested")
static let gridActionRemoveRequested = Notification.Name("gridActionRemoveRequested")
}