import AppKit import SwiftUI struct SettingsView: View { @State private var tab: Tab = .general enum Tab: Hashable { case general, hotkey, speech, about } var body: some View { TabView(selection: $tab) { GeneralPane() .tabItem { Label("通用", systemImage: "gearshape") } .tag(Tab.general) HotkeyPane() .tabItem { Label("快捷键", systemImage: "command") } .tag(Tab.hotkey) SpeechPane() .tabItem { Label("语音", systemImage: "waveform") } .tag(Tab.speech) AboutPane() .tabItem { Label("关于", systemImage: "info.circle") } .tag(Tab.about) } .frame(width: 540, height: 430) .onReceive(NotificationCenter.default.publisher(for: .tinysttOpenSettings)) { _ in // 保持当前 tab } } } // MARK: - 通用 struct GeneralPane: View { @ObservedObject var prefs = Prefs.shared @State private var launchAtLogin = LaunchAtLogin.isEnabled @State private var launchMessage: String? var body: some View { Form { Section { Toggle("开机自动启动", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in if let error = LaunchAtLogin.set(newValue) { launchMessage = error launchAtLogin = LaunchAtLogin.isEnabled } else { launchMessage = nil } } if let launchMessage { Text(launchMessage) .font(.caption) .foregroundStyle(.red) } } header: { Text("启动") } Section { Toggle("菜单栏只显示图标", isOn: $prefs.compactIcon) Toggle("字幕固定在屏幕底部", isOn: $prefs.hudAtBottom) Toggle("12 秒无声自动结束", isOn: $prefs.silenceTimeout) Toggle("显示浮动字幕", isOn: $prefs.showHUD) Toggle("开始和完成时播放提示音", isOn: $prefs.playSound) } header: { Text("界面") } Section { Picker("文字写入方式", selection: $prefs.insertMethod) { ForEach(InsertMethod.allCases) { method in Text(method.title).tag(method) } } Text("默认通过键盘事件直接输入文字,不使用剪贴板;也可选择 AX 或自动粘贴。") .font(.caption) .foregroundStyle(.secondary) } header: { Text("写入") } } .formStyle(.grouped) } } // MARK: - 快捷键 struct HotkeyPane: View { @ObservedObject var prefs = Prefs.shared @StateObject private var recorder = HotkeyRecorderModel() var body: some View { Form { Section { HStack { Text("当前快捷键") Spacer() Text(recorder.isRecording ? "请按下新的快捷键…" : prefs.hotkey.label) .font(.system(.body, design: .rounded)) .foregroundStyle(recorder.isRecording ? Color.accentColor : Color.primary) .padding(.horizontal, 10) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.primary.opacity(0.06)) ) } HStack(spacing: 10) { Button(recorder.isRecording ? "取消录制" : "重新录制") { if recorder.isRecording { recorder.end() } else { recorder.begin() } } if recorder.isRecording { Text("按 Esc 取消") .font(.caption) .foregroundStyle(.secondary) } Spacer() } Text("可以设置单个修饰键(如右 Option),也可以设置组合键(如 ⌃Space)。设置组合键时先按住修饰键再按其他键。") .font(.caption) .foregroundStyle(.secondary) } header: { Text("触发方式") } Section { Picker("按键行为", selection: $prefs.triggerMode) { ForEach(TriggerMode.allCases) { mode in Text(mode.title).tag(mode) } } Text("录音过程中按 Esc 可立即取消,文字不会写入。") .font(.caption) .foregroundStyle(.secondary) } header: { Text("模式") } } .formStyle(.grouped) .onDisappear { recorder.end() } .onAppear { recorder.onCapture = { spec in prefs.hotkey = spec } } } } // MARK: - 语音 struct SpeechPane: View { @ObservedObject var prefs = Prefs.shared @ObservedObject var models = ModelManager.shared @ObservedObject var dictation = DictationController.shared @State private var engineStatus = SpeechEngine.statusDescription @State private var checkingEngine = false @State private var diagnosticResult = "" var body: some View { Form { Section { LabeledContent("识别语言", value: "中文(普通话)") Picker("识别引擎", selection: $prefs.engineChoice) { Text("自动(全本地降级)").tag("auto") Text("SpeechTranscriber").tag("speech") Text("DictationTranscriber").tag("dictation") Text("旧版本地识别").tag("legacy") } .disabled(dictation.state.isBusy || isDownloading || checkingEngine) .onChange(of: prefs.engineChoice) { _, _ in SpeechEngine.resetWarm() Task { await models.refresh(); SpeechEngine.warmUp() } } HStack { Text("模型状态") Spacer() Text(models.statusText) .foregroundStyle(models.state.isReady ? Color.secondary : Color.orange) } LabeledContent("引擎状态", value: engineStatus) Button("重新准备引擎") { SpeechEngine.resetWarm() SpeechEngine.warmUp() } .disabled(dictation.state.isBusy || checkingEngine || isDownloading) if case .downloading(let progress) = models.state { ProgressView(value: progress) } if models.state != .installed && models.state != .legacy { HStack { Button("下载语音模型") { Task { await models.download() } } .disabled(isDownloading) Text("首次使用需要下载系统语音模型,只需要一次。") .font(.caption) .foregroundStyle(.secondary) } } } header: { Text("识别") } Section { HStack(spacing: 10) { Button(dictation.state == .starting ? "准备中…" : (dictation.state == .recording ? "结束测试" : "开始测试")) { if dictation.state == .recording { dictation.stop() } else { dictation.testMode = true dictation.start() } } .disabled(checkingEngine || dictation.state == .starting || dictation.state == .finalizing || (dictation.state.isBusy && !dictation.testMode)) Text("测试不会写入任何输入框。") .font(.caption) .foregroundStyle(.secondary) Spacer() } Text(testText) .font(.system(size: 14)) .foregroundStyle(testText.isEmpty ? Color.secondary : Color.primary) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background( RoundedRectangle(cornerRadius: 8) .fill(Color.primary.opacity(0.05)) ) .frame(minHeight: 72, alignment: .topLeading) } header: { Text("测试语音") } Section { Button(checkingEngine ? "正在检查…" : "检查本地识别(合成语音)") { checkingEngine = true diagnosticResult = "" Task { do { try await SelfTest.perform(choice: prefs.engineChoice) diagnosticResult = "本地识别通过,若麦克风测试无文字,请检查输入设备。" } catch { diagnosticResult = error.localizedDescription } checkingEngine = false } } .disabled(checkingEngine || dictation.state.isBusy) if !diagnosticResult.isEmpty { Text(diagnosticResult).font(.caption).textSelection(.enabled) } } header: { Text("诊断") } Section { Toggle("自动补句号", isOn: $prefs.autoPeriod) Text("识别结果保留系统自带标点,只在你停顿时没有句末标点的情况下补一个句号。") .font(.caption) .foregroundStyle(.secondary) } header: { Text("后处理") } } .formStyle(.grouped) .onReceive(NotificationCenter.default.publisher(for: .init("tinystt.engineStatus"))) { _ in engineStatus = SpeechEngine.statusDescription } .onChange(of: dictation.state) { _, newValue in if newValue == .idle { dictation.testMode = false } } } private var isDownloading: Bool { if case .downloading = models.state { return true } return false } private var testText: String { if dictation.state == .starting { return "正在准备本地识别,请稍候…" } if dictation.state == .finalizing { return dictation.liveText.isEmpty ? "正在整理文字…" : dictation.liveText } if dictation.state == .recording { return dictation.liveText.isEmpty ? "正在听…" : dictation.liveText } return dictation.lastResult } } // MARK: - 关于 struct AboutPane: View { @State private var micGranted = Permissions.microphone == .granted @State private var axGranted = Permissions.accessibility var body: some View { Form { Section { HStack { Image(nsImage: NSApp.applicationIconImage) .resizable().frame(width: 48, height: 48) VStack(alignment: .leading, spacing: 2) { Text("TinySTT").font(.headline) Text("轻量级语音输入 · 全部在本机识别") .font(.caption) .foregroundStyle(.secondary) } Spacer() Text(version).font(.caption).foregroundStyle(.secondary) } } Section { PermissionRow( title: "麦克风", detail: micGranted ? "已授权" : "未授权,无法录音", granted: micGranted, actionTitle: "打开设置" ) { Permissions.openPrivacyPane("Privacy_Microphone") } PermissionRow( title: "辅助功能", detail: axGranted ? "已授权" : "未授权,无法监听快捷键和写入文字", granted: axGranted, actionTitle: "请求授权" ) { Permissions.promptAccessibility() Permissions.openPrivacyPane("Privacy_Accessibility") } Text("辅助功能授权后会自动重试热键;麦克风授权在下次录音时重新检测。") .font(.caption) .foregroundStyle(.secondary) } header: { Text("权限") } Section { Button("打开日志目录") { NSWorkspace.shared.open(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs")) } Text("快捷键 \(Prefs.shared.hotkey.label) · 语言 中文(普通话)") .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .onAppear { micGranted = Permissions.microphone == .granted axGranted = Permissions.accessibility } } private var version: String { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0" return "v\(v)" } } struct PermissionRow: View { var title: String var detail: String var granted: Bool var actionTitle: String var action: () -> Void var body: some View { HStack(spacing: 10) { Circle() .fill(granted ? Color.green : Color.orange) .frame(width: 7, height: 7) VStack(alignment: .leading, spacing: 1) { Text(title) Text(detail).font(.caption).foregroundStyle(.secondary) } Spacer() if !granted { Button(actionTitle, action: action) .controlSize(.small) } } } }