🎉 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.
This commit is contained in:
2025-06-28 16:15:15 +02:00
commit 0dabed11d2
63 changed files with 25727 additions and 0 deletions

View File

@@ -0,0 +1,515 @@
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)
}
}
}