Files
shotscreen/ShotScreen/Sources/PreviewManager.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

1804 lines
77 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 SwiftUI
// MARK: - Button Hover Handler (glass-effect safe)
class ButtonHoverHandler: NSObject {
weak var button: NSButton?
private let zoomScale: CGFloat
init(button: NSButton, zoomScale: CGFloat = 1.3) {
self.button = button
self.zoomScale = zoomScale
super.init()
}
func mouseEntered(with event: NSEvent) {
guard let button = button else { return }
print("🎯 HOVER: Mouse entered button - starting zoom (\(zoomScale)x) and color effect")
let hoverColor = ThemeManager.shared.buttonHoverColor // Adaptive hover color
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
context.allowsImplicitAnimation = true
// Color change
button.animator().contentTintColor = hoverColor
})
// Zoom effect using layer transform (separate from color animation)
if let layer = button.layer {
CATransaction.begin()
CATransaction.setAnimationDuration(0.2)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
let scaleTransform = CATransform3DMakeScale(zoomScale, zoomScale, 1.0)
layer.transform = scaleTransform
CATransaction.commit()
print("🎯 HOVER: Applied \(zoomScale)x zoom scale")
}
}
func mouseExited(with event: NSEvent) {
guard let button = button else { return }
print("🎯 HOVER: Mouse exited button - restoring original size and color")
// Get original color
let originalColor = objc_getAssociatedObject(button, "originalColor") as? NSColor ?? ThemeManager.shared.buttonOriginalColor
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
context.allowsImplicitAnimation = true
// Restore original color
button.animator().contentTintColor = originalColor
})
// Restore original size (separate from color animation)
if let layer = button.layer {
CATransaction.begin()
CATransaction.setAnimationDuration(0.25)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
layer.transform = CATransform3DIdentity
CATransaction.commit()
print("🎯 HOVER: Restored original scale")
}
}
}
// MARK: - Preview Window Management
class PreviewManager: NSObject {
weak var delegate: PreviewManagerDelegate?
// MARK: - Properties
var activePreviewWindow: NSWindow?
var currentImageView: DraggableImageView?
var previewDismissTimer: Timer?
var isClosing = false
var isPreviewUpdating = false
// NIEUW: Loading indicator properties
var isShowingLoadingIndicator = false
var loadingOverlayWindow: NSWindow?
// 🎨 NEW: Background Removal Mode Properties
var isBackgroundRemovalMode = false
var isBGRTransition = false // 🔄 NEW: Flag to prevent BGR reset during BGR actions
var originalImage: NSImage?
var processedImage: NSImage?
var bgrResetButton: BGROverlayButton?
var bgrToggleButton: BGROverlayButton?
// 🎨 NEW: BGR File Management Properties
var originalImageURL: URL? // File URL for original image
var processedImageURL: URL? // File URL for processed image
var isShowingProcessedImage = false // Track which image is currently displayed
// 🎨 NEW: BGR Progress Bar Properties
var bgrProgressContainer: NSView?
var bgrProgressBar: NSProgressIndicator?
var bgrProgressLabel: NSTextField?
// MARK: - 🎛 TOOLBAR BUTTON AANPASSINGEN
// 📏 BUTTON GROOTTES
let closeButtonSize: CGFloat = 9 // 🔴 Close button (X) - Klein
let saveButtonSize: CGFloat = 16 // 💾 Save button (Map+Plus)
let folderButtonSize: CGFloat = 16 // 📁 Folder button (Map)
let settingsButtonSize: CGFloat = 14 // Settings button (Tandwiel)
// 📍 BUTTON VERTICALE POSITIES (offset van center)
let closeButtonVerticalOffset: CGFloat = -0.5 // 🔴 Close button verticaal
let saveButtonVerticalOffset: CGFloat = 0 // 💾 Save button verticaal
let folderButtonVerticalOffset: CGFloat = -0.5 // 📁 Folder button verticaal
let settingsButtonVerticalOffset: CGFloat = 0 // Settings button verticaal
// BUTTON HORIZONTALE POSITIES (extra offset)
let saveButtonHorizontalOffset: CGFloat = 0 // 💾 Save button horizontaal verschuiving
// 🔄 BUTTON INTERACTIE & STYLING
let buttonHoverZoomScale: CGFloat = 1.1 // 🔍 Hover zoom effect (1.0 = geen zoom)
let buttonSpacing: CGFloat = 15 // Afstand tussen buttons
let toolbarStartPadding: CGFloat = 8 // Afstand van linkerrand tot eerste button
// 🏷 FILENAME LABEL INSTELLINGEN
let filenameLabelFontSize: CGFloat = 10 // 🔤 Tekst grootte bestandsnaam
let filenameLabelVerticalOffset: CGFloat = 1 // 📍 Verticale positie bestandsnaam
let filenameLabelTextOpacity: CGFloat = 0.7 // 🌫 Tekst doorzichtigheid
// 🎨 WINDOW STYLING INSTELLINGEN
let windowCornerRadius: CGFloat = 8 // 🎨 Ronde hoeken window
let toolbarHeight: CGFloat = 20 // 📏 Hoogte van toolbar
let containerPadding: CGFloat = 5 // 📦 Binnenste padding
let shadowPadding: CGFloat = 12 // 🌫 Ruimte voor schaduw
init(delegate: PreviewManagerDelegate) {
self.delegate = delegate
super.init()
// Setup theme change observer for live preview updates
ThemeManager.shared.observeThemeChanges { [weak self] in
DispatchQueue.main.async {
self?.updateActivePreviewTheme()
}
}
}
// MARK: - Theme Management
private func updateActivePreviewTheme() {
// Update only if there's an active preview window
guard let window = activePreviewWindow else {
print("🎨 THEME: No active preview window to update")
return
}
print("🎨 THEME: Updating active preview window colors for current theme")
updateWindowThemeColors(window)
}
private func updateWindowThemeColors(_ window: NSWindow) {
// Find and update all theme-dependent views recursively
updateViewThemeColors(window.contentView)
}
private func updateViewThemeColors(_ view: NSView?) {
guard let view = view else { return }
// Update container background colors
if view.layer?.backgroundColor != nil && view.layer?.backgroundColor != NSColor.clear.cgColor {
view.layer?.backgroundColor = ThemeManager.shared.containerBackground.cgColor
print("🎨 THEME: Updated container background")
}
// Update shadow colors
if view.layer?.shadowColor != nil {
view.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor
view.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity
print("🎨 THEME: Updated shadow colors")
}
// Update button colors
if let button = view as? NSButton {
button.contentTintColor = ThemeManager.shared.buttonTintColor
// Update stored original color for hover restoration
objc_setAssociatedObject(button, "originalColor", ThemeManager.shared.buttonTintColor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
print("🎨 THEME: Updated button colors")
}
// Update text field colors
if let textField = view as? NSTextField, textField.tag == 11001 { // Only filename labels
textField.textColor = ThemeManager.shared.filenameLabelTextColor
print("🎨 THEME: Updated text field colors")
}
// Recursively update subviews
for subview in view.subviews {
updateViewThemeColors(subview)
}
}
// MARK: - Public Interface
func showPreview(image: NSImage) {
NSApp.activate(ignoringOtherApps: true)
print("📦 Start showPreview")
print("🖼 Afmeting: \(image.size)")
// 🔄 SMART FIX: Only reset BGR mode for NEW screenshots, not BGR actions on existing thumbnails
if isBackgroundRemovalMode && !isBGRTransition {
print("🔄 FORCE RESET: Detected BGR mode active during NEW screenshot - forcing complete reset")
forceCompleteBGRReset()
}
if let _ = self.activePreviewWindow {
print("📦 Closing existing preview window before showing new one.")
// 🔧 CRITICAL FIX: Preserve tempURL when closing for new screenshot
self.closePreviewWithAnimation(immediate: true, preserveTempFile: true)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
}
guard image.size.width > 0 && image.size.height > 0 else {
print("❌ Lege afbeelding, preview wordt overgeslagen")
return
}
delegate?.setLastImage(image)
previewDismissTimer?.invalidate()
previewDismissTimer = nil
guard let window = createNewThumbnailWindow(image: image) else {
print("❌ Failed to create new thumbnail window in showPreview using createNewThumbnailWindow.")
if isPreviewUpdating {
DispatchQueue.main.async { self.isPreviewUpdating = false }
}
return
}
activePreviewWindow = window
window.alphaValue = 1.0
window.makeKeyAndOrderFront(nil as Any?)
// Force window activation to ensure tracking areas work immediately
window.level = .floating
NSApp.activate(ignoringOtherApps: true)
// Ensure window accepts mouse events
window.acceptsMouseMovedEvents = true
print("DEBUG: showPreview FINISHED - Window: \(window), Visible: \(window.isVisible), Alpha: \(window.alphaValue), Frame: \(window.frame)")
// Voeg automatisch sluiten timer toe volgens instellingen
let timerValue = SettingsManager.shared.thumbnailTimer
if timerValue > 0 {
previewDismissTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(timerValue), repeats: false) { [weak self] _ in
self?.closePreviewWithAnimation()
}
RunLoop.main.add(previewDismissTimer!, forMode: .common)
print("⏱ Scheduled auto-dismiss in \(timerValue) seconds")
} else {
print("⏱ Auto-dismiss disabled (thumbnailTimer = 0)")
}
}
func updatePreviewSize() {
guard let lastImage = delegate?.getLastImage() else { return }
if isClosing || isPreviewUpdating { return }
isPreviewUpdating = true
// Invalidate timer *before* closing, as closing might be asynchronous or delayed slightly
previewDismissTimer?.invalidate()
previewDismissTimer = nil
closePreviewWithAnimation(immediate: true)
showPreview(image: lastImage)
DispatchQueue.main.async {
self.isPreviewUpdating = false
}
}
func closePreviewWithAnimation(immediate: Bool = false, preserveTempFile: Bool = false) {
// Invalidate timer at the very beginning of any close operation
previewDismissTimer?.invalidate()
previewDismissTimer = nil
guard let window = activePreviewWindow, !isClosing else {
if activePreviewWindow == nil && isPreviewUpdating {
DispatchQueue.main.async { self.isPreviewUpdating = false }
}
return
}
isClosing = true
let cleanup = { [weak self] in
guard let self = self else { return }
window.orderOut(nil as Any?)
if self.activePreviewWindow === window {
print("🧼 Cleaning up preview resources...")
self.activePreviewWindow = nil
self.currentImageView = nil
if !preserveTempFile {
self.delegate?.clearTempFile()
} else {
print("💰 Preserving tempURL as requested.")
}
}
self.isClosing = false
}
if immediate {
cleanup()
} else {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.4
window.animator().alphaValue = 0
// Voeg "hupje" animatie toe: beweeg venster iets omhoog
let currentFrame = window.frame
let bounceHeight: CGFloat = 20 // hoogte van het hupje
let finalFrame = NSRect(x: currentFrame.origin.x,
y: currentFrame.origin.y + bounceHeight,
width: currentFrame.width,
height: currentFrame.height)
window.animator().setFrame(finalFrame, display: true)
}, completionHandler: cleanup)
}
}
@objc func closePreviewWindow() {
previewDismissTimer?.invalidate()
previewDismissTimer = nil
closePreviewWithAnimation()
}
func getActivePreviewWindow() -> NSWindow? {
return activePreviewWindow
}
func ensurePreviewVisible() {
guard let preview = self.activePreviewWindow else {
if let img = delegate?.getLastImage() {
self.showPreview(image: img)
}
return
}
if !preview.isVisible {
preview.alphaValue = 0
preview.orderFront(nil)
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 2.0
preview.animator().alphaValue = 1
}
}
}
// MARK: - Visual Effects
func flashPreviewBorder() {
guard let window = activePreviewWindow, let _ = window.contentView else {
print("⚠️ FlashEffect: No active preview window or outerContainer (contentView).")
return
}
guard let currentImgView = self.currentImageView, let imageContainer = currentImgView.superview else {
print("⚠️ FlashEffect: Could not find imageContainer via currentImageView.superview.")
return
}
let flashView = NSView(frame: imageContainer.bounds)
flashView.wantsLayer = true
flashView.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.50).cgColor
flashView.layer?.cornerRadius = imageContainer.layer?.cornerRadius ?? 10
flashView.alphaValue = 0
imageContainer.addSubview(flashView)
print("✨ FlashEffect: Starting white flash animation on imageContainer.")
NSAnimationContext.runAnimationGroup({
context in
context.duration = 0.08
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
flashView.animator().alphaValue = 1.0
}, completionHandler: {
NSAnimationContext.runAnimationGroup({
context in
context.duration = 0.8
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
flashView.animator().alphaValue = 0.0
}, completionHandler: {
flashView.removeFromSuperview()
print("✨ FlashEffect: Animation complete, flashView removed.")
})
})
}
// MARK: - Screen Selection for Thumbnail
func getTargetScreenForThumbnail() -> NSScreen? {
let setting = SettingsManager.shared.thumbnailDisplayScreen
switch setting {
case .automatic:
let mouseLocation = NSEvent.mouseLocation
for screen in NSScreen.screens {
if screen.frame.contains(mouseLocation) {
print("📺 Thumbnail scherm: Automatisch - muis op \(screen.localizedName)")
return screen
}
}
print("📺 Thumbnail scherm: Automatisch fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
case .screen1:
if NSScreen.screens.count >= 1 {
let screen = NSScreen.screens[0]
print("📺 Thumbnail scherm: Scherm 1 (\(screen.localizedName))")
return screen
}
print("📺 Thumbnail scherm: Scherm 1 niet beschikbaar, fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
case .screen2:
if NSScreen.screens.count >= 2 {
let screen = NSScreen.screens[1]
print("📺 Thumbnail scherm: Scherm 2 (\(screen.localizedName))")
return screen
}
print("📺 Thumbnail scherm: Scherm 2 niet beschikbaar, fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
case .screen3:
if NSScreen.screens.count >= 3 {
let screen = NSScreen.screens[2]
print("📺 Thumbnail scherm: Scherm 3 (\(screen.localizedName))")
return screen
}
print("📺 Thumbnail scherm: Scherm 3 niet beschikbaar, fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
case .screen4:
if NSScreen.screens.count >= 4 {
let screen = NSScreen.screens[3]
print("📺 Thumbnail scherm: Scherm 4 (\(screen.localizedName))")
return screen
}
print("📺 Thumbnail scherm: Scherm 4 niet beschikbaar, fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
case .screen5:
if NSScreen.screens.count >= 5 {
let screen = NSScreen.screens[4]
print("📺 Thumbnail scherm: Scherm 5 (\(screen.localizedName))")
return screen
}
print("📺 Thumbnail scherm: Scherm 5 niet beschikbaar, fallback naar eerste scherm")
return NSScreen.screens.first ?? NSScreen.main
}
}
// MARK: - Cleanup
func cleanup() {
// Clean up BGR mode if active
if isBackgroundRemovalMode {
exitBackgroundRemovalMode()
}
previewDismissTimer?.invalidate()
previewDismissTimer = nil
activePreviewWindow = nil
currentImageView = nil
}
// MARK: - Window Creation
private func createNewThumbnailWindow(image: NSImage) -> NSWindow? {
if isPreviewUpdating {
print("DEBUG: createNewThumbnailWindow - isPreviewUpdating is true, returning nil early")
return nil
}
print("DEBUG: createNewThumbnailWindow called with image: \(image.size)")
// Debug current theme
print("🎨 THUMBNAIL: Creating new thumbnail window with current theme:")
ThemeManager.shared.printCurrentTheme()
// 0. Basisinstellingen en afmetingen
let actualImageWidth = SettingsManager.shared.thumbnailFixedSize.dimensions.width
let actualImageHeight = SettingsManager.shared.thumbnailFixedSize.dimensions.height
// 1. NIEUWE UNIFIED LAYOUT - één container voor alles (gebruik constants)
let fixedToolbarHeight: CGFloat = toolbarHeight // 🎛 Gebruik constant
let spacingBelowToolbar: CGFloat = 8
// Corner Radii - gebruik constants
let mainContainerCornerRadius: CGFloat = windowCornerRadius // 🎨 Gebruik constant
let imageCornerRadius: CGFloat = windowCornerRadius - 4 // 🎨 Iets kleiner dan container
// 2. Bereken afmetingen - UNIFIED
let imageWidth = actualImageWidth
let imageHeight = actualImageHeight
// Main container bevat: padding + toolbar + spacing + image + padding
let mainContainerWidth = imageWidth + (2 * containerPadding)
let mainContainerHeight = containerPadding + fixedToolbarHeight + spacingBelowToolbar + imageHeight + containerPadding
// 3. GLASS EFFECT CONTAINER - FIXED CORNER RADIUS
let shadowContainerFrame = NSRect(x: shadowPadding,
y: shadowPadding,
width: mainContainerWidth,
height: mainContainerHeight)
let mainContainerFrame = NSRect(x: 0, y: 0, width: mainContainerWidth, height: mainContainerHeight)
let mainContainer = NSVisualEffectView(frame: mainContainerFrame)
mainContainer.material = ThemeManager.shared.glassEffectMaterial
mainContainer.blendingMode = ThemeManager.shared.glassEffectBlending
mainContainer.state = .active
mainContainer.alphaValue = ThemeManager.shared.glassEffectAlpha
mainContainer.wantsLayer = true
// 🔥 CRITICAL FIX: NSVisualEffectView corner radius
mainContainer.layer?.cornerRadius = mainContainerCornerRadius
mainContainer.layer?.masksToBounds = true // 🔥 MOET TRUE zijn voor corner radius!
// 🔥 EXTRA FIX: Shadow op PARENT container, niet op visual effect view
let shadowContainer = NSView(frame: shadowContainerFrame)
shadowContainer.wantsLayer = true
shadowContainer.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor
shadowContainer.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity
shadowContainer.layer?.shadowRadius = 6
shadowContainer.layer?.shadowOffset = CGSize(width: 0, height: -2)
shadowContainer.layer?.cornerRadius = mainContainerCornerRadius
shadowContainer.layer?.masksToBounds = false // Shadow container mag wel shadow buiten bounds
// 4. Toolbar BINNEN de main container (ONDERAAN)
let toolbarFrame = NSRect(x: containerPadding,
y: containerPadding,
width: mainContainerWidth - (2 * containerPadding),
height: fixedToolbarHeight)
let toolbarView = NSView(frame: toolbarFrame)
toolbarView.wantsLayer = false // Geen aparte layer, gebruik parent styling
// Toolbar buttons
let symbolConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
let closeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 12.5, weight: .regular) // 🎯 Kleinere icoon voor close button
var currentXButton: CGFloat = toolbarStartPadding
let closeButtonY = (fixedToolbarHeight - closeButtonSize) / 2 + closeButtonVerticalOffset
let closeButton = HoverButton(frame: NSRect(x: currentXButton, y: closeButtonY, width: closeButtonSize, height: closeButtonSize))
closeButton.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: "Close")?.withSymbolConfiguration(closeSymbolConfig)
closeButton.isBordered = false; closeButton.bezelStyle = .shadowlessSquare; closeButton.contentTintColor = ThemeManager.shared.buttonTintColor
closeButton.action = #selector(closePreviewWindow); closeButton.target = self
closeButton.setupHover(zoomScale: buttonHoverZoomScale)
toolbarView.addSubview(closeButton)
currentXButton += closeButtonSize + buttonSpacing
let saveButtonY = (fixedToolbarHeight - saveButtonSize) / 2 + saveButtonVerticalOffset
let saveButtonX = currentXButton + saveButtonHorizontalOffset
let saveButton = HoverButton(frame: NSRect(x: saveButtonX, y: saveButtonY, width: saveButtonSize, height: saveButtonSize))
saveButton.image = NSImage(systemSymbolName: "folder.fill.badge.plus", accessibilityDescription: "Save")?.withSymbolConfiguration(symbolConfig)
saveButton.isBordered = false; saveButton.bezelStyle = .shadowlessSquare; saveButton.contentTintColor = ThemeManager.shared.buttonTintColor
saveButton.action = #selector(PreviewManager.saveFromPreview); saveButton.target = self
saveButton.setupHover(zoomScale: buttonHoverZoomScale)
toolbarView.addSubview(saveButton)
currentXButton += saveButtonSize + buttonSpacing
var folderButton: HoverButton?
if SettingsManager.shared.showFolderButton {
let folderButtonY = (fixedToolbarHeight - folderButtonSize) / 2 + folderButtonVerticalOffset
folderButton = HoverButton(frame: NSRect(x: currentXButton, y: folderButtonY, width: folderButtonSize, height: folderButtonSize))
folderButton!.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "Open Folder")?.withSymbolConfiguration(symbolConfig)
folderButton!.isBordered = false; folderButton!.bezelStyle = .shadowlessSquare; folderButton!.contentTintColor = ThemeManager.shared.buttonTintColor
folderButton!.action = #selector(PreviewManager.openScreenshotFolder); folderButton!.target = self
folderButton!.setupHover(zoomScale: buttonHoverZoomScale)
toolbarView.addSubview(folderButton!)
currentXButton += folderButtonSize + buttonSpacing
}
let settingsButtonX = (mainContainerWidth - (2 * containerPadding)) - settingsButtonSize - 8
let settingsButtonY = (fixedToolbarHeight - settingsButtonSize) / 2 + settingsButtonVerticalOffset
let settingsButton = HoverButton(frame: NSRect(x: settingsButtonX, y: settingsButtonY, width: settingsButtonSize, height: settingsButtonSize))
settingsButton.image = NSImage(systemSymbolName: "gearshape.fill", accessibilityDescription: "Settings")?.withSymbolConfiguration(symbolConfig)
settingsButton.isBordered = false; settingsButton.bezelStyle = .shadowlessSquare; settingsButton.contentTintColor = ThemeManager.shared.buttonTintColor
settingsButton.action = #selector(PreviewManager.openSettings); settingsButton.target = self
settingsButton.setupHover(zoomScale: buttonHoverZoomScale)
toolbarView.addSubview(settingsButton)
let labelX = currentXButton
let labelWidth = settingsButtonX - labelX - 8
let labelDesiredHeight: CGFloat = 15.0
let labelYOffset = (fixedToolbarHeight - labelDesiredHeight) / 2.0 + filenameLabelVerticalOffset
let filenameLabel = NSTextField(frame: NSRect(x: labelX, y: labelYOffset, width: labelWidth, height: labelDesiredHeight))
filenameLabel.isEditable = false; filenameLabel.isSelectable = true; filenameLabel.isBordered = false
filenameLabel.backgroundColor = NSColor.clear; filenameLabel.textColor = ThemeManager.shared.filenameLabelTextColor
filenameLabel.font = NSFont.systemFont(ofSize: filenameLabelFontSize)
filenameLabel.alignment = .center
filenameLabel.lineBreakMode = .byTruncatingMiddle; filenameLabel.tag = 11001
if let url = delegate?.getTempURL() {
filenameLabel.stringValue = url.lastPathComponent; filenameLabel.toolTip = url.lastPathComponent
} else {
let fallbackName = "Schermafbeelding \(DateFormatter().string(from: Date())).png"
filenameLabel.stringValue = fallbackName; filenameLabel.toolTip = fallbackName
}
toolbarView.addSubview(filenameLabel)
// 5. Image Container BINNEN de main container (BOVENAAN)
let imageFrame = NSRect(x: containerPadding,
y: containerPadding + fixedToolbarHeight + spacingBelowToolbar,
width: imageWidth,
height: imageHeight)
let imageContainer = NSView(frame: imageFrame)
imageContainer.wantsLayer = true
imageContainer.layer?.cornerRadius = imageCornerRadius
imageContainer.layer?.masksToBounds = true
imageContainer.layer?.backgroundColor = NSColor.clear.cgColor
// 6. DraggableImageView DIRECT in image container
let imageView = DraggableImageView(frame: imageContainer.bounds)
imageView.wantsLayer = true
imageView.clipsToBounds = true
imageView.image = image
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.imageAlignment = .alignCenter
imageView.layer?.cornerRadius = imageCornerRadius
imageView.layer?.masksToBounds = true
imageView.layer?.backgroundColor = NSColor.clear.cgColor
imageView.appDelegate = delegate as? ScreenshotApp
// 🔧 FIX: Set imageURL for dragging functionality
// In BGR mode, this is set elsewhere, but for normal screenshots we need the current tempURL
if !isBackgroundRemovalMode {
imageView.imageURL = delegate?.getTempURL()
print("🔧 DraggableImageView: Set imageURL for normal screenshot: \(imageView.imageURL?.lastPathComponent ?? "nil")")
}
currentImageView = imageView
imageContainer.addSubview(imageView)
// 7. Window Assembly - SIMPLIFIED
let totalWindowWidth = mainContainerWidth + (2 * shadowPadding)
let totalWindowHeight = mainContainerHeight + (2 * shadowPadding)
let windowFrame = NSRect(x: 0, y: 0, width: totalWindowWidth, height: totalWindowHeight)
let window = NSWindow(contentRect: windowFrame, styleMask: [.borderless], backing: .buffered, defer: false)
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = false
let rootView = NSView(frame: window.contentRect(forFrameRect: window.frame))
rootView.wantsLayer = true
rootView.layer?.masksToBounds = false
rootView.layer?.backgroundColor = NSColor.clear.cgColor
window.contentView = rootView
// FIXED HIERARCHY: root -> shadowContainer -> mainContainer -> (toolbar + imageContainer)
shadowContainer.addSubview(mainContainer)
rootView.addSubview(shadowContainer)
mainContainer.addSubview(toolbarView)
mainContainer.addSubview(imageContainer)
// Debug prints
print("DEBUG: Total Window Frame: \(windowFrame)")
print("DEBUG: Main Container Frame: \(mainContainer.frame)")
print("DEBUG: Toolbar Frame: \(toolbarView.frame)")
print("DEBUG: Image Container Frame: \(imageContainer.frame)")
// 5. Positioneer het window op het juiste scherm
guard let screen = getTargetScreenForThumbnail() else {
print("ERROR: Kon doelscherm niet bepalen")
return nil
}
let screenVisibleFrame = screen.visibleFrame
let screenEdgePadding: CGFloat = 20
let windowOriginX = screenVisibleFrame.maxX - totalWindowWidth - screenEdgePadding
let windowOriginY = screenVisibleFrame.minY + screenEdgePadding
window.setFrameOrigin(NSPoint(x: windowOriginX, y: windowOriginY))
return window
}
// MARK: - Loading Indicator
func showLoadingIndicator() {
guard !isShowingLoadingIndicator else { return }
print("🔄 ShowLoadingIndicator: Starting loading indicator...")
isShowingLoadingIndicator = true
// Bepaal de positie en grootte op basis van de thumbnail locatie
guard let targetScreen = getTargetScreenForThumbnail() else {
print("❌ Could not determine target screen for loading indicator")
return
}
// AANGEPAST: Nieuwe afmetingen - breder en minder hoog
let loadingSize = NSSize(width: 200, height: 70)
let screenVisibleFrame = targetScreen.visibleFrame
let screenEdgePadding: CGFloat = 20
// Plaats de loading indicator op dezelfde positie als waar de thumbnail zou komen
let loadingOriginX = screenVisibleFrame.maxX - loadingSize.width - screenEdgePadding
let loadingOriginY = screenVisibleFrame.minY + screenEdgePadding
let loadingFrame = NSRect(x: loadingOriginX, y: loadingOriginY, width: loadingSize.width, height: loadingSize.height)
// Creëer het loading overlay window
let loadingWindow = NSWindow(
contentRect: loadingFrame,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
loadingWindow.backgroundColor = NSColor.clear
loadingWindow.isOpaque = false
loadingWindow.hasShadow = true
loadingWindow.level = .floating
loadingWindow.ignoresMouseEvents = true
loadingWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
// AANGEPAST: Start met alpha 0 voor fade-in animatie
loadingWindow.alphaValue = 0
// Creëer de loading content view
let loadingContentView = NSView(frame: NSRect(origin: .zero, size: loadingSize))
loadingContentView.wantsLayer = true
loadingContentView.layer?.cornerRadius = 12
loadingContentView.layer?.backgroundColor = ThemeManager.shared.containerBackground.cgColor
loadingContentView.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor
loadingContentView.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity
loadingContentView.layer?.shadowRadius = 6
loadingContentView.layer?.shadowOffset = CGSize(width: 0, height: -2)
// AANGEPAST: Horizontale progress bar in plaats van spinning indicator
let progressBarWidth: CGFloat = 140
let progressBarHeight: CGFloat = 6
let progressBar = NSProgressIndicator(frame: NSRect(
x: (loadingSize.width - progressBarWidth) / 2,
y: 25,
width: progressBarWidth,
height: progressBarHeight
))
progressBar.style = .bar
progressBar.isIndeterminate = true
progressBar.controlSize = .regular
progressBar.startAnimation(nil)
// AANGEPAST: Label positionering aangepast voor nieuwe layout
let loadingLabel = NSTextField(frame: NSRect(x: 10, y: 40, width: loadingSize.width - 20, height: 20))
loadingLabel.isEditable = false
loadingLabel.isSelectable = false
loadingLabel.isBordered = false
loadingLabel.backgroundColor = NSColor.clear
loadingLabel.textColor = ThemeManager.shared.primaryTextColor
loadingLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
loadingLabel.alignment = .center
loadingLabel.stringValue = "Stitching Screens..."
loadingContentView.addSubview(progressBar)
loadingContentView.addSubview(loadingLabel)
loadingWindow.contentView = loadingContentView
// Toon het window
loadingWindow.makeKeyAndOrderFront(nil)
loadingOverlayWindow = loadingWindow
// NIEUW: Fade-in animatie
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.3
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
loadingWindow.animator().alphaValue = 1.0
}, completionHandler: {
print("✅ Loading indicator fade-in complete at \(loadingFrame)")
})
}
func hideLoadingIndicator() {
guard isShowingLoadingIndicator, let loadingWindow = loadingOverlayWindow else { return }
print("🔄 HideLoadingIndicator: Hiding loading indicator...")
isShowingLoadingIndicator = false
// AANGEPAST: Fade-out animatie
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.4
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
loadingWindow.animator().alphaValue = 0
}, completionHandler: { [weak self] in
loadingWindow.orderOut(nil)
self?.loadingOverlayWindow = nil
print("✅ Loading indicator fade-out complete and window closed")
})
}
// MARK: - Button Actions (delegate to main app)
@objc func saveFromPreview() {
delegate?.saveFromPreview(self)
}
@objc func openScreenshotFolder() {
delegate?.openScreenshotFolder()
}
@objc func openSettings() {
delegate?.openSettings(self)
}
func closePreview() {
print("🧼 Cleaning up preview resources...")
// FIXED: Bewaar de huidige tempURL voordat we gaan clearen
let currentTempURL = delegate?.getTempURL()
// Close window first
if let window = activePreviewWindow {
window.orderOut(nil)
window.close()
}
activePreviewWindow = nil
// FIXED: Alleen de tempURL clearen als er geen nieuwe screenshot bezig is
// Dit voorkomt dat we de net gemaakte screenshot URL wegwissen
if let tempURL = currentTempURL {
let filename = tempURL.lastPathComponent
// Controleer of dit de oude screenshot is of een nieuwe
// Als de timestamp meer dan 5 seconden oud is, clean het op
let now = Date()
let fileCreationTime = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.creationDate] as? Date) ?? now
let timeDifference = now.timeIntervalSince(fileCreationTime)
if timeDifference > 5.0 {
// Dit is een oude screenshot, veilig om op te ruimen
delegate?.setTempFileURL(nil)
print("🗑️ Cleaned up old screenshot file: \(filename)")
} else {
// Dit is een nieuwe screenshot, NIET opruimen
print("⚠️ Preserving recent screenshot file: \(filename) (created \(String(format: "%.1f", timeDifference))s ago)")
}
}
}
}
// MARK: - Custom Tracking Area
private class HoverTrackingArea: NSTrackingArea {
// This will be used to identify our custom tracking areas
}
// MARK: - PreviewManager Delegate Protocol
protocol PreviewManagerDelegate: AnyObject {
func getLastImage() -> NSImage?
func setLastImage(_ image: NSImage)
func clearTempFile()
func getTempURL() -> URL?
func findFilenameLabel(in window: NSWindow?) -> NSTextField?
func openScreenshotFolder()
func openSettings(_ sender: Any?)
func saveFromPreview(_ sender: Any)
func setTempFileURL(_ url: URL?)
}
// MARK: - Custom Button with Built-in Hover
class HoverButton: NSButton {
private var hoverHandler: ButtonHoverHandler?
private var trackingArea: NSTrackingArea?
override func awakeFromNib() {
super.awakeFromNib()
setupHover()
}
func setupHover(zoomScale: CGFloat = 1.3) {
wantsLayer = true
// Store current theme-aware color as original
let originalColor = ThemeManager.shared.buttonTintColor
// Create hover handler with custom zoom scale
hoverHandler = ButtonHoverHandler(button: self, zoomScale: zoomScale)
// Store theme-aware original color
objc_setAssociatedObject(self, "originalColor", originalColor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
// Setup tracking area
updateTrackingAreas()
print("🎯 SETUP: HoverButton setup complete with \(zoomScale)x zoom and theme-aware colors")
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove existing tracking area
if let existing = trackingArea {
removeTrackingArea(existing)
}
// Create new tracking area
trackingArea = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
)
if let trackingArea = trackingArea {
addTrackingArea(trackingArea)
print("🎯 SETUP: HoverButton tracking area added")
}
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
print("🎯 HOVER: HoverButton mouseEntered")
hoverHandler?.mouseEntered(with: event)
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
print("🎯 HOVER: HoverButton mouseExited")
hoverHandler?.mouseExited(with: event)
}
}
// MARK: - BGR Overlay Button with Hover Tooltips
class BGROverlayButton: NSButton {
enum TooltipPosition {
case left, right
}
var tooltipText: String = ""
var tooltipPosition: TooltipPosition = .left
weak var parentContainer: NSView?
private var tooltipView: NSView?
private var trackingArea: NSTrackingArea?
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove existing tracking area
if let existing = trackingArea {
removeTrackingArea(existing)
}
// Create new tracking area
trackingArea = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
)
if let trackingArea = trackingArea {
addTrackingArea(trackingArea)
print("🎯 BGR Button: Tracking area added - bounds: \(bounds), tooltipText: '\(tooltipText)'")
}
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
print("🎯 BGR Button: Mouse entered - tooltipText: '\(tooltipText)'")
showTooltip()
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
print("🎯 BGR Button: Mouse exited")
hideTooltip()
}
private func showTooltip() {
guard !tooltipText.isEmpty,
let container = parentContainer else {
print("🚫 Tooltip: Missing text or container - text: '\(tooltipText)', container: \(parentContainer != nil)")
return
}
// Force cleanup any existing tooltip first
if let existingTooltip = tooltipView {
print("🧹 Tooltip: Cleaning up existing tooltip before showing new one")
existingTooltip.removeFromSuperview()
tooltipView = nil
}
print("✨ Tooltip: Showing '\(tooltipText)' at position \(tooltipPosition)")
// Create simple text tooltip (no button/container styling)
let tooltipLabel = NSTextField()
tooltipLabel.stringValue = tooltipText
tooltipLabel.textColor = .white
tooltipLabel.backgroundColor = .clear
tooltipLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium)
tooltipLabel.alignment = .center
tooltipLabel.isBordered = false
tooltipLabel.isEditable = false
tooltipLabel.isSelectable = false
tooltipLabel.sizeToFit()
// Add subtle text shadow for readability
tooltipLabel.wantsLayer = true
tooltipLabel.layer?.shadowColor = NSColor.black.cgColor
tooltipLabel.layer?.shadowOpacity = 0.8
tooltipLabel.layer?.shadowRadius = 2
tooltipLabel.layer?.shadowOffset = CGSize(width: 0, height: -1)
// Position text tooltip relative to button in container coordinates
let buttonFrameInContainer = self.frame
let buttonCenterY = buttonFrameInContainer.midY
let tooltipY = buttonCenterY - (tooltipLabel.frame.height / 2)
let tooltipX: CGFloat
let spacing: CGFloat = 8
switch tooltipPosition {
case .left:
tooltipX = buttonFrameInContainer.minX - tooltipLabel.frame.width - spacing
case .right:
tooltipX = buttonFrameInContainer.maxX + spacing
}
tooltipLabel.frame = NSRect(x: tooltipX, y: tooltipY, width: tooltipLabel.frame.width, height: tooltipLabel.frame.height)
print("📍 Tooltip: Button frame: \(buttonFrameInContainer), Tooltip frame: \(tooltipLabel.frame)")
// Add directly to container (no wrapper view)
container.addSubview(tooltipLabel)
// Fade in animation
tooltipLabel.alphaValue = 0
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
tooltipLabel.animator().alphaValue = 1.0
}
self.tooltipView = tooltipLabel
print("✅ Tooltip: Added to container with \(container.subviews.count) total subviews")
}
private func hideTooltip() {
guard let tooltip = tooltipView else {
print("🚫 Tooltip: No tooltip to hide")
return
}
print("🎯 Tooltip: Hiding tooltip - current alpha: \(tooltip.alphaValue)")
// Force immediate removal without animation to prevent hanging
tooltip.removeFromSuperview()
self.tooltipView = nil
print("✅ Tooltip: Force removed from view")
}
// 🔧 NEW: Public method to force hide tooltips (for cleanup)
func forceHideTooltip() {
hideTooltip()
}
}
// MARK: - Background Removal Mode Extension
extension PreviewManager {
// 🎨 NEW: Show BGR preview with identical thumbnail UI
func showBackgroundRemovalPreview(originalImage: NSImage, originalURL: URL) {
print("🎨 PreviewManager: Starting BGR preview with thumbnail UI")
// 🔄 NEW: Set transition flag to prevent force reset during BGR action
isBGRTransition = true
// Store the original image
self.originalImage = originalImage
self.processedImage = nil
self.isShowingProcessedImage = false
// Store original image URL from parameter (not delegate which is cleared)
self.originalImageURL = originalURL
self.processedImageURL = nil
print("🎨 BGR: Stored original URL: \(originalURL.path)")
// Enable BGR mode
isBackgroundRemovalMode = true
// 🔧 CRITICAL FIX: Check if there's already an active preview window and user has closeAfterDrag OFF
let hasActivePreview = activePreviewWindow != nil
let shouldReusePreview = hasActivePreview && !SettingsManager.shared.closeAfterDrag
if shouldReusePreview {
print("🔄 BGR: Reusing existing preview window (closeAfterDrag is OFF)")
// Update existing window for BGR mode
if let window = activePreviewWindow {
window.level = .popUpMenu // Higher level than .floating to stay on top
print("🎨 BGR: Set window level to .popUpMenu for always-on-top behavior")
}
// Update existing image view
if let imageView = currentImageView {
imageView.image = originalImage
imageView.imageURL = originalImageURL
print("🎨 BGR: Updated existing imageView with BGR image and URL")
}
// Update filename label to show original filename in BGR mode
updateBGRFilenameLabel()
} else {
print("🔄 BGR: Creating new preview window (closeAfterDrag is ON or no existing preview)")
// Use existing thumbnail system to create new window
showPreview(image: originalImage)
// 🎨 NEW: Set higher window level for BGR mode to stay on top
if let window = activePreviewWindow {
window.level = .popUpMenu // Higher level than .floating to stay on top
print("🎨 BGR: Set window level to .popUpMenu for always-on-top behavior")
}
// 🎨 NEW: Initialize DraggableImageView with original URL for BGR mode
if isBackgroundRemovalMode {
currentImageView?.imageURL = originalImageURL
print("🎨 BGR: Set initial imageURL to original: \(originalImageURL?.path ?? "nil")")
}
// 🎨 NEW: Update filename label to show original filename in BGR mode
updateBGRFilenameLabel()
}
// Add BGR overlay buttons (for both cases)
DispatchQueue.main.async {
self.addBGROverlayButtons()
// Start automatic BGR processing
self.startBackgroundRemovalProcessing()
// 🔄 NEW: Clear transition flag after BGR setup is complete
self.isBGRTransition = false
print("🔄 BGR transition flag cleared - normal state management resumed")
}
}
// 🎨 NEW: Add small overlay buttons IN the image area
private func addBGROverlayButtons() {
guard let _ = activePreviewWindow,
let imageView = currentImageView,
let imageContainer = imageView.superview else {
print("❌ Cannot add BGR buttons: missing components")
return
}
let buttonSize: CGFloat = 28
let buttonSpacing: CGFloat = 8
let bottomMargin: CGFloat = 12
// Position buttons at bottom center of image
let totalButtonsWidth = (buttonSize * 2) + buttonSpacing
let startX = (imageContainer.bounds.width - totalButtonsWidth) / 2
let buttonY = bottomMargin
// Reset button ()
let resetButton = createBGROverlayButton(
frame: NSRect(x: startX, y: buttonY, width: buttonSize, height: buttonSize),
symbolName: "arrow.clockwise"
)
resetButton.action = #selector(bgrResetButtonClicked)
resetButton.target = self
resetButton.isHidden = true // Initially hidden until processing is complete
resetButton.tooltipText = "Reset"
resetButton.tooltipPosition = .left
resetButton.parentContainer = imageContainer
// Toggle button (👁)
let toggleButton = createBGROverlayButton(
frame: NSRect(x: startX + buttonSize + buttonSpacing, y: buttonY, width: buttonSize, height: buttonSize),
symbolName: "eye"
)
toggleButton.action = #selector(bgrToggleButtonClicked)
toggleButton.target = self
toggleButton.isHidden = true // Initially hidden until processing is complete
toggleButton.tooltipText = "Compare"
toggleButton.tooltipPosition = .right
toggleButton.parentContainer = imageContainer
// Add to image container
imageContainer.addSubview(resetButton)
imageContainer.addSubview(toggleButton)
// Store references
bgrResetButton = resetButton
bgrToggleButton = toggleButton
print("🎨 BGR overlay buttons added to image container")
}
// 🎨 NEW: Add BGR progress bar in center of image
private func addBGRProgressBar() {
guard let _ = activePreviewWindow,
let imageView = currentImageView,
let imageContainer = imageView.superview else {
print("❌ Cannot add BGR progress bar: missing components")
return
}
// Progress container dimensions
let containerWidth: CGFloat = 200
let containerHeight: CGFloat = 60
let containerX = (imageContainer.bounds.width - containerWidth) / 2
let containerY = (imageContainer.bounds.height - containerHeight) / 2
// Create progress container with glass effect
let progressContainer = NSView(frame: NSRect(x: containerX, y: containerY, width: containerWidth, height: containerHeight))
progressContainer.wantsLayer = true
progressContainer.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.7).cgColor
progressContainer.layer?.cornerRadius = 12
progressContainer.layer?.masksToBounds = true
progressContainer.layer?.borderColor = NSColor.white.withAlphaComponent(0.2).cgColor
progressContainer.layer?.borderWidth = 1
// Create progress bar
let progressBarWidth: CGFloat = 160
let progressBarHeight: CGFloat = 6
let progressBarX = (containerWidth - progressBarWidth) / 2
let progressBarY = containerHeight / 2 + 8
let progressBar = NSProgressIndicator(frame: NSRect(x: progressBarX, y: progressBarY, width: progressBarWidth, height: progressBarHeight))
progressBar.style = .bar
progressBar.isIndeterminate = false
progressBar.minValue = 0
progressBar.maxValue = 100
progressBar.doubleValue = 0
progressBar.wantsLayer = true
progressBar.layer?.cornerRadius = 3
// Create progress label
let progressLabel = NSTextField(frame: NSRect(x: 0, y: containerHeight / 2 - 20, width: containerWidth, height: 20))
progressLabel.stringValue = "Removing background... 0%"
progressLabel.textColor = .white
progressLabel.backgroundColor = .clear
progressLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium)
progressLabel.alignment = .center
progressLabel.isBordered = false
progressLabel.isEditable = false
progressLabel.isSelectable = false
// Add subviews
progressContainer.addSubview(progressBar)
progressContainer.addSubview(progressLabel)
imageContainer.addSubview(progressContainer)
// Store references
bgrProgressContainer = progressContainer
bgrProgressBar = progressBar
bgrProgressLabel = progressLabel
// Fade in animation
progressContainer.alphaValue = 0
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
progressContainer.animator().alphaValue = 1.0
}
print("🎨 BGR progress bar added to image container")
}
// 🎨 NEW: Update BGR progress bar
private func updateBGRProgress(_ progress: Double, status: String) {
DispatchQueue.main.async {
self.bgrProgressBar?.doubleValue = progress
self.bgrProgressLabel?.stringValue = "\(status) \(Int(progress))%"
}
}
// 🎨 NEW: Hide BGR progress bar
private func hideBGRProgressBar() {
guard let progressContainer = bgrProgressContainer else { return }
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.4
progressContainer.animator().alphaValue = 0
}) {
progressContainer.removeFromSuperview()
self.bgrProgressContainer = nil
self.bgrProgressBar = nil
self.bgrProgressLabel = nil
}
}
// 🎨 NEW: Create clean BGR overlay button (icon only, no button styling)
private func createBGROverlayButton(frame: NSRect, symbolName: String) -> BGROverlayButton {
let button = BGROverlayButton(frame: frame)
// Completely transparent button - no background or borders
button.wantsLayer = true
button.layer?.backgroundColor = NSColor.clear.cgColor
// No button styling
button.isBordered = false
button.bezelStyle = .shadowlessSquare
// SF Symbol icon with subtle shadow for visibility (20% smaller: 16pt -> 13pt)
let symbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?.withSymbolConfiguration(symbolConfig)
button.contentTintColor = .white
// Add subtle shadow to icon for better visibility
button.layer?.shadowColor = NSColor.black.cgColor
button.layer?.shadowOpacity = 0.7
button.layer?.shadowRadius = 2
button.layer?.shadowOffset = CGSize(width: 0, height: -1)
// Ensure tracking areas are set up
button.updateTrackingAreas()
return button
}
// 🎨 NEW: Start BGR processing automatically
private func startBackgroundRemovalProcessing() {
guard let originalImg = originalImage else {
print("❌ No original image for BGR processing")
return
}
print("🎨 Starting automatic BGR processing with user's preferred method...")
// Show progress bar
addBGRProgressBar()
// Store processing result to be accessed by progress checker
var processingResult: NSImage? = nil
var isProcessingComplete = false
// Start actual background removal processing
BackgroundRemover.shared.processWithPreferredMethod(from: originalImg) { [weak self] result in
processingResult = result
isProcessingComplete = true
print("🎨 BGR: Background removal processing completed with result: \(result != nil ? "success" : "failed")")
}
// Start progress simulation that waits for actual completion
simulateBGRProgress(
checkProcessingComplete: { isProcessingComplete },
getProcessingResult: { processingResult }
) { [weak self] finalResult in
// This runs when both progress and processing are complete
DispatchQueue.main.async {
// Hide progress bar
self?.hideBGRProgressBar()
if let processedImg = finalResult {
print("✅ BGR processing completed successfully")
self?.processedImage = processedImg
// 🎨 NEW: Save processed image to file for dragging
self?.saveProcessedImageToFile(processedImg)
// Update image view to show processed image
self?.currentImageView?.image = processedImg
self?.isShowingProcessedImage = true
// 🎨 NEW: Update DraggableImageView to use processed image URL
self?.updateDraggableImageURL()
// 🎨 NEW: Update filename label to show processed filename
self?.updateBGRFilenameLabel()
// Show overlay buttons
self?.bgrResetButton?.isHidden = false
self?.bgrToggleButton?.isHidden = false
// Add subtle animation to reveal buttons
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.5
self?.bgrResetButton?.animator().alphaValue = 1.0
self?.bgrToggleButton?.animator().alphaValue = 1.0
}
} else {
print("❌ BGR processing failed")
// Keep original image showing, don't show overlay buttons
}
}
}
}
// 🎨 NEW: Smart BGR progress that adapts to actual processing speed
private func simulateBGRProgress(
checkProcessingComplete: @escaping () -> Bool,
getProcessingResult: @escaping () -> NSImage?,
completion: @escaping (NSImage?) -> Void
) {
let processingStartTime = Date()
var currentProgress: Double = 0
// Start monitoring actual completion immediately
func checkForEarlyCompletion() {
let elapsedTime = Date().timeIntervalSince(processingStartTime)
// Check if processing is already complete
if checkProcessingComplete() {
let result = getProcessingResult()
print("🚀 FAST COMPLETION: BGR finished in \(String(format: "%.1f", elapsedTime)) seconds!")
// Rapidly complete the progress bar
finishProgressBar(with: result, completion: completion)
return
}
// Update progress based on elapsed time with smart acceleration
let targetProgress: Double
if elapsedTime < 1.0 {
// Very fast progress for Vision Framework
targetProgress = min(70.0, elapsedTime * 70.0)
updateBGRProgress(targetProgress, status: "Processing...")
} else if elapsedTime < 2.0 {
// Continue fast for Vision Framework
targetProgress = 70.0 + ((elapsedTime - 1.0) * 15.0)
updateBGRProgress(targetProgress, status: "Applying mask...")
} else if elapsedTime < 3.0 {
// Near completion for Vision Framework
targetProgress = 85.0 + ((elapsedTime - 2.0) * 5.0)
updateBGRProgress(targetProgress, status: "Finalizing...")
} else {
// Fallback for slower methods (RMBG with E5RT issues)
targetProgress = min(90.0, 90.0 + ((elapsedTime - 3.0) * 0.5))
updateBGRProgress(targetProgress, status: "Processing...")
}
currentProgress = targetProgress
// Check again in 0.2 seconds for very responsive updates
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
checkForEarlyCompletion()
}
}
// Start progress immediately
updateBGRProgress(5.0, status: "Starting...")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
checkForEarlyCompletion()
}
}
// 🎨 NEW: Rapidly finish progress bar when processing completes
private func finishProgressBar(with result: NSImage?, completion: @escaping (NSImage?) -> Void) {
// Smoothly animate to 100%
updateBGRProgress(95.0, status: "Complete!")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.updateBGRProgress(100.0, status: "Complete!")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
completion(result)
}
}
}
// 🎨 NEW: Wait for actual BGR processing to complete
private func waitForActualCompletion(
checkComplete: @escaping () -> Bool,
getResult: @escaping () -> NSImage?,
completion: @escaping (NSImage?) -> Void
) {
// Check every 0.5 seconds if processing is actually done
func checkCompletion() {
if checkComplete() {
print("🎨 Actual BGR processing detected as complete!")
let result = getResult()
completion(result)
} else {
// Keep waiting and checking
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
checkCompletion()
}
}
}
checkCompletion()
}
// 🎨 NEW: Reset to main thumbnail (exit BGR mode completely)
@objc private func bgrResetButtonClicked() {
guard let originalImg = originalImage else { return }
print("🔄 BGR Reset: Exiting BGR mode and returning to main thumbnail")
// Smooth animation to original image first
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.3
self.currentImageView?.animator().alphaValue = 0.7
}) {
self.currentImageView?.image = originalImg
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.3
self.currentImageView?.animator().alphaValue = 1.0
}) {
// After animation completes, exit BGR mode completely
self.exitBackgroundRemovalMode()
// Reset to normal thumbnail state
self.showNormalThumbnail(with: originalImg)
// 🔄 NEW: Handle post-action completion just like main thumbnail
self.handleBGRActionCompletion(actionType: "reset")
}
}
print("✅ BGR Reset: Returned to main thumbnail - BGR buttons removed")
}
// 🎨 NEW: Show normal thumbnail (no BGR mode)
private func showNormalThumbnail(with image: NSImage) {
print("🔄 Restoring normal thumbnail state...")
// Reset all BGR-specific properties (already done in exitBackgroundRemovalMode)
isBackgroundRemovalMode = false
isShowingProcessedImage = false
// Reset window level to normal
if let window = activePreviewWindow {
window.level = .floating
print("🎨 Reset window level to normal (.floating)")
}
// 🔄 CRITICAL FIX: Enhanced URL validation and recovery
if let originalURL = originalImageURL {
print("🔍 Checking original URL validity: \(originalURL.path)")
// Verify file accessibility
if FileManager.default.fileExists(atPath: originalURL.path) {
// File exists - set it properly
currentImageView?.imageURL = originalURL
print("✅ DraggableImageView reset to verified original URL: \(originalURL.lastPathComponent)")
// Double verify by trying to load the image
if NSImage(contentsOf: originalURL) != nil {
print("✅ Original image file loads successfully")
} else {
print("⚠️ WARNING: Original URL exists but image won't load")
}
} else {
print("❌ CRITICAL: Original file missing at reset time: \(originalURL.path)")
// RECOVERY: Try multiple fallback strategies
var recoveredURL: URL? = nil
// Strategy 1: Check if it's in temp directory
let tempDir = FileManager.default.temporaryDirectory
let fileName = originalURL.lastPathComponent
let tempURL = tempDir.appendingPathComponent(fileName)
if FileManager.default.fileExists(atPath: tempURL.path) {
recoveredURL = tempURL
print("🔄 RECOVERY: Found file in temp directory: \(tempURL.path)")
}
// Strategy 2: Use DraggableImageView's current URL
if recoveredURL == nil, let fallbackURL = currentImageView?.imageURL {
if FileManager.default.fileExists(atPath: fallbackURL.path) {
recoveredURL = fallbackURL
print("🔄 RECOVERY: Using DraggableImageView fallback URL: \(fallbackURL.path)")
}
}
// Strategy 3: Try to recreate the file from the current image
if recoveredURL == nil {
print("🔄 RECOVERY: Attempting to save current image to new temp file...")
let newTempURL = FileManager.default.temporaryDirectory.appendingPathComponent("recovered_\(UUID().uuidString).png")
if let imageData = image.pngData() {
do {
try imageData.write(to: newTempURL)
recoveredURL = newTempURL
print("✅ RECOVERY: Created new temp file: \(newTempURL.path)")
} catch {
print("❌ RECOVERY: Failed to save recovery file: \(error)")
}
}
}
// Apply the recovered URL
if let finalURL = recoveredURL {
originalImageURL = finalURL
currentImageView?.imageURL = finalURL
print("✅ URL RECOVERED: Now using \(finalURL.lastPathComponent)")
} else {
print("❌ RECOVERY FAILED: No valid URL available")
}
}
} else {
print("❌ ERROR: No originalImageURL available for normal thumbnail!")
// Last resort: Use DraggableImageView's current URL
if let fallbackURL = currentImageView?.imageURL {
originalImageURL = fallbackURL
print("🔄 EMERGENCY: Set originalImageURL from DraggableImageView: \(fallbackURL.lastPathComponent)")
}
}
// Reset filename label to original
if let finalURL = originalImageURL,
let filenameLabel = delegate?.findFilenameLabel(in: activePreviewWindow) {
filenameLabel.stringValue = finalURL.lastPathComponent
filenameLabel.toolTip = finalURL.lastPathComponent
print("🎨 Reset filename label to: \(finalURL.lastPathComponent)")
}
print("✅ Normal thumbnail state restored - ready for new actions")
}
// 🎨 NEW: Toggle between original and processed
@objc private func bgrToggleButtonClicked() {
guard let originalImg = originalImage,
let processedImg = processedImage else { return }
// Determine current state and toggle
let nextImage = isShowingProcessedImage ? originalImg : processedImg
isShowingProcessedImage = !isShowingProcessedImage
let nextSymbol = isShowingProcessedImage ? "eye.slash" : "eye"
print("🔄 BGR Toggle: Switching to \(isShowingProcessedImage ? "processed" : "original") image")
// Update image with animation
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.3
self.currentImageView?.animator().alphaValue = 0.7
}) {
self.currentImageView?.image = nextImage
// 🎨 NEW: Update DraggableImageView URL for proper dragging
self.updateDraggableImageURL()
// 🎨 NEW: Update filename label to reflect current image
self.updateBGRFilenameLabel()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.currentImageView?.animator().alphaValue = 1.0
}
}
// Update toggle button icon
let symbolConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
bgrToggleButton?.image = NSImage(systemSymbolName: nextSymbol, accessibilityDescription: nil)?.withSymbolConfiguration(symbolConfig)
}
// 🎨 NEW: Exit BGR mode and return to normal thumbnail
func exitBackgroundRemovalMode() {
print("🎨 Exiting BGR mode")
// 🔄 PRESERVE original image and URL for normal thumbnail to continue working
let preservedOriginalImage = originalImage
let preservedOriginalURL = originalImageURL
// Use the force reset to clean everything up
forceCompleteBGRReset()
// 🔄 CRITICAL: Restore the preserved values for normal thumbnail functionality
originalImage = preservedOriginalImage
originalImageURL = preservedOriginalURL
print("✅ Preserved originalImage and originalImageURL for normal thumbnail: \(originalImageURL?.lastPathComponent ?? "nil")")
// Verify the file still exists and report detailed status
if let originalURL = originalImageURL {
if FileManager.default.fileExists(atPath: originalURL.path) {
print("✅ VERIFIED: Original file exists and is accessible: \(originalURL.path)")
} else {
print("❌ CRITICAL: Original file missing after BGR reset: \(originalURL.path)")
// Try to get alternative URL from DraggableImageView
if let fallbackURL = currentImageView?.imageURL {
print("🔄 Attempting fallback URL: \(fallbackURL.path)")
if FileManager.default.fileExists(atPath: fallbackURL.path) {
originalImageURL = fallbackURL
print("✅ Using fallback URL: \(fallbackURL.path)")
}
}
}
}
}
// 🎨 NEW: Save processed image to file for dragging
private func saveProcessedImageToFile(_ image: NSImage) {
guard let originalURL = originalImageURL else {
print("❌ No original image URL found to save processed image")
return
}
// Create processed image filename
let originalFilename = originalURL.deletingPathExtension().lastPathComponent
let processedFilename = "\(originalFilename)_BGR.png"
let processedURL = originalURL.deletingLastPathComponent().appendingPathComponent(processedFilename)
// Save processed image to file
do {
if let pngData = image.pngData() {
try pngData.write(to: processedURL)
self.processedImageURL = processedURL
print("✅ Processed image saved to: \(processedURL.path)")
} else {
print("❌ Failed to convert processed image to PNG data")
}
} catch {
print("❌ Error saving processed image: \(error)")
}
}
// 🎨 NEW: Update DraggableImageView to use processed image URL
private func updateDraggableImageURL() {
guard isBackgroundRemovalMode else { return }
// Use the correct URL based on which image is currently showing
let currentURL = isShowingProcessedImage ? processedImageURL : originalImageURL
// Update DraggableImageView to use the correct URL
if let url = currentURL {
currentImageView?.imageURL = url
print("✅ DraggableImageView updated to use \(isShowingProcessedImage ? "processed" : "original") image URL: \(url.lastPathComponent)")
} else {
print("❌ No URL available for \(isShowingProcessedImage ? "processed" : "original") image")
}
}
// 🎨 NEW: Update filename label to show original filename in BGR mode
private func updateBGRFilenameLabel() {
guard let originalURL = originalImageURL else {
print("❌ No original image URL found to update filename label")
return
}
// Determine which filename to show based on current image
let displayURL: URL
if isShowingProcessedImage, let processedURL = processedImageURL {
displayURL = processedURL
} else {
displayURL = originalURL
}
// Update filename label to show correct filename in BGR mode
if let filenameLabel = delegate?.findFilenameLabel(in: activePreviewWindow) {
filenameLabel.stringValue = displayURL.lastPathComponent
filenameLabel.toolTip = displayURL.lastPathComponent
print("🎨 BGR: Updated filename label to: \(displayURL.lastPathComponent)")
} else {
print("❌ No filename label found in active preview window")
}
}
// 🎨 NEW: Handle post-action completion just like main thumbnail
private func handleBGRActionCompletion(actionType: String) {
print("🎨 BGR Action Completion: Handling '\(actionType)' action completion")
// BGR reset action should behave like a "cancel" action - no specific closing behavior
// The user explicitly chose to go back to the main thumbnail, so keep it visible
print(" BGR reset action: Keeping thumbnail visible after reset")
}
// 🔄 CRITICAL: Force complete BGR state reset for new screenshots
private func forceCompleteBGRReset() {
print("🔄 FORCE RESET: Starting complete BGR state reset...")
// 1. Force hide any hanging tooltips immediately
bgrResetButton?.forceHideTooltip()
bgrToggleButton?.forceHideTooltip()
// 2. Remove BGR overlay buttons immediately
bgrResetButton?.removeFromSuperview()
bgrToggleButton?.removeFromSuperview()
bgrResetButton = nil
bgrToggleButton = nil
// 3. Remove BGR progress bar immediately
if let progressContainer = bgrProgressContainer {
progressContainer.removeFromSuperview()
bgrProgressContainer = nil
bgrProgressBar = nil
bgrProgressLabel = nil
}
// 4. Clean up BGR file references
if let processedURL = processedImageURL {
try? FileManager.default.removeItem(at: processedURL)
print("🗑️ Force cleaned processed image file: \(processedURL.lastPathComponent)")
}
processedImageURL = nil
// 5. Reset all BGR mode properties to clean state
isBackgroundRemovalMode = false
isBGRTransition = false // 🔄 NEW: Also reset transition flag
originalImage = nil
processedImage = nil
originalImageURL = nil
isShowingProcessedImage = false
// 6. Reset window level if needed
if let window = activePreviewWindow {
window.level = .floating
print("🔄 Reset window level to normal (.floating)")
}
print("✅ FORCE RESET: Complete BGR state reset finished - ready for normal thumbnail")
}
}