VoiceInput/Sources/UI/RecordingPanel.swift
JOJO deae1fbc06 feat: RecordingPanel 宽度自适应 + 打包 DMG
- updateText 移除文本宽度硬编码下限 160,真正根据内容自适应
- showRefining/showThinking 简化为调用 updateText,复用自适应逻辑
- 面板最小宽度由 minPanelWidth(180pt) 兜底
- 打包 VoiceInput-20260425.dmg
2026-04-25 16:34:33 +08:00

243 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(textSize.width + 10, maxTextWidth)
// = + +
let contentPadding: CGFloat = 44 // left(16) + spacing(12) + right(16)
let targetPanelWidth = max(requiredTextWidth + waveformSize.width + contentPadding, minPanelWidth)
//
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 = 0
completion?()
})
}
/// Refining
func showRefining() {
updateText("Refining...")
waveformView.updateLevel(0.3)
}
/// AI
func showThinking() {
updateText("Thinking...")
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
}
}