🎉 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:
163
ShotScreen/Sources/FeedbackBubblePanel.swift
Normal file
163
ShotScreen/Sources/FeedbackBubblePanel.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
import AppKit
|
||||
|
||||
class FeedbackBubblePanel: NSPanel {
|
||||
private var messageLabel: NSTextField!
|
||||
private var animationStartFrame: NSRect?
|
||||
private var autoCloseTimer: Timer?
|
||||
|
||||
// MARK: - Initialization
|
||||
init(contentRect: NSRect, text: String) {
|
||||
super.init(contentRect: contentRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: .buffered, defer: false)
|
||||
|
||||
self.isFloatingPanel = true
|
||||
self.level = .floating + 3
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
self.isOpaque = false
|
||||
self.backgroundColor = .clear
|
||||
self.hasShadow = false
|
||||
self.animationBehavior = .utilityWindow
|
||||
self.hidesOnDeactivate = false // Belangrijk voor interactie met andere UI tijdens tonen
|
||||
self.becomesKeyOnlyIfNeeded = true
|
||||
|
||||
setupVisualEffectView()
|
||||
setupMessageLabel(with: text)
|
||||
setupLayoutConstraints()
|
||||
}
|
||||
|
||||
private func setupVisualEffectView() {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
visualEffectView.blendingMode = .behindWindow
|
||||
visualEffectView.material = .hudWindow
|
||||
visualEffectView.state = .active
|
||||
visualEffectView.wantsLayer = true
|
||||
visualEffectView.layer?.cornerRadius = 12.0
|
||||
visualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.contentView = visualEffectView
|
||||
}
|
||||
|
||||
private func setupMessageLabel(with text: String) {
|
||||
messageLabel = NSTextField(wrappingLabelWithString: text)
|
||||
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
messageLabel.isBezeled = false
|
||||
messageLabel.isEditable = false
|
||||
messageLabel.isSelectable = false
|
||||
messageLabel.backgroundColor = .clear
|
||||
messageLabel.textColor = NSColor(white: 0.95, alpha: 1.0)
|
||||
messageLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
||||
messageLabel.alignment = .center
|
||||
messageLabel.maximumNumberOfLines = 0 // Allow multiple lines
|
||||
(self.contentView as? NSVisualEffectView)?.addSubview(messageLabel)
|
||||
}
|
||||
|
||||
private func setupLayoutConstraints() {
|
||||
guard let contentView = self.contentView as? NSVisualEffectView, messageLabel != nil else { return }
|
||||
|
||||
let horizontalPadding: CGFloat = 18
|
||||
let verticalPadding: CGFloat = 10
|
||||
|
||||
// Pas de grootte van het paneel aan op basis van de tekst, met padding
|
||||
let preferredSize = messageLabel.sizeThatFits(NSSize(width: 250 - 2 * horizontalPadding, height: CGFloat.greatestFiniteMagnitude))
|
||||
let panelWidth = preferredSize.width + 2 * horizontalPadding
|
||||
let panelHeight = preferredSize.height + 2 * verticalPadding
|
||||
self.setContentSize(NSSize(width: panelWidth, height: panelHeight))
|
||||
|
||||
// Herbereken de constraints van de visualEffectView indien nodig
|
||||
contentView.leadingAnchor.constraint(equalTo: (self.contentView!).leadingAnchor).isActive = true
|
||||
contentView.trailingAnchor.constraint(equalTo: (self.contentView!).trailingAnchor).isActive = true
|
||||
contentView.topAnchor.constraint(equalTo: (self.contentView!).topAnchor).isActive = true
|
||||
contentView.bottomAnchor.constraint(equalTo: (self.contentView!).bottomAnchor).isActive = true
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
messageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: horizontalPadding),
|
||||
messageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -horizontalPadding),
|
||||
messageLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: verticalPadding),
|
||||
messageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -verticalPadding)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Show and Close Animations
|
||||
|
||||
/// Shows the panel, animating from the grid.
|
||||
func show(aroundGridFrame gridFrame: NSRect, text: String, autoCloseAfter: TimeInterval?, onAutoCloseCompletion: (() -> Void)? = nil) {
|
||||
self.messageLabel.stringValue = text
|
||||
// Herbereken layout voor nieuwe tekst
|
||||
setupLayoutConstraints()
|
||||
|
||||
self.animationStartFrame = gridFrame
|
||||
let panelSize = self.frame.size
|
||||
|
||||
// Startpositie: gecentreerd op de grid (vergelijkbaar met RenamePanel)
|
||||
let initialX = gridFrame.midX - panelSize.width / 2
|
||||
let initialY = gridFrame.midY - panelSize.height / 2
|
||||
self.setFrameOrigin(NSPoint(x: initialX, y: initialY))
|
||||
self.alphaValue = 0.0
|
||||
|
||||
// Eindpositie: links van de grid (vergelijkbaar met RenamePanel)
|
||||
let spacing: CGFloat = 20
|
||||
var finalFrame = self.frame
|
||||
finalFrame.origin.x = gridFrame.origin.x - panelSize.width - spacing
|
||||
finalFrame.origin.y = gridFrame.origin.y + (gridFrame.height - panelSize.height) / 2
|
||||
|
||||
// Screen bounds check (vereenvoudigd, neem aan dat het past voor nu)
|
||||
if let screen = NSScreen.screens.first(where: { $0.frame.intersects(gridFrame) }) ?? NSScreen.main {
|
||||
let screenVisibleFrame = screen.visibleFrame
|
||||
if finalFrame.origin.x < screenVisibleFrame.origin.x {
|
||||
finalFrame.origin.x = gridFrame.maxX + spacing // Aan de andere kant als het niet past
|
||||
}
|
||||
finalFrame.origin.y = max(screenVisibleFrame.minY + 10, min(finalFrame.origin.y, screenVisibleFrame.maxY - panelSize.height - 10))
|
||||
}
|
||||
|
||||
self.orderFront(nil)
|
||||
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.35 // Iets langzamer dan rename panel (0.3)
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.animator().alphaValue = 1.0
|
||||
self.animator().setFrame(finalFrame, display: true)
|
||||
}, completionHandler: {
|
||||
if let closeDelay = autoCloseAfter, closeDelay > 0 {
|
||||
self.autoCloseTimer?.invalidate()
|
||||
self.autoCloseTimer = Timer.scheduledTimer(withTimeInterval: closeDelay, repeats: false) { [weak self] _ in
|
||||
self?.closeWithAnimation(completion: onAutoCloseCompletion)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes the panel, animating back towards the grid.
|
||||
func closeWithAnimation(completion: (() -> Void)?) {
|
||||
autoCloseTimer?.invalidate()
|
||||
autoCloseTimer = nil
|
||||
|
||||
guard let startFrame = self.animationStartFrame else {
|
||||
self.orderOut(nil)
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
// Doel animatie: terug naar midden van grid, met huidige grootte (geen zoom/krimp)
|
||||
let currentPanelSize = self.frame.size
|
||||
let endOriginX = startFrame.midX - currentPanelSize.width / 2
|
||||
let endOriginY = startFrame.midY - currentPanelSize.height / 2
|
||||
// We animeren alleen de origin en alpha, niet de size.
|
||||
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.3 // Snelheid van terug animeren
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
self.animator().alphaValue = 0
|
||||
// Alleen de origin animeren, niet de frame size.
|
||||
self.animator().setFrameOrigin(NSPoint(x: endOriginX, y: endOriginY))
|
||||
}, completionHandler: {
|
||||
self.orderOut(nil)
|
||||
self.alphaValue = 1 // Reset alpha voor volgende keer
|
||||
// Belangrijk: Reset ook de frame origin/size naar iets zinnigs voor het geval het direct opnieuw getoond wordt
|
||||
// zonder dat de init opnieuw doorlopen wordt, hoewel dat hier minder waarschijnlijk is.
|
||||
// Voor nu laten we dit, aangezien een nieuwe .show() de frame opnieuw instelt.
|
||||
completion?()
|
||||
})
|
||||
}
|
||||
|
||||
deinit {
|
||||
autoCloseTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user