@hasna/recordings 0.1.10 → 0.1.11
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.
- package/.takumi/settings.local.json +7 -0
- package/dist/cli/index.js +73 -122
- package/dist/index.js +64 -25
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/mcp/index.js +65 -26
- package/package.json +1 -1
- package/src/__tests__/enhancer.test.ts +65 -0
- package/src/cli/index.ts +3 -130
- package/src/lib/config.ts +56 -16
- package/src/lib/enhancer.ts +17 -11
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +58 -49
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +247 -118
- package/src/native/Recordings/Recordings/ProjectStore.swift +126 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +157 -57
- package/src/native/Recordings/Recordings/RecordingsApp.swift +12 -4
- package/src/native/Recordings/Recordings/SettingsView.swift +147 -36
- package/src/native/RecordingsHelper.swift +0 -395
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Cocoa
|
|
2
2
|
|
|
3
3
|
/// Monitors the fn/Globe key using CGEventTap.
|
|
4
|
-
///
|
|
5
|
-
///
|
|
4
|
+
/// Fn is exposed as a modifier flag, so we watch flagsChanged events and
|
|
5
|
+
/// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
|
|
6
6
|
final class FnKeyMonitor: @unchecked Sendable {
|
|
7
7
|
var onFnKeyDown: (() -> Void)?
|
|
8
8
|
var onFnKeyUp: (() -> Void)?
|
|
@@ -13,7 +13,6 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
13
13
|
private var fnIsDown = false
|
|
14
14
|
|
|
15
15
|
private static let fnKeyCode: UInt16 = 63
|
|
16
|
-
private static let fnFlagMask: UInt64 = 0x800000
|
|
17
16
|
|
|
18
17
|
/// Start monitoring. Returns true if successful.
|
|
19
18
|
func start() -> Bool {
|
|
@@ -22,46 +21,54 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
22
21
|
return true
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self.runLoopSource = source
|
|
51
|
-
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
52
|
-
CGEvent.tapEnable(tap: tap, enable: true)
|
|
53
|
-
fputs("[FnKeyMonitor] Event tap created and enabled OK\n", stderr)
|
|
24
|
+
let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
|
|
25
|
+
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
|
26
|
+
let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
|
|
27
|
+
(.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
|
|
28
|
+
(.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
|
|
29
|
+
(.cghidEventTap, .headInsertEventTap, "hid/head"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (tapLocation, tapPlacement, label) in candidates {
|
|
33
|
+
fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
|
|
34
|
+
|
|
35
|
+
guard let tap = CGEvent.tapCreate(
|
|
36
|
+
tap: tapLocation,
|
|
37
|
+
place: tapPlacement,
|
|
38
|
+
options: .defaultTap,
|
|
39
|
+
eventsOfInterest: eventMask,
|
|
40
|
+
callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
|
|
41
|
+
guard let refcon else { return Unmanaged.passRetained(event) }
|
|
42
|
+
let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
|
|
43
|
+
return monitor.handleEvent(type: type, event: event)
|
|
44
|
+
},
|
|
45
|
+
userInfo: selfPtr
|
|
46
|
+
) else {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
54
49
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
eventTap = tap
|
|
51
|
+
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
52
|
+
if let runLoopSource {
|
|
53
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
|
54
|
+
}
|
|
55
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
56
|
+
fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
|
|
57
|
+
|
|
58
|
+
// Health check: macOS can silently disable taps — re-enable every 3 seconds.
|
|
59
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
60
|
+
guard let self, let tap = self.eventTap else { return }
|
|
61
|
+
if !CGEvent.tapIsEnabled(tap: tap) {
|
|
62
|
+
fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
|
|
63
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
64
|
+
}
|
|
61
65
|
}
|
|
66
|
+
|
|
67
|
+
return true
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
|
|
71
|
+
return false
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
func stop() {
|
|
@@ -80,7 +87,6 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
|
83
|
-
// Re-enable if macOS disabled the tap
|
|
84
90
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
85
91
|
if let tap = eventTap {
|
|
86
92
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
@@ -88,33 +94,36 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
88
94
|
return Unmanaged.passRetained(event)
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
guard type == .flagsChanged else {
|
|
98
|
+
return Unmanaged.passRetained(event)
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
|
|
102
|
+
let fnPressed = event.flags.contains(.maskSecondaryFn)
|
|
103
|
+
let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
guard keyCode == FnKeyMonitor.fnKeyCode else {
|
|
105
|
+
guard isFnTransition else {
|
|
95
106
|
return Unmanaged.passRetained(event)
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
let flags = event.flags.rawValue
|
|
99
|
-
let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
|
|
100
|
-
|
|
101
110
|
fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
|
|
102
111
|
|
|
103
112
|
if fnPressed && !fnIsDown {
|
|
104
|
-
// fn just pressed
|
|
105
113
|
fnIsDown = true
|
|
106
114
|
fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
|
|
107
115
|
DispatchQueue.main.async { [weak self] in
|
|
108
116
|
self?.onFnKeyDown?()
|
|
109
117
|
}
|
|
110
|
-
return nil
|
|
111
|
-
}
|
|
112
|
-
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if !fnPressed && fnIsDown {
|
|
113
122
|
fnIsDown = false
|
|
114
123
|
DispatchQueue.main.async { [weak self] in
|
|
115
124
|
self?.onFnKeyUp?()
|
|
116
125
|
}
|
|
117
|
-
return nil
|
|
126
|
+
return nil
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
return Unmanaged.passRetained(event)
|
|
@@ -4,89 +4,81 @@ import KeyboardShortcuts
|
|
|
4
4
|
struct MenuBarPopover: View {
|
|
5
5
|
@ObservedObject var engine: RecordingEngine
|
|
6
6
|
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
+
@ObservedObject var projectStore: ProjectStore
|
|
8
|
+
@State private var copiedIndex: Int?
|
|
9
|
+
@State private var filterProjectId: String?
|
|
10
|
+
|
|
11
|
+
private var filteredTranscriptions: [TranscriptionResult] {
|
|
12
|
+
guard let filter = filterProjectId else {
|
|
13
|
+
return engine.recentTranscriptions
|
|
14
|
+
}
|
|
15
|
+
if filter == "__none__" {
|
|
16
|
+
return engine.recentTranscriptions.filter { $0.projectId == nil }
|
|
17
|
+
}
|
|
18
|
+
return engine.recentTranscriptions.filter { $0.projectId == filter }
|
|
19
|
+
}
|
|
7
20
|
|
|
8
21
|
var body: some View {
|
|
9
22
|
VStack(spacing: 0) {
|
|
10
|
-
header
|
|
11
|
-
|
|
12
|
-
modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
|
|
13
|
-
shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
|
|
23
|
+
header
|
|
24
|
+
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
14
25
|
Divider()
|
|
15
|
-
recordingArea
|
|
26
|
+
recordingArea
|
|
27
|
+
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
16
28
|
Divider()
|
|
17
|
-
|
|
29
|
+
if !engine.recentTranscriptions.isEmpty {
|
|
30
|
+
filterBar
|
|
31
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
32
|
+
Divider()
|
|
33
|
+
}
|
|
34
|
+
recentList
|
|
35
|
+
.frame(maxHeight: 200)
|
|
18
36
|
Divider()
|
|
19
|
-
|
|
37
|
+
footerMenu
|
|
20
38
|
}
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
// MARK: - Header
|
|
24
42
|
|
|
25
43
|
private var header: some View {
|
|
26
|
-
|
|
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
|
|
44
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
67
45
|
HStack {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
Image(systemName: "mic.fill")
|
|
47
|
+
.foregroundStyle(.tint)
|
|
48
|
+
Text("Hasna Recordings")
|
|
49
|
+
Spacer()
|
|
50
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
51
|
+
Text(shortcut.description)
|
|
52
|
+
.foregroundStyle(.secondary)
|
|
73
53
|
}
|
|
74
|
-
.toggleStyle(.switch).controlSize(.small)
|
|
75
54
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
55
|
+
if !projectStore.settings.projects.isEmpty {
|
|
56
|
+
HStack(spacing: 4) {
|
|
57
|
+
Image(systemName: "folder.fill")
|
|
58
|
+
.foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
|
|
59
|
+
Menu {
|
|
60
|
+
Button("None") { projectStore.setActive(nil) }
|
|
61
|
+
Divider()
|
|
62
|
+
ForEach(projectStore.settings.projects) { project in
|
|
63
|
+
Button {
|
|
64
|
+
projectStore.setActive(project.id)
|
|
65
|
+
} label: {
|
|
66
|
+
HStack {
|
|
67
|
+
Text(project.name)
|
|
68
|
+
if project.id == projectStore.settings.activeProjectId {
|
|
69
|
+
Image(systemName: "checkmark")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} label: {
|
|
75
|
+
Text(projectStore.activeProject?.name ?? "No project")
|
|
76
|
+
.foregroundStyle(projectStore.activeProject == nil ? .secondary : .primary)
|
|
77
|
+
}
|
|
78
|
+
.menuStyle(.borderlessButton)
|
|
79
|
+
.fixedSize()
|
|
80
|
+
Spacer()
|
|
81
|
+
}
|
|
90
82
|
}
|
|
91
83
|
}
|
|
92
84
|
}
|
|
@@ -94,41 +86,94 @@ struct MenuBarPopover: View {
|
|
|
94
86
|
// MARK: - Recording Area
|
|
95
87
|
|
|
96
88
|
private var recordingArea: some View {
|
|
97
|
-
|
|
89
|
+
Group {
|
|
98
90
|
if engine.isRecording {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
VStack(spacing: 6) {
|
|
92
|
+
HStack {
|
|
93
|
+
Circle().fill(.red).frame(width: 8, height: 8)
|
|
94
|
+
Text(fmt(engine.recordingDuration))
|
|
95
|
+
.monospacedDigit()
|
|
96
|
+
Spacer()
|
|
97
|
+
Button("Stop") { engine.stopAndTranscribe() }
|
|
98
|
+
.controlSize(.small)
|
|
99
|
+
}
|
|
100
|
+
if let project = projectStore.activeProject {
|
|
101
|
+
HStack(spacing: 4) {
|
|
102
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
103
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
104
|
+
Spacer()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
106
107
|
}
|
|
107
|
-
.padding(
|
|
108
|
+
.padding(10)
|
|
109
|
+
.glassEffect(.regular)
|
|
108
110
|
} else if engine.isTranscribing {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
VStack(spacing: 6) {
|
|
112
|
+
HStack(spacing: 8) {
|
|
113
|
+
ProgressView().controlSize(.small)
|
|
114
|
+
Text("Transcribing...")
|
|
115
|
+
.foregroundStyle(.secondary)
|
|
116
|
+
Spacer()
|
|
117
|
+
}
|
|
118
|
+
if let project = projectStore.activeProject {
|
|
119
|
+
HStack(spacing: 4) {
|
|
120
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
121
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
122
|
+
Spacer()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
112
125
|
}
|
|
113
|
-
.frame(maxWidth: .infinity)
|
|
126
|
+
.frame(maxWidth: .infinity)
|
|
127
|
+
.padding(10)
|
|
128
|
+
.glassEffect(.regular)
|
|
114
129
|
} else {
|
|
115
|
-
VStack(spacing:
|
|
130
|
+
VStack(spacing: 6) {
|
|
116
131
|
Button { engine.startRecording() } label: {
|
|
117
|
-
Label("Record", systemImage: "mic.circle.fill")
|
|
118
|
-
}
|
|
132
|
+
Label("Record", systemImage: "mic.circle.fill")
|
|
133
|
+
}
|
|
134
|
+
.controlSize(.regular)
|
|
119
135
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
137
|
+
Text("or hold \(shortcut.description)")
|
|
138
|
+
.foregroundStyle(.tertiary)
|
|
139
|
+
}
|
|
124
140
|
}
|
|
125
|
-
.frame(maxWidth: .infinity)
|
|
141
|
+
.frame(maxWidth: .infinity)
|
|
142
|
+
.padding(10)
|
|
143
|
+
.glassEffect(.regular)
|
|
126
144
|
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
127
147
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// MARK: - Filter Bar
|
|
149
|
+
|
|
150
|
+
private var filterBar: some View {
|
|
151
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
152
|
+
HStack(spacing: 6) {
|
|
153
|
+
FilterChip(label: "All", isActive: filterProjectId == nil) {
|
|
154
|
+
filterProjectId = nil
|
|
155
|
+
}
|
|
156
|
+
ForEach(projectStore.settings.projects) { project in
|
|
157
|
+
let count = engine.recentTranscriptions.filter { $0.projectId == project.id }.count
|
|
158
|
+
if count > 0 {
|
|
159
|
+
FilterChip(
|
|
160
|
+
label: "\(project.name) (\(count))",
|
|
161
|
+
isActive: filterProjectId == project.id
|
|
162
|
+
) {
|
|
163
|
+
filterProjectId = filterProjectId == project.id ? nil : project.id
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let noProjectCount = engine.recentTranscriptions.filter { $0.projectId == nil }.count
|
|
168
|
+
if noProjectCount > 0 {
|
|
169
|
+
FilterChip(
|
|
170
|
+
label: "No project (\(noProjectCount))",
|
|
171
|
+
isActive: filterProjectId == "__none__"
|
|
172
|
+
) {
|
|
173
|
+
filterProjectId = filterProjectId == "__none__" ? nil : "__none__"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
132
177
|
}
|
|
133
178
|
}
|
|
134
179
|
|
|
@@ -136,18 +181,30 @@ struct MenuBarPopover: View {
|
|
|
136
181
|
|
|
137
182
|
private var recentList: some View {
|
|
138
183
|
Group {
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
|
|
142
|
-
Spacer()
|
|
143
|
-
}.frame(maxWidth: .infinity)
|
|
184
|
+
if filteredTranscriptions.isEmpty {
|
|
185
|
+
Spacer()
|
|
144
186
|
} else {
|
|
145
187
|
ScrollView {
|
|
146
|
-
LazyVStack(alignment: .leading, spacing:
|
|
147
|
-
ForEach(
|
|
148
|
-
|
|
188
|
+
LazyVStack(alignment: .leading, spacing: 2) {
|
|
189
|
+
ForEach(filteredTranscriptions.indices, id: \.self) { i in
|
|
190
|
+
let item = filteredTranscriptions[i]
|
|
191
|
+
TranscriptionRow(
|
|
192
|
+
item: item,
|
|
193
|
+
showProject: filterProjectId == nil,
|
|
194
|
+
isCopied: copiedIndex == i
|
|
195
|
+
) {
|
|
196
|
+
NSPasteboard.general.clearContents()
|
|
197
|
+
NSPasteboard.general.setString(item.displayText, forType: .string)
|
|
198
|
+
withAnimation(.easeInOut(duration: 0.15)) {
|
|
199
|
+
copiedIndex = i
|
|
200
|
+
}
|
|
201
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
202
|
+
withAnimation { if copiedIndex == i { copiedIndex = nil } }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
149
205
|
}
|
|
150
|
-
}
|
|
206
|
+
}
|
|
207
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
151
208
|
}
|
|
152
209
|
}
|
|
153
210
|
}
|
|
@@ -155,12 +212,33 @@ struct MenuBarPopover: View {
|
|
|
155
212
|
|
|
156
213
|
// MARK: - Footer
|
|
157
214
|
|
|
158
|
-
private var
|
|
159
|
-
|
|
160
|
-
SettingsLink {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
215
|
+
private var footerMenu: some View {
|
|
216
|
+
VStack(spacing: 0) {
|
|
217
|
+
SettingsLink {
|
|
218
|
+
HStack {
|
|
219
|
+
Text("Settings...")
|
|
220
|
+
Spacer()
|
|
221
|
+
Text("⌘,").foregroundStyle(.tertiary)
|
|
222
|
+
}
|
|
223
|
+
.contentShape(Rectangle())
|
|
224
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
225
|
+
}
|
|
226
|
+
.buttonStyle(.plain)
|
|
227
|
+
|
|
228
|
+
Divider()
|
|
229
|
+
|
|
230
|
+
Button {
|
|
231
|
+
NSApplication.shared.terminate(nil)
|
|
232
|
+
} label: {
|
|
233
|
+
HStack {
|
|
234
|
+
Text("Quit Hasna Recordings")
|
|
235
|
+
Spacer()
|
|
236
|
+
Text("⌘Q").foregroundStyle(.tertiary)
|
|
237
|
+
}
|
|
238
|
+
.contentShape(Rectangle())
|
|
239
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
240
|
+
}
|
|
241
|
+
.buttonStyle(.plain)
|
|
164
242
|
}
|
|
165
243
|
}
|
|
166
244
|
|
|
@@ -169,20 +247,71 @@ struct MenuBarPopover: View {
|
|
|
169
247
|
}
|
|
170
248
|
}
|
|
171
249
|
|
|
250
|
+
// MARK: - Filter Chip
|
|
251
|
+
|
|
252
|
+
struct FilterChip: View {
|
|
253
|
+
let label: String
|
|
254
|
+
let isActive: Bool
|
|
255
|
+
let action: () -> Void
|
|
256
|
+
|
|
257
|
+
var body: some View {
|
|
258
|
+
Button(action: action) {
|
|
259
|
+
Text(label)
|
|
260
|
+
.padding(.horizontal, 8)
|
|
261
|
+
.padding(.vertical, 3)
|
|
262
|
+
.background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
263
|
+
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
264
|
+
}
|
|
265
|
+
.buttonStyle(.plain)
|
|
266
|
+
.foregroundStyle(isActive ? .primary : .secondary)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MARK: - Transcription Row
|
|
271
|
+
|
|
172
272
|
struct TranscriptionRow: View {
|
|
173
273
|
let item: TranscriptionResult
|
|
274
|
+
let showProject: Bool
|
|
275
|
+
let isCopied: Bool
|
|
276
|
+
let onCopy: () -> Void
|
|
277
|
+
|
|
174
278
|
var body: some View {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
279
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
280
|
+
HStack(alignment: .top, spacing: 8) {
|
|
281
|
+
Text(item.displayText)
|
|
282
|
+
.lineLimit(2)
|
|
283
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
284
|
+
|
|
285
|
+
if isCopied {
|
|
286
|
+
Image(systemName: "checkmark")
|
|
287
|
+
.foregroundStyle(.green)
|
|
288
|
+
.transition(.scale.combined(with: .opacity))
|
|
289
|
+
} else {
|
|
290
|
+
Text(relativeTime(item.timestamp))
|
|
291
|
+
.foregroundStyle(.tertiary)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if showProject, let name = item.projectName {
|
|
295
|
+
HStack(spacing: 3) {
|
|
296
|
+
Image(systemName: "folder")
|
|
297
|
+
Text(name)
|
|
298
|
+
}
|
|
299
|
+
.foregroundStyle(.tertiary)
|
|
300
|
+
}
|
|
181
301
|
}
|
|
182
|
-
.padding(.vertical, 4)
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
302
|
+
.padding(.vertical, 4)
|
|
303
|
+
.padding(.horizontal, 6)
|
|
304
|
+
.contentShape(Rectangle())
|
|
305
|
+
.onTapGesture { onCopy() }
|
|
306
|
+
.onHover { hovering in
|
|
307
|
+
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
186
308
|
}
|
|
187
309
|
}
|
|
310
|
+
|
|
311
|
+
private func relativeTime(_ date: Date) -> String {
|
|
312
|
+
let s = Date().timeIntervalSince(date)
|
|
313
|
+
if s < 60 { return "now" }
|
|
314
|
+
if s < 3600 { return "\(Int(s / 60))m" }
|
|
315
|
+
return "\(Int(s / 3600))h"
|
|
316
|
+
}
|
|
188
317
|
}
|