Skip to content

Commit 5c14d12

Browse files
committed
feat: show floating mic indicator near text cursor during recording
- Floating red mic icon appears near the text cursor when recording starts - Pulses with audio level for visual feedback - Uses Accessibility API (AXUIElement) to locate the caret position - Falls back to mouse cursor position if caret can't be detected - Repositions every 0.5s to follow cursor movement/scrolling - Non-interactive overlay (clicks pass through) - Toggle in Settings > General: 'Show mic indicator near cursor' - Enabled by default, easily disabled per user preference
1 parent c95218f commit 5c14d12

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ final class AppState: ObservableObject {
102102
@AppStorage("vocamac.launchAtLogin") var launchAtLogin: Bool = false
103103
@AppStorage("vocamac.preserveClipboard") var preserveClipboard: Bool = true
104104
@AppStorage("vocamac.soundEffectsEnabled") var soundEffectsEnabled: Bool = true
105+
@AppStorage("vocamac.showCursorIndicator") var showCursorIndicator: Bool = true
105106

106107
// MARK: - Services
107108

@@ -111,6 +112,7 @@ final class AppState: ObservableObject {
111112
let hotKeyManager = HotKeyManager()
112113
let modelManager = ModelManager()
113114
let soundManager = SoundManager()
115+
let cursorOverlay = CursorOverlayManager()
114116

115117
// MARK: - Private
116118

@@ -152,6 +154,7 @@ final class AppState: ObservableObject {
152154
audioEngine.onAudioLevel = { [weak self] level in
153155
Task { @MainActor in
154156
self?.audioLevel = level
157+
self?.cursorOverlay.updateAudioLevel(level)
155158
}
156159
}
157160

@@ -232,6 +235,11 @@ final class AppState: ObservableObject {
232235
soundManager.playStartSound()
233236
}
234237

238+
// Show cursor indicator
239+
if showCursorIndicator {
240+
cursorOverlay.show()
241+
}
242+
235243
audioEngine.startRecording(
236244
silenceThreshold: Float(silenceThreshold),
237245
silenceDuration: silenceDuration,
@@ -251,6 +259,9 @@ final class AppState: ObservableObject {
251259
soundManager.playStopSound()
252260
}
253261

262+
// Hide cursor indicator
263+
cursorOverlay.hide()
264+
254265
guard !audioData.isEmpty else {
255266
appStatus = .idle
256267
return
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// CursorOverlayManager.swift
2+
// VocaMac
3+
//
4+
// Shows a floating mic indicator near the text cursor during recording.
5+
// Uses the Accessibility API to locate the caret position in the focused app,
6+
// then renders a small, non-interactive overlay that pulses with audio level.
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
// MARK: - CursorOverlayManager
12+
13+
@MainActor
14+
final class CursorOverlayManager {
15+
16+
// MARK: - Properties
17+
18+
/// The floating panel that hosts the mic indicator
19+
private var overlayPanel: NSPanel?
20+
21+
/// Hosting view for the SwiftUI indicator content
22+
private var hostingView: NSHostingView<MicIndicatorView>?
23+
24+
/// The SwiftUI view model driving the indicator animation
25+
private let viewModel = MicIndicatorViewModel()
26+
27+
/// Timer to periodically reposition the overlay to follow the cursor
28+
private var repositionTimer: Timer?
29+
30+
// MARK: - Public API
31+
32+
/// Show the recording indicator near the text cursor
33+
func show() {
34+
guard overlayPanel == nil else { return }
35+
36+
let indicatorView = MicIndicatorView(viewModel: viewModel)
37+
let hosting = NSHostingView(rootView: indicatorView)
38+
hosting.frame = NSRect(x: 0, y: 0, width: 36, height: 36)
39+
40+
let panel = NSPanel(
41+
contentRect: NSRect(x: 0, y: 0, width: 36, height: 36),
42+
styleMask: [.borderless, .nonactivatingPanel],
43+
backing: .buffered,
44+
defer: false
45+
)
46+
panel.isOpaque = false
47+
panel.backgroundColor = .clear
48+
panel.hasShadow = false
49+
panel.level = .floating
50+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
51+
panel.ignoresMouseEvents = true
52+
panel.contentView = hosting
53+
54+
// Position near the text cursor
55+
positionNearCaret(panel)
56+
57+
panel.orderFront(nil)
58+
overlayPanel = panel
59+
hostingView = hosting
60+
61+
// Reposition periodically in case the user scrolls or the cursor moves
62+
repositionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
63+
Task { @MainActor in
64+
guard let self = self, let panel = self.overlayPanel else { return }
65+
self.positionNearCaret(panel)
66+
}
67+
}
68+
69+
viewModel.isActive = true
70+
NSLog("[CursorOverlay] Indicator shown")
71+
}
72+
73+
/// Hide the recording indicator
74+
func hide() {
75+
repositionTimer?.invalidate()
76+
repositionTimer = nil
77+
viewModel.isActive = false
78+
overlayPanel?.orderOut(nil)
79+
overlayPanel = nil
80+
hostingView = nil
81+
NSLog("[CursorOverlay] Indicator hidden")
82+
}
83+
84+
/// Update the audio level for the pulsing animation
85+
func updateAudioLevel(_ level: Float) {
86+
viewModel.audioLevel = level
87+
}
88+
89+
// MARK: - Caret Position Detection
90+
91+
/// Position the panel near the text caret using the Accessibility API.
92+
/// Falls back to positioning near the mouse cursor if the caret can't be found.
93+
private func positionNearCaret(_ panel: NSPanel) {
94+
if let caretRect = getCaretRect() {
95+
// Place the indicator just above and to the right of the caret
96+
let screenPoint = NSPoint(
97+
x: caretRect.origin.x + caretRect.width + 4,
98+
y: caretRect.origin.y + caretRect.height + 4
99+
)
100+
panel.setFrameOrigin(screenPoint)
101+
} else {
102+
// Fallback: position near the mouse cursor
103+
let mouseLocation = NSEvent.mouseLocation
104+
panel.setFrameOrigin(NSPoint(
105+
x: mouseLocation.x + 16,
106+
y: mouseLocation.y - 40
107+
))
108+
}
109+
}
110+
111+
/// Use the Accessibility API to get the bounding rect of the text caret
112+
/// in the currently focused application.
113+
private func getCaretRect() -> CGRect? {
114+
// Get the focused application
115+
let systemWide = AXUIElementCreateSystemWide()
116+
117+
var focusedApp: AnyObject?
118+
guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedApplicationAttribute as CFString, &focusedApp) == .success else {
119+
return nil
120+
}
121+
122+
// Get the focused UI element (usually a text field)
123+
var focusedElement: AnyObject?
124+
guard AXUIElementCopyAttributeValue(focusedApp as! AXUIElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) == .success else {
125+
return nil
126+
}
127+
128+
let element = focusedElement as! AXUIElement
129+
130+
// Get the selected text range (caret position)
131+
var selectedRange: AnyObject?
132+
guard AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &selectedRange) == .success else {
133+
return nil
134+
}
135+
136+
// Get the bounds of the selected range (caret position on screen)
137+
var bounds: AnyObject?
138+
guard AXUIElementCopyParameterizedAttributeValue(
139+
element,
140+
kAXBoundsForRangeParameterizedAttribute as CFString,
141+
selectedRange!,
142+
&bounds
143+
) == .success else {
144+
return nil
145+
}
146+
147+
// Convert AXValue to CGRect
148+
var rect = CGRect.zero
149+
guard AXValueGetValue(bounds as! AXValue, .cgRect, &rect) else {
150+
return nil
151+
}
152+
153+
// AX coordinates are top-left origin; convert to NSScreen bottom-left origin
154+
if let screen = NSScreen.main {
155+
rect.origin.y = screen.frame.height - rect.origin.y - rect.height
156+
}
157+
158+
return rect
159+
}
160+
}
161+
162+
// MARK: - MicIndicatorViewModel
163+
164+
@MainActor
165+
final class MicIndicatorViewModel: ObservableObject {
166+
@Published var isActive: Bool = false
167+
@Published var audioLevel: Float = 0.0
168+
}
169+
170+
// MARK: - MicIndicatorView
171+
172+
struct MicIndicatorView: View {
173+
@ObservedObject var viewModel: MicIndicatorViewModel
174+
175+
var body: some View {
176+
ZStack {
177+
// Pulsing background circle
178+
Circle()
179+
.fill(Color.red.opacity(0.2))
180+
.frame(width: pulseSize, height: pulseSize)
181+
.animation(.easeInOut(duration: 0.3), value: viewModel.audioLevel)
182+
183+
// Background circle
184+
Circle()
185+
.fill(Color.red.opacity(0.85))
186+
.frame(width: 28, height: 28)
187+
.shadow(color: .red.opacity(0.4), radius: 4, x: 0, y: 0)
188+
189+
// Mic icon
190+
Image(systemName: "mic.fill")
191+
.font(.system(size: 14, weight: .semibold))
192+
.foregroundColor(.white)
193+
}
194+
.opacity(viewModel.isActive ? 1 : 0)
195+
.animation(.easeInOut(duration: 0.2), value: viewModel.isActive)
196+
}
197+
198+
/// Size of the pulse circle, driven by audio level
199+
private var pulseSize: CGFloat {
200+
let base: CGFloat = 28
201+
let maxPulse: CGFloat = 36
202+
let level = CGFloat(min(max(viewModel.audioLevel, 0), 1))
203+
return base + (maxPulse - base) * level
204+
}
205+
}

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ struct GeneralSettingsTab: View {
131131
Section("Behavior") {
132132
Toggle("Preserve clipboard after text injection", isOn: $appState.preserveClipboard)
133133

134+
Toggle("Show mic indicator near cursor while recording", isOn: $appState.showCursorIndicator)
135+
134136
Text("When enabled, your clipboard contents are restored after injecting text.")
135137
.font(.caption)
136138
.foregroundStyle(.secondary)

0 commit comments

Comments
 (0)