🚀 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.
711 lines
32 KiB
Swift
Executable File
711 lines
32 KiB
Swift
Executable File
import AppKit
|
||
import UniformTypeIdentifiers
|
||
|
||
// NIEUW: Voeg ConflictType enum definitie toe
|
||
enum ConflictType {
|
||
case renameOnly
|
||
case saveToFolder
|
||
// TODO: case moveToApplications // Als dit later nodig is
|
||
}
|
||
|
||
// NIEUW: Custom TextFieldCell voor verticale aanpassing van tekst
|
||
class VerticallyAdjustedTextFieldCell: NSTextFieldCell {
|
||
var verticalOffset: CGFloat = 0
|
||
// didSet is hier mogelijk niet effectief genoeg, we forceren de update in drawingRect
|
||
// {
|
||
// didSet {
|
||
// self.controlView?.needsDisplay = true
|
||
// }
|
||
// }
|
||
|
||
override func drawingRect(forBounds rect: NSRect) -> NSRect {
|
||
var drawingRect = super.drawingRect(forBounds: rect)
|
||
// Een positieve offset verplaatst de tekst naar beneden
|
||
drawingRect.origin.y = drawingRect.origin.y + verticalOffset
|
||
if let controlView = self.controlView {
|
||
controlView.setNeedsDisplay(controlView.bounds) // Correcte aanroep met bounds
|
||
}
|
||
return drawingRect
|
||
}
|
||
}
|
||
|
||
// NIEUW: Custom Button klasse met icoon en subtiele tekst
|
||
class IconTextButton: NSButton {
|
||
|
||
convenience init(sfSymbolName: String, title: String, target: AnyObject?, action: Selector?) {
|
||
self.init()
|
||
|
||
self.target = target
|
||
self.action = action
|
||
|
||
let symbolConfig = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular)
|
||
if let iconImage = NSImage(systemSymbolName: sfSymbolName, accessibilityDescription: title)?.withSymbolConfiguration(symbolConfig) {
|
||
self.image = iconImage
|
||
}
|
||
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.alignment = .center
|
||
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.foregroundColor: NSColor(white: 0.90, alpha: 0.85),
|
||
.font: NSFont.systemFont(ofSize: 10, weight: .medium),
|
||
.paragraphStyle: paragraphStyle
|
||
]
|
||
self.attributedTitle = NSAttributedString(string: title, attributes: attributes)
|
||
|
||
self.setButtonType(.momentaryPushIn)
|
||
self.isBordered = false
|
||
self.imagePosition = .imageLeading
|
||
self.imageHugsTitle = true
|
||
self.alignment = .center
|
||
|
||
self.wantsLayer = true
|
||
self.layer?.backgroundColor = NSColor.clear.cgColor
|
||
self.layer?.cornerRadius = 5
|
||
|
||
(self.cell as? NSButtonCell)?.setAccessibilityLabel(title)
|
||
}
|
||
|
||
private var trackingArea: NSTrackingArea?
|
||
|
||
override func updateTrackingAreas() {
|
||
super.updateTrackingAreas()
|
||
if let existingTrackingArea = self.trackingArea {
|
||
self.removeTrackingArea(existingTrackingArea)
|
||
}
|
||
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways, .inVisibleRect]
|
||
trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||
if let ta = trackingArea { self.addTrackingArea(ta) }
|
||
}
|
||
|
||
override func mouseEntered(with event: NSEvent) {
|
||
super.mouseEntered(with: event)
|
||
NSAnimationContext.runAnimationGroup({ context in
|
||
context.duration = 0.15
|
||
context.allowsImplicitAnimation = true
|
||
self.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.15).cgColor
|
||
}, completionHandler: nil)
|
||
}
|
||
|
||
override func mouseExited(with event: NSEvent) {
|
||
super.mouseExited(with: event)
|
||
NSAnimationContext.runAnimationGroup({ context in
|
||
context.duration = 0.2
|
||
context.allowsImplicitAnimation = true
|
||
self.layer?.backgroundColor = NSColor.clear.cgColor
|
||
}, completionHandler: nil)
|
||
}
|
||
|
||
override var intrinsicContentSize: NSSize {
|
||
var size = super.intrinsicContentSize
|
||
size.width += 16 // Extra padding: 8pt left, 8pt right
|
||
size.height = 24
|
||
return size
|
||
}
|
||
}
|
||
|
||
// Custom Panel for Rename Input
|
||
class RenamePromptPanel: NSPanel, NSTextFieldDelegate {
|
||
var textField: NSTextField!
|
||
var saveNameButton: IconTextButton!
|
||
var saveToFolderButton: IconTextButton!
|
||
var cancelButton: IconTextButton!
|
||
|
||
var enteredName: String? { textField.stringValue }
|
||
let textFieldTag = 101
|
||
|
||
var onAction: ((NSApplication.ModalResponse) -> Void)?
|
||
var animationStartFrame: NSRect?
|
||
// private var keepKeyTimer: Timer? // Uitgecommentarieerd
|
||
|
||
weak var actionHandlerDelegate: RenameActionHandlerDelegate?
|
||
|
||
override var acceptsFirstResponder: Bool {
|
||
return true
|
||
}
|
||
override var canBecomeKey: Bool { true }
|
||
override var canBecomeMain: Bool { true }
|
||
|
||
override func keyDown(with event: NSEvent) {
|
||
if event.keyCode == 53 { // ESC key
|
||
closeRenamePanelAndCleanup(response: .cancel)
|
||
return
|
||
}
|
||
super.keyDown(with: event)
|
||
}
|
||
|
||
private func closeRenamePanelAndCleanup(response: NSApplication.ModalResponse) {
|
||
print("🧼 DEBUG: closeRenamePanelAndCleanup called with response: \(response)")
|
||
// keepKeyTimer?.invalidate() // Uitgecommentarieerd
|
||
// keepKeyTimer = nil // Uitgecommentarieerd
|
||
|
||
NSAnimationContext.runAnimationGroup({ context in
|
||
context.duration = 0.4
|
||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||
self.animator().alphaValue = 0
|
||
if let startFrame = self.animationStartFrame {
|
||
let endFrame = NSRect(x: startFrame.midX - 25, y: startFrame.midY - 12.5, width: 50, height: 25)
|
||
self.animator().setFrame(endFrame, display: true)
|
||
}
|
||
}, completionHandler: {
|
||
self.orderOut(nil)
|
||
self.alphaValue = 1
|
||
|
||
self.actionHandlerDelegate?.enableGridMonitoring()
|
||
self.actionHandlerDelegate?.hideGrid()
|
||
|
||
self.onAction?(response)
|
||
})
|
||
}
|
||
|
||
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
|
||
super.init(contentRect: contentRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: backingStoreType, defer: flag)
|
||
|
||
self.isFloatingPanel = true
|
||
self.level = .floating + 3 // Teruggezet
|
||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||
self.isOpaque = false
|
||
self.backgroundColor = NSColor.clear
|
||
self.hasShadow = false
|
||
self.animationBehavior = .utilityWindow
|
||
self.becomesKeyOnlyIfNeeded = true
|
||
self.hidesOnDeactivate = false
|
||
|
||
let visualEffectView = NSVisualEffectView()
|
||
visualEffectView.blendingMode = .behindWindow
|
||
visualEffectView.material = .hudWindow
|
||
visualEffectView.state = .active
|
||
visualEffectView.wantsLayer = true
|
||
visualEffectView.layer?.cornerRadius = 12.0
|
||
visualEffectView.layer?.masksToBounds = true
|
||
visualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||
self.contentView = visualEffectView
|
||
|
||
let horizontalPadding: CGFloat = 10
|
||
let verticalPadding: CGFloat = 15
|
||
let textFieldHeight: CGFloat = 19
|
||
let spacingBelowTextField: CGFloat = 12
|
||
let buttonSpacing: CGFloat = 8
|
||
let buttonHeight: CGFloat = 24
|
||
|
||
textField = NSTextField()
|
||
textField.tag = textFieldTag
|
||
textField.delegate = self
|
||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||
visualEffectView.addSubview(textField)
|
||
textFieldStyling()
|
||
|
||
saveNameButton = IconTextButton(sfSymbolName: "checkmark.circle", title: " Save", target: self, action: #selector(buttonClicked(_:)))
|
||
saveNameButton.tag = NSApplication.ModalResponse.OK.rawValue
|
||
saveNameButton.translatesAutoresizingMaskIntoConstraints = false
|
||
visualEffectView.addSubview(saveNameButton)
|
||
|
||
saveToFolderButton = IconTextButton(sfSymbolName: "folder.badge.plus", title: " Save", target: self, action: #selector(buttonClicked(_:)))
|
||
saveToFolderButton.tag = NSApplication.ModalResponse.continue.rawValue
|
||
saveToFolderButton.translatesAutoresizingMaskIntoConstraints = false
|
||
visualEffectView.addSubview(saveToFolderButton)
|
||
|
||
cancelButton = IconTextButton(sfSymbolName: "xmark.circle", title: " Cancel", target: self, action: #selector(buttonClicked(_:)))
|
||
cancelButton.tag = NSApplication.ModalResponse.cancel.rawValue
|
||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||
visualEffectView.addSubview(cancelButton)
|
||
|
||
let buttons = [saveNameButton!, saveToFolderButton!, cancelButton!]
|
||
let buttonStackView = NSStackView(views: buttons)
|
||
buttonStackView.orientation = .horizontal
|
||
buttonStackView.alignment = .centerY
|
||
buttonStackView.spacing = buttonSpacing
|
||
buttonStackView.distribution = .fillProportionally
|
||
buttonStackView.translatesAutoresizingMaskIntoConstraints = false
|
||
visualEffectView.addSubview(buttonStackView)
|
||
|
||
let minPanelWidth: CGFloat = 280
|
||
let calculatedPanelHeight = verticalPadding + textFieldHeight + spacingBelowTextField + buttonHeight + verticalPadding
|
||
self.setContentSize(NSSize(width: minPanelWidth, height: calculatedPanelHeight))
|
||
|
||
NSLayoutConstraint.activate([
|
||
visualEffectView.leadingAnchor.constraint(equalTo: (self.contentView!).leadingAnchor),
|
||
visualEffectView.trailingAnchor.constraint(equalTo: (self.contentView!).trailingAnchor),
|
||
visualEffectView.topAnchor.constraint(equalTo: (self.contentView!).topAnchor),
|
||
visualEffectView.bottomAnchor.constraint(equalTo: (self.contentView!).bottomAnchor),
|
||
|
||
textField.topAnchor.constraint(equalTo: visualEffectView.topAnchor, constant: verticalPadding),
|
||
textField.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor, constant: horizontalPadding),
|
||
textField.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor, constant: -horizontalPadding),
|
||
textField.heightAnchor.constraint(equalToConstant: textFieldHeight),
|
||
|
||
buttonStackView.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: spacingBelowTextField),
|
||
buttonStackView.centerXAnchor.constraint(equalTo: visualEffectView.centerXAnchor),
|
||
buttonStackView.heightAnchor.constraint(equalToConstant: buttonHeight),
|
||
buttonStackView.leadingAnchor.constraint(greaterThanOrEqualTo: visualEffectView.leadingAnchor, constant: horizontalPadding),
|
||
buttonStackView.trailingAnchor.constraint(lessThanOrEqualTo: visualEffectView.trailingAnchor, constant: -horizontalPadding),
|
||
buttonStackView.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor, constant: -verticalPadding)
|
||
])
|
||
|
||
let stackViewWidthConstraint = buttonStackView.widthAnchor.constraint(lessThanOrEqualToConstant: minPanelWidth - 2 * horizontalPadding)
|
||
stackViewWidthConstraint.priority = .defaultHigh
|
||
stackViewWidthConstraint.isActive = true
|
||
|
||
// keepKeyTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(ensureKeyWindow), userInfo: nil, repeats: true) // Uitgecommentarieerd
|
||
self.initialFirstResponder = textField
|
||
}
|
||
|
||
private func textFieldStyling() {
|
||
textField.font = NSFont.systemFont(ofSize: 13)
|
||
textField.textColor = NSColor(white: 0.95, alpha: 1.0)
|
||
textField.isEditable = true
|
||
textField.isSelectable = true
|
||
textField.isBordered = false
|
||
textField.isBezeled = false
|
||
textField.backgroundColor = NSColor.clear
|
||
textField.focusRingType = .none
|
||
|
||
textField.wantsLayer = true
|
||
textField.layer?.cornerRadius = 6.0
|
||
textField.layer?.masksToBounds = true
|
||
textField.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.25).cgColor
|
||
textField.layer?.borderColor = NSColor.gray.withAlphaComponent(0.4).cgColor
|
||
textField.layer?.borderWidth = 0.5
|
||
|
||
let cell = textField.cell as? NSTextFieldCell
|
||
cell?.usesSingleLineMode = true
|
||
cell?.wraps = false
|
||
cell?.isScrollable = true
|
||
cell?.isEditable = true
|
||
cell?.isSelectable = true
|
||
|
||
// Set alignment AFTER configuring the cell
|
||
textField.alignment = .center
|
||
if let standardCell = textField.cell as? NSTextFieldCell { // Controleer of het een NSTextFieldCell is
|
||
standardCell.alignment = .center
|
||
}
|
||
}
|
||
|
||
@objc func ensureKeyWindow() {
|
||
if !self.isKeyWindow {
|
||
self.makeKeyAndOrderFront(nil)
|
||
DispatchQueue.main.async {
|
||
_ = self.makeFirstResponder(self.textField)
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc func buttonClicked(_ sender: NSButton) {
|
||
let response = NSApplication.ModalResponse(rawValue: sender.tag)
|
||
print("🔍 DEBUG: RenamePromptPanel button clicked with tag: \(sender.tag), mapped to response: \(response)")
|
||
closeRenamePanelAndCleanup(response: response)
|
||
}
|
||
|
||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||
if commandSelector == #selector(insertNewline(_:)) {
|
||
if let okButton = saveNameButton {
|
||
buttonClicked(okButton)
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
|
||
}
|
||
|
||
protocol RenameActionHandlerDelegate: AnyObject {
|
||
func getScreenshotFolder() -> String?
|
||
func renameActionHandler(_ handler: RenameActionHandler, didRenameFileFrom oldURL: URL, to newURL: URL)
|
||
func findFilenameLabel(in window: NSWindow?) -> NSTextField?
|
||
func setTempFileURL(_ url: URL?)
|
||
func getActivePreviewWindow() -> NSWindow?
|
||
func closePreviewWithAnimation(immediate: Bool, preserveTempFile: Bool)
|
||
func getGridWindowFrame() -> NSRect?
|
||
func hideGrid()
|
||
func disableGridMonitoring()
|
||
func enableGridMonitoring()
|
||
}
|
||
|
||
class RenameActionHandler {
|
||
weak var delegate: RenameActionHandlerDelegate?
|
||
private var renamePanel: RenamePromptPanel?
|
||
|
||
init(delegate: RenameActionHandlerDelegate) {
|
||
self.delegate = delegate
|
||
}
|
||
|
||
func isRenamePanelActive() -> Bool {
|
||
return renamePanel?.isVisible == true
|
||
}
|
||
|
||
func closeRenamePanelAndCleanup() {
|
||
if let panel = self.renamePanel, panel.isVisible {
|
||
print("ℹ️ Closing existing rename panel due to new action.")
|
||
|
||
if let panelCancelButton = panel.cancelButton {
|
||
panel.buttonClicked(panelCancelButton)
|
||
} else {
|
||
print("⚠️ Could not find cancel button in rename panel to simulate click.")
|
||
self.renamePanel = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sanitize(name: String) -> String {
|
||
var characterSet = CharacterSet.alphanumerics
|
||
characterSet.insert(charactersIn: "-_")
|
||
let sanitized = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.components(separatedBy: characterSet.inverted).joined()
|
||
return sanitized.isEmpty ? "screenshot" : sanitized
|
||
}
|
||
|
||
func promptAndRename(originalURL: URL, completion: @escaping (NSApplication.ModalResponse) -> Void) {
|
||
print("🔍 DEBUG: promptAndRename called with URL: \(originalURL)")
|
||
print("🔍 DEBUG: Current working directory: \(FileManager.default.currentDirectoryPath)")
|
||
print("🔍 DEBUG: Original URL exists: \(FileManager.default.fileExists(atPath: originalURL.path))")
|
||
|
||
if let existingPanel = self.renamePanel, existingPanel.isVisible {
|
||
print("ℹ️ Rename panel already visible. Bringing to front.")
|
||
existingPanel.makeKeyAndOrderFront(nil)
|
||
completion(.cancel)
|
||
return
|
||
}
|
||
|
||
guard let handlerDelegate = self.delegate else {
|
||
print("❌ RenameActionHandler: handlerDelegate (for self) not set.")
|
||
completion(.cancel)
|
||
return
|
||
}
|
||
|
||
let tempFilename = originalURL.deletingPathExtension().lastPathComponent
|
||
|
||
let gridFrame = handlerDelegate.getGridWindowFrame()
|
||
let panel = RenamePromptPanel(animationStartFrame: gridFrame)
|
||
|
||
panel.actionHandlerDelegate = handlerDelegate
|
||
panel.textField.stringValue = tempFilename
|
||
self.renamePanel = panel
|
||
|
||
panel.onAction = { [weak self] response in
|
||
guard let self = self else {
|
||
print("🔍 DEBUG: self is nil in onAction callback")
|
||
completion(.cancel)
|
||
return
|
||
}
|
||
|
||
print("🔍 DEBUG: RenamePanel onAction called with response: \(response)")
|
||
let enteredName = self.renamePanel?.enteredName ?? ""
|
||
print("🔍 DEBUG: enteredName: '\(enteredName)'")
|
||
var actionResponse = response
|
||
|
||
if response == .OK {
|
||
print("🔍 DEBUG: Processing Save Name Only action")
|
||
_ = self.performRename(originalURL: originalURL, newNameInput: enteredName)
|
||
} else if response == .continue {
|
||
print("🔍 DEBUG: Processing Save to Folder action")
|
||
if let renamedURL = self.performRename(originalURL: originalURL, newNameInput: enteredName) {
|
||
self.saveToDesignatedFolder(fileURL: renamedURL, desiredName: enteredName)
|
||
} else {
|
||
print("Rename failed or was cancelled, cannot proceed to Save to Folder.")
|
||
actionResponse = .cancel
|
||
}
|
||
} else {
|
||
print("🔍 DEBUG: Processing Cancel action or unknown response: \(response)")
|
||
}
|
||
|
||
print("🔍 DEBUG: Calling completion handler with actionResponse: \(actionResponse)")
|
||
completion(actionResponse)
|
||
if response != .OK && response != .continue {
|
||
self.renamePanel = nil
|
||
}
|
||
}
|
||
|
||
let previewFrame = handlerDelegate.getActivePreviewWindow()?.frame
|
||
let panelSize = panel.frame.size
|
||
|
||
var finalFrame = panel.frame
|
||
|
||
if let currentGridFrame = gridFrame {
|
||
let spacing: CGFloat = 20
|
||
finalFrame.origin.x = currentGridFrame.origin.x - panelSize.width - spacing
|
||
finalFrame.origin.y = currentGridFrame.origin.y + (currentGridFrame.height - panelSize.height) / 2
|
||
finalFrame.size = panelSize
|
||
|
||
let targetScreenForGrid = NSScreen.screens.first { $0.frame.intersects(currentGridFrame) } ?? NSScreen.main ?? NSScreen.screens.first!
|
||
let screenVisibleFrame = targetScreenForGrid.visibleFrame
|
||
|
||
if finalFrame.origin.x < screenVisibleFrame.origin.x {
|
||
finalFrame.origin.x = currentGridFrame.maxX + spacing
|
||
}
|
||
if finalFrame.origin.y < screenVisibleFrame.origin.y {
|
||
finalFrame.origin.y = screenVisibleFrame.origin.y + 50
|
||
}
|
||
if finalFrame.maxY > screenVisibleFrame.maxY {
|
||
finalFrame.origin.y = screenVisibleFrame.maxY - panelSize.height - 50
|
||
}
|
||
} else {
|
||
let previewScreen = handlerDelegate.getActivePreviewWindow()?.screen
|
||
let mouseScreen = NSScreen.screenWithMouse()
|
||
let targetScreenForFallback = previewScreen ?? mouseScreen ?? NSScreen.main ?? NSScreen.screens.first!
|
||
let screenVisibleFrame = targetScreenForFallback.visibleFrame
|
||
finalFrame.origin.x = screenVisibleFrame.origin.x + (screenVisibleFrame.width - panelSize.width) / 2
|
||
finalFrame.origin.y = screenVisibleFrame.origin.y + (screenVisibleFrame.height - panelSize.height) / 2
|
||
finalFrame.size = panelSize
|
||
}
|
||
|
||
panel.alphaValue = 0.0
|
||
let startFrameForAnimation: NSRect
|
||
|
||
if let currentGridFrame = gridFrame {
|
||
startFrameForAnimation = NSRect(x: currentGridFrame.midX - panelSize.width / 2,
|
||
y: currentGridFrame.midY - panelSize.height / 2,
|
||
width: panelSize.width,
|
||
height: panelSize.height)
|
||
} else if let previewFrame = previewFrame {
|
||
startFrameForAnimation = NSRect(x: previewFrame.midX - panelSize.width / 2,
|
||
y: previewFrame.midY - panelSize.height / 2,
|
||
width: panelSize.width,
|
||
height: panelSize.height)
|
||
} else {
|
||
startFrameForAnimation = finalFrame.offsetBy(dx: 0, dy: 50)
|
||
}
|
||
panel.setFrame(startFrameForAnimation, display: false)
|
||
panel.animationStartFrame = startFrameForAnimation
|
||
|
||
panel.orderFront(nil)
|
||
handlerDelegate.disableGridMonitoring()
|
||
|
||
NSAnimationContext.runAnimationGroup({
|
||
context in
|
||
context.duration = 0.3
|
||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||
panel.animator().alphaValue = 1.0
|
||
panel.animator().setFrame(finalFrame, display: true)
|
||
}, completionHandler: {
|
||
print("✨ Panel animation complete. Current panel alpha: \(panel.alphaValue), isVisible: \(panel.isVisible), frame: \(panel.frame)")
|
||
panel.makeKey()
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
if panel.isVisible {
|
||
if panel.alphaValue < 0.1 {
|
||
print("⚠️ Panel is visible but alpha is very low. Might be a ghost panel.")
|
||
}
|
||
} else {
|
||
print("⚠️ Panel disappeared unexpectedly after animation.")
|
||
self.renamePanel = nil
|
||
completion(.cancel)
|
||
}
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
if let field = panel.textField {
|
||
if panel.firstResponder != field {
|
||
if panel.makeFirstResponder(field) {
|
||
field.currentEditor()?.selectedRange = NSRange(location: 0, length: field.stringValue.count)
|
||
print("⌨️ DEBUG: TextField is first responder, text selected.")
|
||
} else {
|
||
print("⚠️ DEBUG: Failed to make TextField first responder.")
|
||
}
|
||
} else {
|
||
print("⌨️ DEBUG: TextField was already first responder.")
|
||
field.currentEditor()?.selectedRange = NSRange(location: 0, length: field.stringValue.count)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
private func performRename(originalURL: URL, newNameInput: String) -> URL? {
|
||
guard let handlerDelegateRef = delegate else { return nil }
|
||
|
||
let currentNameWithoutExt = originalURL.deletingPathExtension().lastPathComponent
|
||
let sanitizedNewName = self.sanitize(name: newNameInput)
|
||
|
||
if newNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sanitizedNewName == currentNameWithoutExt.replacingOccurrences(of: "screenshot_", with: "").replacingOccurrences(of: ".", with: "_") {
|
||
print("ℹ️ Name not changed or empty, using original: \(originalURL.lastPathComponent)")
|
||
handlerDelegateRef.setTempFileURL(originalURL)
|
||
DispatchQueue.main.async {
|
||
if let label = handlerDelegateRef.findFilenameLabel(in: handlerDelegateRef.getActivePreviewWindow()) {
|
||
label.stringValue = originalURL.lastPathComponent
|
||
label.toolTip = originalURL.lastPathComponent
|
||
}
|
||
}
|
||
return originalURL
|
||
}
|
||
|
||
let fileManager = FileManager.default
|
||
let directory = originalURL.deletingLastPathComponent()
|
||
let newURL = directory.appendingPathComponent(sanitizedNewName).appendingPathExtension(originalURL.pathExtension.isEmpty ? "png" : originalURL.pathExtension)
|
||
|
||
if fileManager.fileExists(atPath: newURL.path) {
|
||
print("⚠️ Rename failed: File already exists at \(newURL.path)")
|
||
DispatchQueue.main.async {
|
||
let conflictAlert = NSAlert()
|
||
conflictAlert.messageText = "File Exists"
|
||
conflictAlert.informativeText = "A file named \"\(newURL.lastPathComponent)\" already exists. Please use a different name."
|
||
conflictAlert.addButton(withTitle: "OK")
|
||
if let panelWindow = self.renamePanel {
|
||
conflictAlert.beginSheetModal(for: panelWindow, completionHandler: nil)
|
||
} else {
|
||
conflictAlert.runModal()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
do {
|
||
try fileManager.moveItem(at: originalURL, to: newURL)
|
||
print("✅ Temp File renamed from \(originalURL.lastPathComponent) to \(newURL.lastPathComponent)")
|
||
handlerDelegateRef.setTempFileURL(newURL)
|
||
DispatchQueue.main.async {
|
||
if let label = handlerDelegateRef.findFilenameLabel(in: handlerDelegateRef.getActivePreviewWindow()) {
|
||
label.stringValue = newURL.lastPathComponent
|
||
label.toolTip = newURL.lastPathComponent
|
||
}
|
||
}
|
||
return newURL
|
||
} catch {
|
||
print("❌ Error renaming temp file: \(error)")
|
||
DispatchQueue.main.async {
|
||
let errorAlert = NSAlert(error: error)
|
||
if let panelWindow = self.renamePanel {
|
||
errorAlert.beginSheetModal(for: panelWindow, completionHandler: nil)
|
||
} else {
|
||
errorAlert.runModal()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func saveToDesignatedFolder(fileURL: URL, desiredName: String) {
|
||
guard let handlerDelegateRef = delegate else { return }
|
||
guard let destinationFolderStr = handlerDelegateRef.getScreenshotFolder(),
|
||
!destinationFolderStr.isEmpty else {
|
||
DispatchQueue.main.async {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Folder Not Set"
|
||
alert.informativeText = "Please set a default save folder in Settings."
|
||
alert.addButton(withTitle: "OK")
|
||
if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel {
|
||
alert.beginSheetModal(for: hostWindow, completionHandler: nil)
|
||
} else {
|
||
alert.runModal()
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
let finalFilename = fileURL.lastPathComponent
|
||
|
||
let fileManager = FileManager.default
|
||
let destinationFolderURL = URL(fileURLWithPath: destinationFolderStr)
|
||
let destinationURL = destinationFolderURL.appendingPathComponent(finalFilename)
|
||
|
||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||
print("⚠️ Save failed: File already exists at \(destinationURL.path)")
|
||
DispatchQueue.main.async {
|
||
let conflictAlert = NSAlert()
|
||
conflictAlert.messageText = "File Exists in Destination"
|
||
conflictAlert.informativeText = "A file named \"\(destinationURL.lastPathComponent)\" already exists in the destination folder \"\(destinationFolderURL.lastPathComponent)\"."
|
||
conflictAlert.addButton(withTitle: "OK")
|
||
if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel {
|
||
conflictAlert.beginSheetModal(for: hostWindow, completionHandler: nil)
|
||
} else {
|
||
conflictAlert.runModal()
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
do {
|
||
try fileManager.moveItem(at: fileURL, to: destinationURL)
|
||
print("✅ Screenshot moved to: \(destinationURL.path)")
|
||
handlerDelegateRef.setTempFileURL(nil)
|
||
|
||
DispatchQueue.main.async {
|
||
if !SettingsManager.shared.closeAfterSave {
|
||
print("RenameActionHandler: closeAfterSave is OFF, expliciet sluiten van preview na Save to Folder.")
|
||
handlerDelegateRef.closePreviewWithAnimation(immediate: false, preserveTempFile: false)
|
||
}
|
||
}
|
||
} catch {
|
||
print("❌ Error moving file to destination folder: \(error)")
|
||
DispatchQueue.main.async {
|
||
let errorAlert = NSAlert(error: error)
|
||
if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel {
|
||
errorAlert.beginSheetModal(for: hostWindow, completionHandler: nil)
|
||
} else {
|
||
errorAlert.runModal()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleExistingFileConflict(for type: ConflictType, originalURL: URL, destinationURL: URL, destinationFolderURL: URL, completion: @escaping (NSApplication.ModalResponse) -> Void) {
|
||
let conflictAlert = NSAlert()
|
||
conflictAlert.messageText = "File Exists"
|
||
|
||
let fileName = destinationURL.lastPathComponent
|
||
let folderName = destinationFolderURL.lastPathComponent
|
||
|
||
if type == .renameOnly {
|
||
conflictAlert.informativeText = "A file named \"\(fileName)\" already exists. Please use a different name."
|
||
let okButton = conflictAlert.addButton(withTitle: "OK")
|
||
okButton.tag = NSApplication.ModalResponse.OK.rawValue
|
||
} else {
|
||
conflictAlert.informativeText = "A file named \"\(fileName)\" already exists in the destination folder \"\(folderName)\"."
|
||
let overwriteButton = conflictAlert.addButton(withTitle: "Overwrite")
|
||
overwriteButton.tag = NSApplication.ModalResponse.OK.rawValue
|
||
|
||
let saveNewNameButton = conflictAlert.addButton(withTitle: "Save with New Name")
|
||
saveNewNameButton.tag = NSApplication.ModalResponse.continue.rawValue
|
||
|
||
let cancelButton = conflictAlert.addButton(withTitle: "Cancel")
|
||
cancelButton.tag = NSApplication.ModalResponse.cancel.rawValue
|
||
}
|
||
|
||
var response = conflictAlert.runModal()
|
||
|
||
if type == .renameOnly && response == .OK {
|
||
response = .cancel
|
||
}
|
||
|
||
completion(response)
|
||
}
|
||
|
||
private func showRenameErrorAlert(message: String, informativeText: String, onOK: (() -> Void)? = nil) {
|
||
let alert = NSAlert()
|
||
alert.messageText = message
|
||
alert.informativeText = informativeText
|
||
let okButton = alert.addButton(withTitle: "OK")
|
||
okButton.tag = NSApplication.ModalResponse.OK.rawValue
|
||
|
||
alert.beginSheetModal(for: self.renamePanel ?? NSApplication.shared.keyWindow ?? NSWindow()) { response in
|
||
if response == .OK {
|
||
onOK?()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension RenamePromptPanel {
|
||
convenience init(animationStartFrame: NSRect?) {
|
||
let initialWidth: CGFloat = 280
|
||
let initialHeight: CGFloat = 130
|
||
var initialRect = NSRect(x: 0, y: 0, width: initialWidth, height: initialHeight)
|
||
|
||
if let startFrame = animationStartFrame, let mainScreen = NSScreen.main {
|
||
let screenFrame = mainScreen.visibleFrame
|
||
initialRect.origin.x = startFrame.midX - initialWidth / 2
|
||
initialRect.origin.y = startFrame.midY - initialHeight / 2
|
||
|
||
if initialRect.maxX > screenFrame.maxX { initialRect.origin.x = screenFrame.maxX - initialWidth }
|
||
if initialRect.minX < screenFrame.minX { initialRect.origin.x = screenFrame.minX }
|
||
if initialRect.maxY > screenFrame.maxY { initialRect.origin.y = screenFrame.maxY - initialHeight }
|
||
if initialRect.minY < screenFrame.minY { initialRect.origin.y = screenFrame.minY }
|
||
} else if let mainScreen = NSScreen.main {
|
||
initialRect.origin.x = (mainScreen.visibleFrame.width - initialWidth) / 2 + mainScreen.visibleFrame.origin.x
|
||
initialRect.origin.y = (mainScreen.visibleFrame.height - initialHeight) / 2 + mainScreen.visibleFrame.origin.y
|
||
}
|
||
|
||
self.init(contentRect: initialRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: .buffered, defer: false)
|
||
self.animationStartFrame = animationStartFrame
|
||
}
|
||
} |