208 lines
7.0 KiB
Swift
208 lines
7.0 KiB
Swift
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 }
|
|
}
|