import AppKit import SwiftUI @MainActor final class HUDModel: ObservableObject { enum Phase { case preparing case listening case processing } @Published var text: String = "" @Published var level: Float = 0 @Published var phase: Phase = .listening @Published var hint: String = "再按一次结束 · Esc 取消" } struct HUDView: View { @ObservedObject var model: HUDModel var body: some View { HStack(alignment: .center, spacing: 14) { Waveform(level: model.level, active: model.phase == .listening) .frame(width: 34, height: 34) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { if model.phase != .listening { ProgressView() .controlSize(.small) .scaleEffect(0.7) } Text(displayText) .font(.system(size: 15, weight: .regular)) .foregroundStyle(model.text.isEmpty ? Color.secondary : Color.primary) .lineLimit(3) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } Text(model.phase == .preparing ? "等待本地引擎就绪后再说话 · Esc 取消" : (model.phase == .processing ? "正在整理文字…" : model.hint)) .font(.system(size: 11)) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(.horizontal, 16) .padding(.vertical, 12) .frame(width: 480, height: 104, alignment: .leading) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(.regularMaterial) ) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(Color.primary.opacity(0.09), lineWidth: 0.5) ) } private var displayText: String { if model.text.isEmpty { return model.phase == .preparing ? "正在准备本地识别…" : (model.phase == .processing ? " " : "聆听中…") } return model.text } } private struct Waveform: View { var level: Float var active: Bool var body: some View { HStack(alignment: .center, spacing: 3) { ForEach(0..<5, id: \.self) { index in Capsule() .fill(active ? Color.accentColor : Color.secondary.opacity(0.4)) .frame(width: 3, height: height(for: index)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .animation(.easeOut(duration: 0.12), value: level) } private func height(for index: Int) -> CGFloat { let weights: [CGFloat] = [0.45, 0.75, 1.0, 0.7, 0.5] let base: CGFloat = 5 let maxHeight: CGFloat = 30 let l = CGFloat(max(0, min(1, level))) return base + (maxHeight - base) * l * weights[index] } } @MainActor final class HUDController { private var panel: NSPanel? private var tracking: Timer? private let model = HUDModel() var isVisible: Bool { panel?.isVisible ?? false } func show() { model.text = "" model.level = 0 model.phase = .preparing model.hint = "\(Prefs.shared.languageCode) · " + (Prefs.shared.triggerMode == .hold ? "松开结束 · Esc 取消" : "再按一次结束 · Esc 取消") let panel = ensurePanel() position(panel) panel.alphaValue = 0 panel.orderFrontRegardless() tracking?.invalidate() tracking = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in MainActor.assumeIsolated { guard let self, let panel = self.panel else { return } self.position(panel) } } NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 panel.animator().alphaValue = 1 } } func update(text: String) { model.text = text } func update(level: Float) { // 平滑一下,避免抖动 model.level = model.level * 0.6 + level * 0.4 } func setListening() { model.phase = .listening } func setProcessing() { model.phase = .processing } func hide() { tracking?.invalidate() tracking = nil guard let panel, panel.isVisible else { return } panel.orderOut(nil) } private func ensurePanel() -> NSPanel { if let panel { return panel } let panel = CaptionPanel( contentRect: NSRect(x: 0, y: 0, width: 480, height: 104), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.level = .statusBar panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.ignoresMouseEvents = true panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = false panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] panel.contentView = NSHostingView(rootView: HUDView(model: model)) self.panel = panel return panel } private func position(_ panel: NSPanel) { let size = panel.frame.size let screen = screenUnderMouse() ?? NSScreen.main guard let screen else { return } var origin: CGPoint if !Prefs.shared.hudAtBottom, let ax = Inserter.focusedFrame() { // AX 坐标系:主屏左上角为原点,y 向下 let primaryHeight = NSScreen.screens .first { $0.frame.origin == .zero }?.frame.height ?? screen.frame.height let appKitY = primaryHeight - ax.maxY var x = ax.minX + ax.width / 2 - size.width / 2 var y = appKitY - size.height - 10 // 下方空间不够时显示到输入框上方 if y < screen.visibleFrame.minY + 8 { y = primaryHeight - ax.minY + 10 } x = min(max(x, screen.visibleFrame.minX + 8), screen.visibleFrame.maxX - size.width - 8) y = min(max(y, screen.visibleFrame.minY + 8), screen.visibleFrame.maxY - size.height - 8) origin = CGPoint(x: x, y: y) } else { origin = CGPoint(x: screen.frame.midX - size.width / 2, y: screen.visibleFrame.minY + 120) } panel.setFrame(NSRect(origin: origin, size: size), display: true) } private func screenUnderMouse() -> NSScreen? { let location = NSEvent.mouseLocation return NSScreen.screens.first { $0.frame.contains(location) } } } private final class CaptionPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } }