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() 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) } }