VoiceInput/Sources/UI/RecordingPanel.swift
JOJO 1d9c9d5fa0 feat: macOS 菜单栏语音输入应用初始版本
- CGEvent cghidEventTap 全局监听 Fn 键
- Apple Speech 流式语音识别(zh-CN 默认)
- 实时音频 RMS 电平驱动波形动画
- NSPanel 胶囊浮动窗口
- 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换
- OpenAI 兼容 LLM 纠错(DeepSeek 预设)
- LSUIElement 菜单栏运行
- SPM + Makefile 构建
2026-04-24 16:17:57 +08:00

255 lines
8.9 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(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)
}
// 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
}
}