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() } }