Files
tinystt/Sources/TinySTT/UI/StatusItemController.swift

151 lines
6.5 KiB
Swift

import AppKit
import Combine
@MainActor
final class StatusItemController: NSObject, NSMenuDelegate {
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
private let stateItem = NSMenuItem()
private let modelItem = NSMenuItem()
private let engineItem = NSMenuItem()
private let permissionItem = NSMenuItem()
private let toggleItem = NSMenuItem()
private let hintItem = NSMenuItem()
private let cancelItem = NSMenuItem(title: "取消此次听写", action: nil, keyEquivalent: "")
private var state: DictationController.State = .idle
private var animation: Timer?
private var frame = 0
private var subscriptions = Set<AnyCancellable>()
private let brandImage = Bundle.main.url(forResource: "AppIcon", withExtension: "icns")
.flatMap { NSImage(contentsOf: $0) } ?? NSApp.applicationIconImage!
var onToggle: (() -> Void)?
var onCancel: (() -> Void)?
var onSettings: (() -> Void)?
override init() {
super.init()
menu.delegate = self
menu.autoenablesItems = false
for item in [stateItem, modelItem, engineItem, permissionItem] { item.isEnabled = false; menu.addItem(item) }
menu.addItem(.separator())
toggleItem.target = self
toggleItem.action = #selector(toggleTapped)
menu.addItem(toggleItem)
cancelItem.target = self
cancelItem.action = #selector(cancelTapped)
menu.addItem(cancelItem)
hintItem.isEnabled = false
menu.addItem(hintItem)
menu.addItem(.separator())
for (title, code, action) in [
("设置与测试语音…", ",", #selector(settingsTapped)),
("下载 / 检查语音模型", "", #selector(downloadTapped)),
("复制上次识别结果", "", #selector(copyTapped)),
("退出 TinySTT", "q", #selector(quitTapped))
] {
let item = NSMenuItem(title: title, action: action, keyEquivalent: code)
item.target = self
menu.addItem(item)
}
statusItem.menu = menu
ModelManager.shared.$state.sink { [weak self] _ in
DispatchQueue.main.async { self?.refresh() }
}.store(in: &subscriptions)
Prefs.shared.objectWillChange.sink { [weak self] _ in
DispatchQueue.main.async { self?.refresh() }
}.store(in: &subscriptions)
NotificationCenter.default.publisher(for: .init("tinystt.engineStatus")).sink { [weak self] _ in
self?.refresh()
}.store(in: &subscriptions)
update(state: .idle)
}
func menuWillOpen(_ menu: NSMenu) { refresh() }
func update(state: DictationController.State) {
self.state = state
animation?.invalidate()
animation = nil
if state.isBusy {
animation = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.frame += 1
self.drawIcon()
}
}
}
refresh()
}
private func refresh() {
let prefs = Prefs.shared
switch state {
case .idle: stateItem.title = "TinySTT · 待机"
case .starting: stateItem.title = "TinySTT · 正在准备…"
case .recording: stateItem.title = "TinySTT · 正在聆听"
case .finalizing: stateItem.title = "TinySTT · 正在整理文字…"
}
modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)"
engineItem.title = SpeechEngine.statusDescription
permissionItem.title = "麦克风\(Permissions.microphone == .granted ? " ✓" : " 未授权") · 辅助功能\(Permissions.accessibility ? " ✓" : " 未授权")"
toggleItem.title = state == .recording ? "结束并写入" : "开始听写"
toggleItem.isEnabled = state == .idle || state == .recording
cancelItem.isEnabled = state.isBusy
hintItem.title = "\(prefs.hotkey.label) · " + (prefs.triggerMode == .hold ? "按住说话 · Esc 取消" : "按键切换 · Esc 取消")
drawIcon()
}
private func drawIcon() {
guard let button = statusItem.button else { return }
let icon = brandImage
let state = self.state
let frame = self.frame
let image = NSImage(size: NSSize(width: 22, height: 18), flipped: false) { _ in
icon.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18))
if state != .idle {
let badge = NSRect(x: 15, y: 0, width: 7, height: 7)
NSColor.windowBackgroundColor.setFill()
NSBezierPath(ovalIn: badge).fill()
if state == .recording {
NSColor.systemRed.setFill()
NSBezierPath(ovalIn: badge.insetBy(dx: 1, dy: 1)).fill()
} else {
let arc = NSBezierPath()
let angle = CGFloat(frame * 30)
arc.appendArc(withCenter: NSPoint(x: badge.midX, y: badge.midY),
radius: 2.5, startAngle: angle, endAngle: angle + 250)
arc.lineWidth = 1.5
arc.lineCapStyle = .round
NSColor.controlAccentColor.setStroke()
arc.stroke()
}
}
return true
}
image.isTemplate = false
button.image = image
button.imagePosition = .imageLeading
button.title = Prefs.shared.compactIcon ? "" : " TinySTT"
button.contentTintColor = state == .recording ? .systemRed : nil
button.alphaValue = state == .recording ? 0.65 + 0.35 * sin(Double(frame) * 0.5) : 1
button.wantsLayer = true
button.layer?.sublayerTransform = CATransform3DIdentity
button.toolTip = "\(stateItem.title)\n\(modelItem.title)\n\(hintItem.title)"
button.setAccessibilityLabel(button.toolTip)
}
@objc private func copyTapped() {
let text = DictationController.shared.lastResult
guard !text.isEmpty else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
@objc private func toggleTapped() { onToggle?() }
@objc private func cancelTapped() { onCancel?() }
@objc private func settingsTapped() { onSettings?() }
@objc private func downloadTapped() { Task { await ModelManager.shared.refresh(); await ModelManager.shared.download() } }
@objc private func quitTapped() { NSApp.terminate(nil) }
}