import Cocoa /// 录音期间屏幕底部居中的胶囊形浮动窗口 final class RecordingPanel: NSPanel { // MARK: - 子视图 let waveformView: WaveformView private let textLabel: NSTextField private let containerStack: NSStackView // 文本宽度约束 private var textWidthConstraint: NSLayoutConstraint? // 窗口宽度常量 private let minPanelWidth: CGFloat = 180 private let maxPanelWidth: CGFloat = 680 private let panelHeight: CGFloat = 56 private let cornerRadius: CGFloat = 28 private let waveformSize = NSSize(width: 44, height: 32) private let marginAboveDock: CGFloat = 12 init() { // Waveform waveformView = WaveformView(frame: NSRect(origin: .zero, size: waveformSize)) waveformView.translatesAutoresizingMaskIntoConstraints = false waveformView.wantsLayer = true // Label textLabel = NSTextField(labelWithString: "") textLabel.translatesAutoresizingMaskIntoConstraints = false textLabel.font = .systemFont(ofSize: 16, weight: .medium) textLabel.textColor = .white textLabel.alignment = .left textLabel.lineBreakMode = .byTruncatingTail textLabel.maximumNumberOfLines = 1 textLabel.backgroundColor = .clear textLabel.isBezeled = false textLabel.drawsBackground = false // Stack containerStack = NSStackView(views: [waveformView, textLabel]) containerStack.translatesAutoresizingMaskIntoConstraints = false containerStack.orientation = .horizontal containerStack.alignment = .centerY containerStack.spacing = 12 containerStack.edgeInsets = NSEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) // Panel 配置 super.init( contentRect: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) configurePanel() configureVisualEffect() setupConstraints() } private func configurePanel() { isFloatingPanel = true becomesKeyOnlyIfNeeded = true isOpaque = false hasShadow = false level = .floating collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] ignoresMouseEvents = false titleVisibility = .hidden titlebarAppearsTransparent = true isMovableByWindowBackground = false // 彻底去除边框 backgroundColor = .clear isOpaque = false } private func configureVisualEffect() { let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight)) visualEffect.material = .hudWindow visualEffect.blendingMode = .behindWindow visualEffect.state = .active visualEffect.wantsLayer = true visualEffect.layer?.cornerRadius = cornerRadius visualEffect.layer?.masksToBounds = true visualEffect.layer?.borderWidth = 0 visualEffect.layer?.borderColor = NSColor.clear.cgColor visualEffect.autoresizingMask = [.width, .height] // 替换 contentView contentView = visualEffect visualEffect.addSubview(containerStack) } private func setupConstraints() { guard let content = contentView else { return } NSLayoutConstraint.activate([ // Waveform 固定尺寸 waveformView.widthAnchor.constraint(equalToConstant: waveformSize.width), waveformView.heightAnchor.constraint(equalToConstant: waveformSize.height), // Container 填充 containerStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), containerStack.centerYAnchor.constraint(equalTo: content.centerYAnchor), containerStack.heightAnchor.constraint(equalToConstant: panelHeight) ]) // 文本弹性宽度 textWidthConstraint = textLabel.widthAnchor.constraint(equalToConstant: 160) textWidthConstraint?.isActive = true } /// 更新转录文本 func updateText(_ text: String) { textLabel.stringValue = text // 计算所需宽度 let maxTextWidth = maxPanelWidth - waveformSize.width - 40 // 40 = spacing + insets let attributes: [NSAttributedString.Key: Any] = [.font: textLabel.font!] let textSize = (text as NSString).size(withAttributes: attributes) let requiredTextWidth = min(max(textSize.width + 10, 160), maxTextWidth) let targetPanelWidth = requiredTextWidth + waveformSize.width + 44 // 动画更新宽度 NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.25 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) ctx.allowsImplicitAnimation = true textWidthConstraint?.animator().constant = requiredTextWidth let frame = currentScreenFrame() let y = bottomY(for: frame) let newFrame = NSRect( x: (frame.width - targetPanelWidth) / 2, y: y, width: targetPanelWidth, height: panelHeight ) animator().setFrame(newFrame, display: true) } } /// 显示面板(弹簧动画) func showAnimated() { let frame = currentScreenFrame() let y = bottomY(for: frame) let initialFrame = NSRect( x: (frame.width - minPanelWidth) / 2, y: y, width: minPanelWidth, height: panelHeight ) // 初始状态:缩小且透明 setFrame(initialFrame, display: false) alphaValue = 0 // 出场 makeKeyAndOrderFront(nil) // 弹簧动画 NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.35 ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.0, 0.5, 0.3, 1.5) // 弹簧曲线 animator().alphaValue = 1.0 } // 弹簧效果通过 CATransaction 模拟 let spring = CASpringAnimation(keyPath: "transform.scale") spring.fromValue = 0.85 spring.toValue = 1.0 spring.duration = 0.35 spring.damping = 12 spring.initialVelocity = 0 spring.fillMode = .backwards contentView?.layer?.add(spring, forKey: "showSpring") } /// 隐藏面板(缩放动画) func hideAnimated(completion: (() -> Void)? = nil) { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = 0.22 ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) animator().alphaValue = 0 // 缩放 let scale = CABasicAnimation(keyPath: "transform.scale") scale.fromValue = 1.0 scale.toValue = 0.9 scale.duration = 0.22 scale.timingFunction = CAMediaTimingFunction(name: .easeIn) scale.fillMode = .forwards scale.isRemovedOnCompletion = false contentView?.layer?.add(scale, forKey: "hideScale") }, completionHandler: { self.orderOut(nil) self.contentView?.layer?.removeAnimation(forKey: "hideScale") self.textLabel.stringValue = "" self.textWidthConstraint?.animator().constant = 160 completion?() }) } /// 显示 Refining 状态(弹窗自适应缩窄) func showRefining() { textLabel.stringValue = "Refining..." let refiningTextWidth: CGFloat = 120 let targetWidth: CGFloat = refiningTextWidth + waveformSize.width + 44 NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.2 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) ctx.allowsImplicitAnimation = true textWidthConstraint?.animator().constant = refiningTextWidth let frame = currentScreenFrame() let y = bottomY(for: frame) let newFrame = NSRect( x: (frame.width - targetWidth) / 2, y: y, width: targetWidth, height: panelHeight ) animator().setFrame(newFrame, display: true) } waveformView.updateLevel(0.3) } /// 显示 AI 助手思考状态 func showThinking() { textLabel.stringValue = "Thinking..." let thinkingTextWidth: CGFloat = 120 let targetWidth: CGFloat = thinkingTextWidth + waveformSize.width + 44 NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.2 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) ctx.allowsImplicitAnimation = true textWidthConstraint?.animator().constant = thinkingTextWidth let frame = currentScreenFrame() let y = bottomY(for: frame) let newFrame = NSRect( x: (frame.width - targetWidth) / 2, y: y, width: targetWidth, height: panelHeight ) animator().setFrame(newFrame, display: true) } waveformView.updateLevel(0.2) } // 重写:允许在非 active 状态响应 override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } // MARK: - 屏幕坐标 private func currentScreenFrame() -> NSRect { // 优先使用鼠标所在屏幕,否则主屏幕 let mouseLocation = NSEvent.mouseLocation for screen in NSScreen.screens { if screen.frame.contains(mouseLocation) { return screen.visibleFrame } } return NSScreen.main?.visibleFrame ?? .zero } private func bottomY(for screenFrame: NSRect) -> CGFloat { screenFrame.minY + marginAboveDock } }