@hasna/recordings 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ struct MenuBarPopover: View {
5
+ @ObservedObject var engine: RecordingEngine
6
+ @ObservedObject var shortcuts: VoiceShortcuts
7
+
8
+ var body: some View {
9
+ VStack(spacing: 0) {
10
+ header.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
11
+ Divider()
12
+ modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
13
+ shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
14
+ Divider()
15
+ recordingArea.padding(.horizontal, 16).padding(.vertical, 12)
16
+ Divider()
17
+ recentList.frame(maxHeight: 180)
18
+ Divider()
19
+ footer.padding(.horizontal, 16).padding(.vertical, 8)
20
+ }
21
+ }
22
+
23
+ // MARK: - Header
24
+
25
+ private var header: some View {
26
+ HStack {
27
+ Image(systemName: "mic.fill").font(.title3).foregroundStyle(.tint)
28
+ Text("Recordings").font(.headline)
29
+ Spacer()
30
+ Toggle(isOn: $engine.isWhisperMode) {
31
+ Label("Whisper", systemImage: "speaker.wave.1.fill").font(.caption)
32
+ }
33
+ .toggleStyle(.button).buttonStyle(.bordered).controlSize(.small)
34
+ }
35
+ }
36
+
37
+ // MARK: - Mode Selector
38
+
39
+ private var modeSelector: some View {
40
+ HStack(spacing: 8) {
41
+ modeBtn(.pushToTalk)
42
+ modeBtn(.dictation)
43
+ modeBtn(.command)
44
+ }
45
+ }
46
+
47
+ @ViewBuilder
48
+ private func modeBtn(_ m: RecordingMode) -> some View {
49
+ if engine.mode == m {
50
+ Button { engine.mode = m } label: {
51
+ Label(m.rawValue, systemImage: m.icon).font(.caption)
52
+ .foregroundStyle(.white).frame(maxWidth: .infinity)
53
+ }.buttonStyle(.borderedProminent).controlSize(.small)
54
+ } else {
55
+ Button { engine.mode = m } label: {
56
+ Label(m.rawValue, systemImage: m.icon).font(.caption)
57
+ .frame(maxWidth: .infinity)
58
+ }.buttonStyle(.bordered).controlSize(.small)
59
+ }
60
+ }
61
+
62
+ // MARK: - Shortcut Row
63
+
64
+ private var shortcutRow: some View {
65
+ VStack(spacing: 6) {
66
+ // fn key toggle
67
+ HStack {
68
+ Toggle(isOn: $engine.useFnKey) {
69
+ HStack(spacing: 4) {
70
+ Text("fn").font(.system(.caption, design: .monospaced)).bold()
71
+ Text("Globe key").font(.caption).foregroundStyle(.secondary)
72
+ }
73
+ }
74
+ .toggleStyle(.switch).controlSize(.small)
75
+ }
76
+
77
+ // Custom shortcut
78
+ HStack(spacing: 8) {
79
+ Text("Custom").font(.caption).foregroundStyle(.secondary)
80
+ Spacer()
81
+ let current = KeyboardShortcuts.getShortcut(for: .toggleRecording)
82
+ Text(current?.description ?? "None")
83
+ .font(.system(.caption, design: .monospaced))
84
+ .padding(.horizontal, 8).padding(.vertical, 3)
85
+ .background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
86
+
87
+ SettingsLink {
88
+ Text("Set").font(.caption2)
89
+ }.buttonStyle(.bordered).controlSize(.mini)
90
+ }
91
+ }
92
+ }
93
+
94
+ // MARK: - Recording Area
95
+
96
+ private var recordingArea: some View {
97
+ VStack(spacing: 8) {
98
+ if engine.isRecording {
99
+ HStack(spacing: 12) {
100
+ Circle().fill(.red).frame(width: 10, height: 10)
101
+ Text(fmt(engine.recordingDuration))
102
+ .font(.system(.title2, design: .monospaced))
103
+ Spacer()
104
+ Button("Stop") { engine.stopAndTranscribe() }
105
+ .buttonStyle(.borderedProminent).tint(.red).controlSize(.small)
106
+ }
107
+ .padding(12).glassEffect(.regular.tint(.red))
108
+ } else if engine.isTranscribing {
109
+ HStack(spacing: 8) {
110
+ ProgressView().controlSize(.small)
111
+ Text("Transcribing...").font(.subheadline).foregroundStyle(.secondary)
112
+ }
113
+ .frame(maxWidth: .infinity).padding(12).glassEffect(.regular)
114
+ } else {
115
+ VStack(spacing: 10) {
116
+ Button { engine.startRecording() } label: {
117
+ Label("Record", systemImage: "mic.circle.fill").font(.title3)
118
+ }.buttonStyle(.borderedProminent).controlSize(.large)
119
+
120
+ Text(engine.mode.hint)
121
+ .font(.callout).foregroundStyle(.primary.opacity(0.7))
122
+ .multilineTextAlignment(.center)
123
+ .fixedSize(horizontal: false, vertical: true)
124
+ }
125
+ .frame(maxWidth: .infinity).padding(14).glassEffect(.clear)
126
+ }
127
+
128
+ Text(engine.statusMessage)
129
+ .font(.caption2).foregroundStyle(.secondary)
130
+ .lineLimit(2).truncationMode(.tail)
131
+ .frame(maxWidth: .infinity, alignment: .leading)
132
+ }
133
+ }
134
+
135
+ // MARK: - Recent
136
+
137
+ private var recentList: some View {
138
+ Group {
139
+ if engine.recentTranscriptions.isEmpty {
140
+ VStack { Spacer()
141
+ Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
142
+ Spacer()
143
+ }.frame(maxWidth: .infinity)
144
+ } else {
145
+ ScrollView {
146
+ LazyVStack(alignment: .leading, spacing: 4) {
147
+ ForEach(engine.recentTranscriptions.indices, id: \.self) { i in
148
+ TranscriptionRow(item: engine.recentTranscriptions[i])
149
+ }
150
+ }.padding(.horizontal, 16).padding(.vertical, 6)
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // MARK: - Footer
157
+
158
+ private var footer: some View {
159
+ HStack {
160
+ SettingsLink { Image(systemName: "gear") }.buttonStyle(.borderless)
161
+ Spacer()
162
+ Button("Quit") { NSApplication.shared.terminate(nil) }
163
+ .buttonStyle(.borderless).foregroundStyle(.secondary)
164
+ }
165
+ }
166
+
167
+ private func fmt(_ t: TimeInterval) -> String {
168
+ String(format: "%d:%02d", Int(t) / 60, Int(t) % 60)
169
+ }
170
+ }
171
+
172
+ struct TranscriptionRow: View {
173
+ let item: TranscriptionResult
174
+ var body: some View {
175
+ HStack(alignment: .top, spacing: 8) {
176
+ Text(item.displayText).font(.caption).lineLimit(2)
177
+ .frame(maxWidth: .infinity, alignment: .leading)
178
+ Text({ let s = Date().timeIntervalSince(item.timestamp)
179
+ return s < 60 ? "now" : s < 3600 ? "\(Int(s/60))m" : "\(Int(s/3600))h"
180
+ }()).font(.caption2).foregroundStyle(.tertiary)
181
+ }
182
+ .padding(.vertical, 4).contentShape(Rectangle())
183
+ .onTapGesture {
184
+ NSPasteboard.general.clearContents()
185
+ NSPasteboard.general.setString(item.displayText, forType: .string)
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,355 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
5
+
6
+ extension KeyboardShortcuts.Name {
7
+ static let toggleRecording = Self("toggleRecording")
8
+ }
9
+
10
+ // MARK: - Recording Mode
11
+
12
+ enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
13
+ case pushToTalk = "Push to Talk"
14
+ case dictation = "Dictation"
15
+ case command = "Command"
16
+
17
+ var id: String { rawValue }
18
+ var icon: String {
19
+ switch self {
20
+ case .pushToTalk: return "hand.tap.fill"
21
+ case .dictation: return "text.bubble.fill"
22
+ case .command: return "wand.and.stars"
23
+ }
24
+ }
25
+ var hint: String {
26
+ switch self {
27
+ case .pushToTalk: return "Hold fn to record, release to stop & paste"
28
+ case .dictation: return "Hold fn to dictate, release to stop & paste"
29
+ case .command: return "Select text, hold fn, speak to rewrite"
30
+ }
31
+ }
32
+ }
33
+
34
+ // MARK: - Transcription Result
35
+
36
+ struct TranscriptionResult: Sendable {
37
+ let rawText: String
38
+ let processedText: String?
39
+ let timestamp: Date
40
+ var displayText: String { processedText ?? rawText }
41
+ }
42
+
43
+ // MARK: - Recording Engine
44
+
45
+ @MainActor
46
+ final class RecordingEngine: ObservableObject {
47
+ @Published var isRecording = false
48
+ @Published var mode: RecordingMode = .pushToTalk
49
+ @Published var useFnKey: Bool = true {
50
+ didSet {
51
+ UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
52
+ updateFnMonitor()
53
+ updateStatus()
54
+ }
55
+ }
56
+ @Published var isWhisperMode = false
57
+ @Published var recentTranscriptions: [TranscriptionResult] = []
58
+ @Published var statusMessage = "Starting..."
59
+ @Published var isTranscribing = false
60
+ @Published var recordingDuration: TimeInterval = 0
61
+
62
+ private var recordProcess: Process?
63
+ private var stdinPipe: Pipe?
64
+ private var currentAudioPath: String?
65
+ private var recordingTimer: Timer?
66
+ private let maxDuration = 300
67
+
68
+ // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
69
+ private let fnMonitor = FnKeyMonitor()
70
+
71
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
72
+ private var audioDir: String { "\(home)/.recordings/audio" }
73
+
74
+ init() {
75
+ try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
76
+
77
+ // Load preferences
78
+ useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? true
79
+
80
+ // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
81
+ fnMonitor.onFnKeyDown = { [weak self] in
82
+ Task { @MainActor [weak self] in
83
+ guard let self, self.useFnKey, !self.isRecording else { return }
84
+ self.startRecording()
85
+ }
86
+ }
87
+ fnMonitor.onFnKeyUp = { [weak self] in
88
+ Task { @MainActor [weak self] in
89
+ guard let self, self.useFnKey, self.isRecording else { return }
90
+ self.stopAndTranscribe()
91
+ }
92
+ }
93
+ updateFnMonitor()
94
+
95
+ // Set up custom shortcut (toggle mode — press to start, press to stop)
96
+ KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
97
+ Task { @MainActor [weak self] in
98
+ self?.toggleRecording()
99
+ }
100
+ }
101
+
102
+ updateStatus()
103
+ }
104
+
105
+ private func updateFnMonitor() {
106
+ if useFnKey {
107
+ let ok = fnMonitor.start()
108
+ if !ok {
109
+ statusMessage = "fn needs Accessibility permission (System Settings > Privacy & Security > Accessibility)"
110
+ }
111
+ } else {
112
+ fnMonitor.stop()
113
+ }
114
+ }
115
+
116
+ func updateStatus() {
117
+ if isRecording || isTranscribing { return }
118
+
119
+ var parts: [String] = []
120
+ if useFnKey {
121
+ parts.append("fn (hold)")
122
+ }
123
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
124
+ parts.append(shortcut.description)
125
+ }
126
+
127
+ if parts.isEmpty {
128
+ statusMessage = "No shortcut set — enable fn or set a custom shortcut"
129
+ } else {
130
+ statusMessage = "Ready — press \(parts.joined(separator: " or ")) to record"
131
+ }
132
+ }
133
+
134
+ // MARK: - Toggle
135
+
136
+ func toggleRecording() {
137
+ if isRecording { stopAndTranscribe() } else { startRecording() }
138
+ }
139
+
140
+ // MARK: - Start Recording
141
+
142
+ func startRecording() {
143
+ guard !isRecording else { return }
144
+
145
+ let ts = DateFormatter()
146
+ ts.dateFormat = "yyyyMMdd'T'HHmmss"
147
+ let path = "\(audioDir)/rec-\(ts.string(from: Date())).wav"
148
+ currentAudioPath = path
149
+
150
+ let proc = Process()
151
+ let stdin = Pipe()
152
+ proc.executableURL = URL(fileURLWithPath: "/bin/bash")
153
+ proc.arguments = ["-c", """
154
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
155
+ if command -v ffmpeg &>/dev/null; then
156
+ ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y 2>/dev/null
157
+ elif command -v rec &>/dev/null; then
158
+ rec -r 16000 -c 1 -b 16 "\(path)" trim 0 \(maxDuration)
159
+ else
160
+ exit 1
161
+ fi
162
+ """]
163
+ proc.standardInput = stdin
164
+ proc.standardOutput = FileHandle.nullDevice
165
+ proc.standardError = FileHandle.nullDevice
166
+
167
+ do {
168
+ try proc.run()
169
+ recordProcess = proc
170
+ stdinPipe = stdin
171
+ isRecording = true
172
+ recordingDuration = 0
173
+ statusMessage = mode == .command ? "Speak your instruction..." : "Recording — press fn to stop"
174
+
175
+ recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
176
+ Task { @MainActor [weak self] in
177
+ self?.recordingDuration += 0.1
178
+ }
179
+ }
180
+ } catch {
181
+ statusMessage = "Failed: \(error.localizedDescription)"
182
+ }
183
+ }
184
+
185
+ // MARK: - Stop & Transcribe
186
+
187
+ func stopAndTranscribe() {
188
+ guard let proc = recordProcess else { return }
189
+
190
+ recordingTimer?.invalidate()
191
+ recordingTimer = nil
192
+
193
+ if let pipe = stdinPipe {
194
+ try? pipe.fileHandleForWriting.write(contentsOf: Data("q".utf8))
195
+ try? pipe.fileHandleForWriting.close()
196
+ }
197
+
198
+ isRecording = false
199
+ isTranscribing = true
200
+ statusMessage = "Transcribing..."
201
+
202
+ let audioPath = currentAudioPath
203
+ let curMode = mode
204
+ currentAudioPath = nil
205
+ recordProcess = nil
206
+ stdinPipe = nil
207
+ let homePath = home
208
+
209
+ Task.detached {
210
+ try? await Task.sleep(for: .seconds(1))
211
+ if proc.isRunning { proc.terminate() }
212
+ try? await Task.sleep(for: .milliseconds(300))
213
+ if proc.isRunning { proc.interrupt() }
214
+
215
+ guard let audioPath else {
216
+ await MainActor.run { self.finish("No audio file") }
217
+ return
218
+ }
219
+
220
+ let attrs = try? FileManager.default.attributesOfItem(atPath: audioPath)
221
+ let size = (attrs?[.size] as? Int) ?? 0
222
+ guard size >= 1000 else {
223
+ await MainActor.run { self.finish("Audio too short — speak longer") }
224
+ return
225
+ }
226
+
227
+ let output = CLIRunner.run(["transcribe", audioPath, "--json"], home: homePath)
228
+ let text = CLIRunner.parseJSON(output)
229
+
230
+ guard let text, !text.isEmpty else {
231
+ await MainActor.run { self.finish("Empty transcription") }
232
+ return
233
+ }
234
+
235
+ await MainActor.run {
236
+ self.isTranscribing = false
237
+ if curMode == .command {
238
+ self.runCommandMode(instruction: text)
239
+ } else {
240
+ self.pasteIntoFrontApp(text)
241
+ self.recentTranscriptions.insert(
242
+ TranscriptionResult(rawText: text, processedText: nil, timestamp: Date()), at: 0
243
+ )
244
+ if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
245
+ self.statusMessage = "Pasted: \(String(text.prefix(50)))"
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ private func finish(_ msg: String) {
252
+ isTranscribing = false
253
+ statusMessage = msg
254
+ }
255
+
256
+ // MARK: - Command Mode
257
+
258
+ private func runCommandMode(instruction: String) {
259
+ postKey(0x08, flags: .maskCommand) // Cmd+C
260
+ let homePath = home
261
+
262
+ Task {
263
+ try? await Task.sleep(for: .milliseconds(250))
264
+ let selected = NSPasteboard.general.string(forType: .string) ?? ""
265
+ guard !selected.isEmpty else {
266
+ statusMessage = "No text selected"
267
+ return
268
+ }
269
+ statusMessage = "Rewriting..."
270
+ isTranscribing = true
271
+ let prompt = "Rewrite: \"\(instruction)\"\n\nText:\n\(selected)"
272
+
273
+ Task.detached {
274
+ let result = CLIRunner.run(["transcribe", "--text", prompt, "--enhance"], home: homePath)
275
+ await MainActor.run {
276
+ self.isTranscribing = false
277
+ if !result.isEmpty {
278
+ self.pasteIntoFrontApp(result)
279
+ self.statusMessage = "Rewritten"
280
+ } else {
281
+ self.statusMessage = "Rewrite failed"
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // MARK: - Paste
289
+
290
+ func pasteIntoFrontApp(_ text: String) {
291
+ let pb = NSPasteboard.general
292
+ pb.clearContents()
293
+ pb.setString(text, forType: .string)
294
+
295
+ // Activate the last user app (not us)
296
+ let myPID = ProcessInfo.processInfo.processIdentifier
297
+ if let target = NSWorkspace.shared.runningApplications.first(where: {
298
+ $0.activationPolicy == .regular && $0.processIdentifier != myPID
299
+ }) {
300
+ target.activate()
301
+ }
302
+
303
+ // Wait for activation, then Cmd+V
304
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
305
+ self.postKey(0x09, flags: .maskCommand)
306
+ }
307
+ }
308
+
309
+ private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
310
+ let src = CGEventSource(stateID: .hidSystemState)
311
+ let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true)
312
+ down?.flags = flags
313
+ down?.post(tap: .cghidEventTap)
314
+ let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false)
315
+ up?.flags = flags
316
+ up?.post(tap: .cghidEventTap)
317
+ }
318
+ }
319
+
320
+ // MARK: - CLI Runner
321
+
322
+ enum CLIRunner: Sendable {
323
+ static func run(_ args: [String], home: String) -> String {
324
+ let bin = "\(home)/.bun/bin/recordings"
325
+ let escaped = args.map { "\"\($0)\"" }.joined(separator: " ")
326
+ let proc = Process()
327
+ let pipe = Pipe()
328
+ proc.executableURL = URL(fileURLWithPath: "/bin/bash")
329
+ proc.arguments = ["-c", """
330
+ export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
331
+ "\(bin)" \(escaped)
332
+ """]
333
+ proc.standardOutput = pipe
334
+ proc.standardError = FileHandle.nullDevice
335
+ do {
336
+ try proc.run()
337
+ proc.waitUntilExit()
338
+ return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
339
+ } catch { return "" }
340
+ }
341
+
342
+ static func parseJSON(_ output: String) -> String? {
343
+ if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards) {
344
+ let json = String(output[s.lowerBound...e.upperBound])
345
+ if let data = json.data(using: .utf8),
346
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
347
+ if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
348
+ if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
349
+ }
350
+ }
351
+ return output.components(separatedBy: "\n")
352
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
353
+ .first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") }
354
+ }
355
+ }
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.app-sandbox</key>
6
+ <false/>
7
+ <key>com.apple.security.device.audio-input</key>
8
+ <true/>
9
+ <key>com.apple.security.automation.apple-events</key>
10
+ <true/>
11
+ </dict>
12
+ </plist>
@@ -0,0 +1,30 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ @main
5
+ struct RecordingsApp: App {
6
+ @StateObject private var engine = RecordingEngine()
7
+ @StateObject private var shortcuts = VoiceShortcuts()
8
+
9
+ var body: some Scene {
10
+ MenuBarExtra {
11
+ MenuBarPopover(engine: engine, shortcuts: shortcuts)
12
+ .frame(width: 320, height: 440)
13
+ } label: {
14
+ if engine.isRecording {
15
+ Image(systemName: "record.circle.fill")
16
+ .symbolRenderingMode(.multicolor)
17
+ } else if engine.isTranscribing {
18
+ Image(systemName: "ellipsis.circle")
19
+ } else {
20
+ Image(systemName: "mic.fill")
21
+ }
22
+ }
23
+ .menuBarExtraStyle(.window)
24
+
25
+ // Settings window — opened via SettingsLink
26
+ Settings {
27
+ SettingsView(engine: engine, shortcuts: shortcuts)
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,93 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ struct SettingsView: View {
5
+ @ObservedObject var engine: RecordingEngine
6
+ @ObservedObject var shortcuts: VoiceShortcuts
7
+ @State private var newTrigger = ""
8
+ @State private var newContent = ""
9
+
10
+ var body: some View {
11
+ TabView {
12
+ generalTab.tabItem { Label("General", systemImage: "gear") }
13
+ shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
14
+ }
15
+ .frame(width: 480, height: 400)
16
+ }
17
+
18
+ private var generalTab: some View {
19
+ Form {
20
+ Section("Shortcut — fn Key") {
21
+ Toggle("Use fn (Globe) key", isOn: $engine.useFnKey)
22
+
23
+ if engine.useFnKey {
24
+ Text("Hold fn to record, release to stop and paste.")
25
+ .font(.caption).foregroundStyle(.secondary)
26
+ HStack(spacing: 4) {
27
+ Image(systemName: "exclamationmark.triangle.fill")
28
+ .foregroundStyle(.orange)
29
+ Text("Required: System Settings → Keyboard → set \"Press 🌐 key to: Do Nothing\"")
30
+ .font(.caption)
31
+ }
32
+ Button("Open Keyboard Settings") {
33
+ NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!)
34
+ }.controlSize(.small)
35
+ }
36
+ }
37
+
38
+ Section("Shortcut — Custom Key") {
39
+ HStack {
40
+ Text("Toggle Recording")
41
+ Spacer()
42
+ KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
43
+ engine.updateStatus()
44
+ }
45
+ }
46
+ Text("Click the field and press any key combo (e.g. ⌥D, ⌃R, F5).")
47
+ .font(.caption).foregroundStyle(.secondary)
48
+ }
49
+
50
+ Section("Recording") {
51
+ Picker("Mode", selection: $engine.mode) {
52
+ ForEach(RecordingMode.allCases) { m in
53
+ Label(m.rawValue, systemImage: m.icon).tag(m)
54
+ }
55
+ }
56
+ Toggle("Whisper Mode", isOn: $engine.isWhisperMode)
57
+ }
58
+ }
59
+ .formStyle(.grouped).padding()
60
+ }
61
+
62
+ private var shortcutsTab: some View {
63
+ VStack(spacing: 0) {
64
+ HStack(spacing: 8) {
65
+ TextField("Trigger phrase", text: $newTrigger).textFieldStyle(.roundedBorder)
66
+ TextField("Text to insert", text: $newContent).textFieldStyle(.roundedBorder)
67
+ Button("Add") {
68
+ guard !newTrigger.isEmpty, !newContent.isEmpty else { return }
69
+ shortcuts.add(trigger: newTrigger, content: newContent)
70
+ newTrigger = ""; newContent = ""
71
+ }.buttonStyle(.borderedProminent).disabled(newTrigger.isEmpty || newContent.isEmpty)
72
+ }.padding()
73
+ Divider()
74
+ if shortcuts.shortcuts.isEmpty {
75
+ Spacer()
76
+ VStack(spacing: 8) {
77
+ Image(systemName: "text.badge.star").font(.largeTitle).foregroundStyle(.quaternary)
78
+ Text("No voice shortcuts yet").foregroundStyle(.secondary)
79
+ }
80
+ Spacer()
81
+ } else {
82
+ List {
83
+ ForEach(shortcuts.shortcuts) { s in
84
+ VStack(alignment: .leading) {
85
+ Text(s.trigger).font(.body.bold())
86
+ Text(s.content).font(.caption).foregroundStyle(.secondary).lineLimit(2)
87
+ }
88
+ }.onDelete(perform: shortcuts.remove)
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }