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

515 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
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
// MARK: - Grid Window for Action Selection
class GridWindow: NSWindow, NSDraggingDestination {
var cellViews: [GridCellView] = []
weak var gridViewManagerDelegate: GridViewManagerDelegate?
weak var manager: GridViewManager?
private var currentlyHighlightedCell: GridCellView?
private var fadeTimer: Timer?
private let fadeStart: CGFloat = 50 // afstand in px waarbij fading start
private let fadeEnd: CGFloat = 300 // afstand waarbij minimale alpha bereikt is
private let minAlpha: CGFloat = 0 // minimale zichtbaarheid
var isInitialFadingIn: Bool = false
var isPerformingProgrammaticHide: Bool = false // NIEUWE FLAG
init(screen: NSScreen, cellsPerRowInput: Int = 2, manager: GridViewManager, previewFrame: NSRect?) {
self.manager = manager
let settings = SettingsManager.shared
var activeActions: [(index: Int, text: String)] = []
// Build actions based on settings actionOrder to respect user's preferred order
for (gridIndex, actionType) in settings.actionOrder.enumerated() {
let isEnabled: Bool
let displayText: String
switch actionType {
case .rename:
isEnabled = settings.isRenameActionEnabled
displayText = "Rename"
case .stash:
isEnabled = settings.isStashActionEnabled
displayText = "Stash"
case .ocr:
isEnabled = settings.isOCRActionEnabled
displayText = "Text"
case .clipboard:
isEnabled = settings.isClipboardActionEnabled
displayText = "Clipboard"
case .backgroundRemove:
isEnabled = settings.isBackgroundRemoveActionEnabled
if BackgroundRemover.shared.isRMBGModelAvailable() {
displayText = "Remove BG"
} else {
displayText = "Remove BG"
}
case .cancel:
isEnabled = settings.isCancelActionEnabled
displayText = "Cancel"
case .remove:
isEnabled = settings.isRemoveActionEnabled
displayText = "Remove"
}
if isEnabled {
// Use gridIndex as the cellIndex, which will map to actionOrder position
activeActions.append((gridIndex, displayText))
}
}
let numberOfActiveActions = activeActions.count
guard numberOfActiveActions > 0 else {
// Geen acties actief, maak een leeg/onzichtbaar window of handle anders
// Voor nu, een heel klein, onzichtbaar venster en return vroeg.
// Dit voorkomt een crash als numberOfCells 0 is.
super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false)
self.isOpaque = false
self.backgroundColor = .clear
self.ignoresMouseEvents = true // Belangrijk
// Roep manager.hideGrid() aan omdat er geen grid is om te tonen
DispatchQueue.main.async { manager.hideGrid() }
return
}
print("🔷 GridWindow init: Number of active actions = \(numberOfActiveActions)") // Print na guard
// Verticale ActionBar: altijd 1 kolom
let cellsPerRow = 1
let numberOfRows = Int(ceil(Double(numberOfActiveActions) / Double(cellsPerRow)))
let spacing: CGFloat = 8
let fixedCellHeight: CGFloat = 40.0
let fixedCellWidth: CGFloat = 160.0
let calculatedGridWidth = (fixedCellWidth * CGFloat(cellsPerRow)) + (spacing * (CGFloat(cellsPerRow) + 1))
let calculatedGridHeight = (fixedCellHeight * CGFloat(numberOfRows)) + (spacing * (CGFloat(numberOfRows) + 1))
var xPosition: CGFloat
var yPosition: CGFloat
// Oude, incorrecte positionering verwijderd
if let pFrame = previewFrame {
let screenVisibleFrame = screen.visibleFrame
yPosition = pFrame.maxY + spacing
xPosition = screenVisibleFrame.maxX - calculatedGridWidth + 10
// Bounds checking
if xPosition < screenVisibleFrame.minX + spacing { xPosition = screenVisibleFrame.minX + spacing }
if xPosition + calculatedGridWidth > screenVisibleFrame.maxX - spacing { xPosition = screenVisibleFrame.maxX - calculatedGridWidth - spacing }
if yPosition + calculatedGridHeight > screenVisibleFrame.maxY - spacing { yPosition = pFrame.minY - calculatedGridHeight - spacing }
if yPosition < screenVisibleFrame.minY + spacing { yPosition = screenVisibleFrame.minY + spacing }
} else {
let effectiveScreenFrame = screen.visibleFrame
xPosition = (effectiveScreenFrame.width - calculatedGridWidth) / 2 + effectiveScreenFrame.origin.x
yPosition = (effectiveScreenFrame.height - calculatedGridHeight) / 2 + effectiveScreenFrame.origin.y
}
let contentRect = NSRect(x: xPosition, y: yPosition, width: calculatedGridWidth, height: calculatedGridHeight)
super.init(contentRect: contentRect, styleMask: [.borderless], backing: .buffered, defer: false)
self.level = .floating + 2
print("🔷 GridWindow init: Calculated contentRect = \(contentRect)") // Print na super.init
self.isOpaque = false
self.backgroundColor = .clear
self.hasShadow = false // Was false, houd consistent
self.ignoresMouseEvents = false
self.acceptsMouseMovedEvents = true // BELANGRIJK: Voor hover events in subviews
let containerView = NSView(frame: NSRect(origin: .zero, size: contentRect.size))
containerView.wantsLayer = true
// Gedeelde achtergrond voor alle iconen (dubbele blur)
let barBlur1 = NSVisualEffectView()
barBlur1.blendingMode = .behindWindow
barBlur1.material = .hudWindow
barBlur1.state = .active
barBlur1.frame = containerView.bounds
barBlur1.autoresizingMask = [.width, .height]
let barBlur2 = NSVisualEffectView()
barBlur2.blendingMode = .behindWindow
barBlur2.material = .hudWindow
barBlur2.state = .active
barBlur2.alphaValue = 0.6
barBlur2.frame = containerView.bounds
barBlur2.autoresizingMask = [.width, .height]
containerView.addSubview(barBlur1, positioned: .below, relativeTo: nil)
containerView.addSubview(barBlur2, positioned: .below, relativeTo: nil)
containerView.layer?.cornerRadius = 12
containerView.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] // Alle hoeken afgerond
containerView.layer?.masksToBounds = true
self.contentView = containerView
// Maak cellen alleen voor actieve acties
for (gridIndex, action) in activeActions.enumerated() {
let col = gridIndex % cellsPerRow
let row = gridIndex / cellsPerRow
let cellX = spacing + CGFloat(col) * (fixedCellWidth + spacing)
// Y-positie berekend van boven naar beneden voor de grid
let cellY = calculatedGridHeight - spacing - CGFloat(row + 1) * fixedCellHeight - CGFloat(row) * spacing
let cellFrame = NSRect(x: cellX, y: cellY, width: fixedCellWidth, height: fixedCellHeight)
// Gebruik de 'index' van de actie (0 voor Rename, 1 voor Stash, etc.) voor de delegate
let cellView = GridCellView(frame: cellFrame, index: action.index, text: action.text)
containerView.addSubview(cellView)
cellViews.append(cellView)
}
print("🔷 GridWindow init: Number of cellViews created = \(cellViews.count)") // Print na cell creatie
registerForDraggedTypes([.fileURL])
// Start timer voor dynamische transparantie
fadeTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: { [weak self] _ in
self?.updateAlphaBasedOnCursor()
})
}
deinit {
fadeTimer?.invalidate()
}
private func updateAlphaBasedOnCursor() {
guard !isPerformingProgrammaticHide else { return } // NIEUWE CHECK
// NIEUW: Als monitoring uitgeschakeld is (rename actief), houd grid vol zichtbaar
if let manager = self.manager, manager.isMonitoringDisabled {
if abs(self.alphaValue - 1.0) > 0.01 {
self.alphaValue = 1.0 // Forceer volle alpha
}
return // Stop verdere alpha berekening
}
guard !isInitialFadingIn else { return } // <-- CHECK DE FLAG
guard let screenPoint = NSEvent.mouseLocation as NSPoint? else { return }
let windowFrame = self.frame
let distance: CGFloat
if windowFrame.contains(screenPoint) {
distance = 0
} else {
// Bereken kortste afstand van punt naar rechthoek
let dx: CGFloat
if screenPoint.x < windowFrame.minX { dx = windowFrame.minX - screenPoint.x }
else if screenPoint.x > windowFrame.maxX { dx = screenPoint.x - windowFrame.maxX } else { dx = 0 }
let dy: CGFloat
if screenPoint.y < windowFrame.minY { dy = windowFrame.minY - screenPoint.y }
else if screenPoint.y > windowFrame.maxY { dy = screenPoint.y - windowFrame.maxY } else { dy = 0 }
distance = sqrt(dx*dx + dy*dy)
}
let newAlpha: CGFloat
if distance <= fadeStart { newAlpha = 1 }
else if distance >= fadeEnd { newAlpha = minAlpha }
else {
let ratio = (distance - fadeStart) / (fadeEnd - fadeStart)
newAlpha = 1 - ratio * (1 - minAlpha)
}
// Stel alpha direct in zonder animator voor snellere respons
if abs(self.alphaValue - newAlpha) > 0.01 {
self.alphaValue = newAlpha
}
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// MARK: - NSDraggingDestination Methods (HERSTEL DE IMPLEMENTATIES)
func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
// Geef aan dat we een kopieer-operatie accepteren
return .copy
}
func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
let dropLocationInScreen = sender.draggingLocation
// Converteer naar coördinaten binnen de content view van het grid window
guard let dropLocationInContent = self.contentView?.convert(dropLocationInScreen, from: nil) else {
// Als conversie faalt, doe niets (of reset highlight)
currentlyHighlightedCell?.setHighlighted(false)
currentlyHighlightedCell = nil
return []
}
var foundCell: GridCellView? = nil
// Zoek de cel onder de cursor
for cell in cellViews {
if cell.frame.contains(dropLocationInContent) {
foundCell = cell
break
}
}
// Update highlighting alleen als de cel verandert
if currentlyHighlightedCell !== foundCell {
currentlyHighlightedCell?.setHighlighted(false)
currentlyHighlightedCell?.setHovered(false)
foundCell?.setHighlighted(true)
foundCell?.setHovered(true)
currentlyHighlightedCell = foundCell
}
// Geef aan dat we nog steeds een kopieer-operatie accepteren
return .copy
}
func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
// Haal de bestands URL op van het gesleepte item
guard let pasteboard = sender.draggingPasteboard.propertyList(forType: NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType")) as? NSArray,
let path = pasteboard[0] as? String else {
manager?.hideGrid(monitorForReappear: true) // Verberg grid als we de data niet kunnen lezen
return false
}
let imageURL = URL(fileURLWithPath: path)
let dropLocationInContent = self.contentView?.convert(sender.draggingLocation, from: nil) ?? .zero
// Verwijder highlight
currentlyHighlightedCell?.setHighlighted(false)
currentlyHighlightedCell = nil
// Zoek de cel waarop gedropt is
for cell in cellViews {
if cell.frame.contains(dropLocationInContent) {
// Roep de delegate aan als deze bestaat
if let manager = self.manager, let delegate = manager.delegate {
print("✅ GridWindow: Detected drop on cell \(cell.index). Calling delegate...")
delegate.gridViewManager(manager, didDropImage: imageURL, ontoCell: cell.index, at: dropLocationInContent)
// Het verbergen van de grid wordt nu afgehandeld door de delegate completion!
return true // Succesvolle drop
} else {
print("❌ GridWindow: Manager or delegate is nil for drop on cell \(cell.index)!")
}
// Als manager of delegate nil is, faalt de operatie voor deze cel
return false
}
}
// Als er niet op een cel is gedropt
manager?.hideGrid(monitorForReappear: true)
return false // Geen succesvolle drop
}
func draggingExited(_ sender: NSDraggingInfo?) {
// Verwijder highlight en verberg grid als de cursor het window verlaat
currentlyHighlightedCell?.setHighlighted(false)
currentlyHighlightedCell?.setHovered(false)
currentlyHighlightedCell = nil
// Grid blijft zichtbaar; verbergen gebeurt elders als drag eindigt.
}
// mouseUp override (om grid te sluiten bij klikken buiten cellen)
override func mouseUp(with event: NSEvent) {
super.mouseUp(with: event)
if !event.isARepeat {
// NIEUW: Controleer of monitoring is uitgeschakeld (rename actief)
guard let manager = self.manager, !manager.isMonitoringDisabled else {
print("🔶 GridWindow: MouseUp ignored, monitoring is disabled (rename active).")
return
}
let locationInWindow = self.contentView?.convert(event.locationInWindow, from: nil) ?? .zero
let cellUnderMouse = cellViews.first { $0.frame.contains(locationInWindow) }
if cellUnderMouse == nil {
manager.hideGrid(monitorForReappear: true)
}
}
}
}
// MARK: - Grid View Manager
class GridViewManager {
var gridWindow: GridWindow?
weak var delegate: GridViewManagerDelegate?
// Laatste bekende positie waarop de grid rond de preview werd getoond
private var lastPreviewFrame: NSRect?
private var reappearanceTimer: Timer?
// Flag om monitoring uit te schakelen tijdens rename operaties (internal access)
var isMonitoringDisabled: Bool = false
// GEFIXT: Explicit false initialization + reset in init
private var isDragSessionActive: Bool = false
// NIEUW: Initializer om state correct te resetten
init() {
// CRITICAL: Reset all state to ensure consistent behavior between swift run and .app
self.isDragSessionActive = false
self.isMonitoringDisabled = false
self.lastPreviewFrame = nil
print("🔶 GridViewManager: Initialized with clean state - isDragSessionActive: \(self.isDragSessionActive)")
}
// MARK: - Show Grid
func showGrid(previewFrame: NSRect?) {
print("🔶 MAIN DEBUG: GridViewManager: showGrid called for MAIN THUMBNAIL")
print("🔶 MAIN DEBUG: This is MAIN app grid (NOT stash grid)")
print("🔶 MAIN DEBUG: PreviewFrame: \(String(describing: previewFrame))")
// Annuleer eventueel lopende timers grid is alweer zichtbaar
reappearanceTimer?.invalidate(); reappearanceTimer = nil
self.lastPreviewFrame = previewFrame
if let existingWindow = gridWindow {
print("🔶 MAIN DEBUG: Closing existing main grid window")
existingWindow.isPerformingProgrammaticHide = false // Reset voor het geval het vastzat
existingWindow.orderOut(nil as Any?)
self.gridWindow = nil
}
// Bepaal het juiste scherm op basis van waar de thumbnail zich bevindt
let screen: NSScreen
if let pFrame = previewFrame {
// Zoek het scherm dat de thumbnail bevat
let thumbnailCenter = NSPoint(x: pFrame.midX, y: pFrame.midY)
if let thumbnailScreen = NSScreen.screens.first(where: { $0.frame.contains(thumbnailCenter) }) {
screen = thumbnailScreen
print("🔶 MAIN DEBUG: Using thumbnail screen for main grid: \(thumbnailScreen.localizedName)")
} else {
// Fallback naar hoofdscherm als thumbnail scherm niet gevonden
screen = NSScreen.main ?? NSScreen.screens.first!
print("🔶 MAIN DEBUG: Thumbnail screen not found, using fallback: \(screen.localizedName)")
}
} else {
// Geen preview frame, gebruik hoofdscherm
screen = NSScreen.main ?? NSScreen.screens.first!
print("🔶 MAIN DEBUG: No preview frame, using main screen: \(screen.localizedName)")
}
print("🔶 MAIN DEBUG: Creating NEW GridWindow (main app grid, not stash)")
gridWindow = GridWindow(screen: screen, manager: self, previewFrame: previewFrame)
gridWindow?.gridViewManagerDelegate = self.delegate
gridWindow?.alphaValue = 0
gridWindow?.makeKeyAndOrderFront(nil as Any?) // Zorg ervoor dat het key window wordt voor events
gridWindow?.orderFrontRegardless()
gridWindow?.isInitialFadingIn = true // <-- ZET FLAG VOOR ANIMATIE
print("🔶 MAIN DEBUG: Animating main grid appearance")
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 1.0 // AANGEPAST van 0.2 naar 1.0
self.gridWindow?.animator().alphaValue = 1
} completionHandler: { // <-- COMPLETION HANDLER TOEGEVOEGD
self.gridWindow?.isInitialFadingIn = false // <-- RESET FLAG NA ANIMATIE
}
}
// MARK: - Hide Grid
func hideGrid(monitorForReappear: Bool = false) {
print("🔶 MAIN DEBUG: GridViewManager: hideGrid called for MAIN THUMBNAIL")
print("🔶 MAIN DEBUG: monitorForReappear = \(monitorForReappear)")
guard let window = gridWindow else {
print("🔶 MAIN DEBUG: No main grid window to hide")
return
}
window.isPerformingProgrammaticHide = true // ZET FLAG VOOR ANIMATIE
// Stop een bestaande timer zodat we niet meerdere tegelijk hebben
if !monitorForReappear {
reappearanceTimer?.invalidate(); reappearanceTimer = nil
}
print("🔶 MAIN DEBUG: Hiding main grid window")
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.8 // Teruggezet naar 0.8s
window.animator().alphaValue = 0
}, completionHandler: { [weak self] in
guard let self = self else { return }
print("🔶 MAIN DEBUG: Main grid hide animation complete")
window.orderOut(nil as Any?)
if self.gridWindow === window {
self.gridWindow = nil
}
// Reset flag na animatie en orderOut, maar VOOR potentiële startReappearanceMonitor
// Echter, window referentie is nu mogelijk nil als self.gridWindow === window was.
// Het is veiliger om de flag te resetten via een referentie die nog geldig is, of de logica herzien.
// Voor nu: als window nog bestaat (niet de self.gridWindow was die nil werd), reset het.
// Maar de window instance zelf wordt niet direct nil. We kunnen het nog steeds gebruiken.
window.isPerformingProgrammaticHide = false
// Start monitor na het verbergen ALLEEN als monitoring niet uitgeschakeld is
if monitorForReappear && !self.isMonitoringDisabled {
self.startReappearanceMonitor()
}
})
}
// NIEUW: Methodes om monitoring te controleren
func disableMonitoring() {
print("🔶 GridViewManager: Monitoring disabled (e.g., during rename)")
isMonitoringDisabled = true
// NIEUW: Stop de reappearance timer volledig
reappearanceTimer?.invalidate()
reappearanceTimer = nil
print("🔶 GridViewManager: Reappearance timer stopped and invalidated")
}
func enableMonitoring() {
print("🔶 GridViewManager: Monitoring enabled")
isMonitoringDisabled = false
// GEEN automatische herstart van timer hier - alleen als grid opnieuw wordt verborgen
}
// NIEUW: Start drag session - schakelt proximity monitoring in
func startDragSession() {
print("🔶 GridViewManager: Drag session started - enabling proximity monitoring")
isDragSessionActive = true
}
// NIEUW: Stop drag session - schakelt proximity monitoring uit
func stopDragSession() {
print("🔶 GridViewManager: Drag session ended - disabling proximity monitoring")
isDragSessionActive = false
// Stop proximity timer als er geen drag actief is
reappearanceTimer?.invalidate()
reappearanceTimer = nil
}
// MARK: - Monitoring Logic
private func startReappearanceMonitor() {
// GEFIXT: Check of monitoring uitgeschakeld is EN of er een actieve drag sessie is
// CRITICAL: Only start proximity monitoring during active drag sessions
guard !isMonitoringDisabled && isDragSessionActive else {
print("🔶 GridViewManager: Skipping reappearance monitor - monitoring disabled: \(isMonitoringDisabled), drag active: \(isDragSessionActive)")
print("🔶 GridViewManager: This prevents grid from triggering without actual drag")
return
}
// Safety: invalideer vorige timer
reappearanceTimer?.invalidate()
guard let targetFrame = lastPreviewFrame else {
print("🔶 GridViewManager: No previewFrame skipping reappearance monitor.")
return
}
print("🔶 GridViewManager: Starting proximity monitoring (only during active drag)")
reappearanceTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in
self?.evaluateMouseProximity(to: targetFrame)
}
RunLoop.main.add(reappearanceTimer!, forMode: .common)
}
private func evaluateMouseProximity(to frame: NSRect) {
// GEFIXT: Check of monitoring uitgeschakeld is EN of er een actieve drag sessie is
// CRITICAL: Only evaluate proximity during active drag sessions
guard !isMonitoringDisabled && isDragSessionActive else {
print("🔶 GridViewManager: Skipping proximity evaluation - monitoring disabled: \(isMonitoringDisabled), drag active: \(isDragSessionActive)")
// NIEUW: Stop timer when drag session is not active
reappearanceTimer?.invalidate()
reappearanceTimer = nil
return
}
// Bereken een vergrote zone (200 px marge) rondom de preview
let expansion: CGFloat = 200
let enlarged = frame.insetBy(dx: -expansion, dy: -expansion)
let currentLoc = NSEvent.mouseLocation
if enlarged.contains(currentLoc) {
// Cursor is weer in de buurt toon grid opnieuw
print("🔶 GridViewManager: Mouse near preview showing grid again.")
self.showGrid(previewFrame: self.lastPreviewFrame)
}
}
}