vOOice/VoiceInk/HotkeyManager.swift
Beingpax 4ea8d382a4 Add Power Mode keyboard shortcuts and improve session management
Power Mode Keyboard Shortcuts:
- Add hotkeyShortcut property to PowerModeConfig for storing custom shortcuts
- Implement keyboard shortcut UI in Power Mode configuration view
- Add hotkey registration system in HotkeyManager to manage Power Mode shortcuts
- Support cleanup of shortcuts when Power Mode configurations are removed
- Post notifications when Power Mode configurations change

Explicit Power Mode Activation:
- Add optional powerModeId parameter to toggleRecord and toggleMiniRecorder
- Refactor ActiveWindowService.applyConfigurationForCurrentApp to applyConfiguration
- Support direct Power Mode activation via powerModeId instead of auto-detection
- Pass powerModeId through recording flow for explicit mode selection

Session Management Improvements:
- Fix auto-restore to preserve baseline when switching Power Modes mid-recording
- Only capture baseline state on first session creation
- Prevent subsequent beginSession calls from overwriting original baseline
- Ensure auto-restore returns to settings from before recording started

UI Refinements:
- Remove redundant "Record" label from keyboard shortcut recorder
2026-01-03 08:43:11 +05:45

494 lines
18 KiB
Swift

import Foundation
import KeyboardShortcuts
import Carbon
import AppKit
extension KeyboardShortcuts.Name {
static let toggleMiniRecorder = Self("toggleMiniRecorder")
static let toggleMiniRecorder2 = Self("toggleMiniRecorder2")
static let pasteLastTranscription = Self("pasteLastTranscription")
static let pasteLastEnhancement = Self("pasteLastEnhancement")
static let retryLastTranscription = Self("retryLastTranscription")
static let openHistoryWindow = Self("openHistoryWindow")
}
@MainActor
class HotkeyManager: ObservableObject {
@Published var selectedHotkey1: HotkeyOption {
didSet {
UserDefaults.standard.set(selectedHotkey1.rawValue, forKey: "selectedHotkey1")
setupHotkeyMonitoring()
}
}
@Published var selectedHotkey2: HotkeyOption {
didSet {
if selectedHotkey2 == .none {
KeyboardShortcuts.setShortcut(nil, for: .toggleMiniRecorder2)
}
UserDefaults.standard.set(selectedHotkey2.rawValue, forKey: "selectedHotkey2")
setupHotkeyMonitoring()
}
}
@Published var isMiddleClickToggleEnabled: Bool {
didSet {
UserDefaults.standard.set(isMiddleClickToggleEnabled, forKey: "isMiddleClickToggleEnabled")
setupHotkeyMonitoring()
}
}
@Published var middleClickActivationDelay: Int {
didSet {
UserDefaults.standard.set(middleClickActivationDelay, forKey: "middleClickActivationDelay")
}
}
private var whisperState: WhisperState
private var miniRecorderShortcutManager: MiniRecorderShortcutManager
// MARK: - Helper Properties
private var canProcessHotkeyAction: Bool {
whisperState.recordingState != .transcribing && whisperState.recordingState != .enhancing && whisperState.recordingState != .busy
}
// NSEvent monitoring for modifier keys
private var globalEventMonitor: Any?
private var localEventMonitor: Any?
// Middle-click event monitoring
private var middleClickMonitors: [Any?] = []
private var middleClickTask: Task<Void, Never>?
// Key state tracking
private var currentKeyState = false
private var keyPressStartTime: Date?
private let briefPressThreshold = 1.7
private var isHandsFreeMode = false
// Debounce for Fn key
private var fnDebounceTask: Task<Void, Never>?
private var pendingFnKeyState: Bool? = nil
// Keyboard shortcut state tracking
private var shortcutKeyPressStartTime: Date?
private var isShortcutHandsFreeMode = false
private var shortcutCurrentKeyState = false
private var lastShortcutTriggerTime: Date?
private let shortcutCooldownInterval: TimeInterval = 0.5
private var registeredPowerModeIds: Set<UUID> = []
enum HotkeyOption: String, CaseIterable {
case none = "none"
case rightOption = "rightOption"
case leftOption = "leftOption"
case leftControl = "leftControl"
case rightControl = "rightControl"
case fn = "fn"
case rightCommand = "rightCommand"
case rightShift = "rightShift"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .rightOption: return "Right Option (⌥)"
case .leftOption: return "Left Option (⌥)"
case .leftControl: return "Left Control (⌃)"
case .rightControl: return "Right Control (⌃)"
case .fn: return "Fn"
case .rightCommand: return "Right Command (⌘)"
case .rightShift: return "Right Shift (⇧)"
case .custom: return "Custom"
}
}
var keyCode: CGKeyCode? {
switch self {
case .rightOption: return 0x3D
case .leftOption: return 0x3A
case .leftControl: return 0x3B
case .rightControl: return 0x3E
case .fn: return 0x3F
case .rightCommand: return 0x36
case .rightShift: return 0x3C
case .custom, .none: return nil
}
}
var isModifierKey: Bool {
return self != .custom && self != .none
}
}
init(whisperState: WhisperState) {
self.selectedHotkey1 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey1") ?? "") ?? .rightCommand
self.selectedHotkey2 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey2") ?? "") ?? .none
self.isMiddleClickToggleEnabled = UserDefaults.standard.bool(forKey: "isMiddleClickToggleEnabled")
let storedDelay = UserDefaults.standard.integer(forKey: "middleClickActivationDelay")
self.middleClickActivationDelay = storedDelay > 0 ? storedDelay : 200
self.whisperState = whisperState
self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState)
KeyboardShortcuts.onKeyUp(for: .pasteLastTranscription) { [weak self] in
guard let self = self else { return }
Task { @MainActor in
LastTranscriptionService.pasteLastTranscription(from: self.whisperState.modelContext)
}
}
KeyboardShortcuts.onKeyUp(for: .pasteLastEnhancement) { [weak self] in
guard let self = self else { return }
Task { @MainActor in
LastTranscriptionService.pasteLastEnhancement(from: self.whisperState.modelContext)
}
}
KeyboardShortcuts.onKeyUp(for: .retryLastTranscription) { [weak self] in
guard let self = self else { return }
Task { @MainActor in
LastTranscriptionService.retryLastTranscription(from: self.whisperState.modelContext, whisperState: self.whisperState)
}
}
KeyboardShortcuts.onKeyUp(for: .openHistoryWindow) { [weak self] in
guard let self = self else { return }
Task { @MainActor in
HistoryWindowController.shared.showHistoryWindow(
modelContainer: self.whisperState.modelContext.container,
whisperState: self.whisperState
)
}
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 100_000_000)
self.setupHotkeyMonitoring()
self.setupPowerModeHotkeys()
}
// Observe PowerMode configuration changes
NotificationCenter.default.addObserver(
self,
selector: #selector(powerModeConfigurationsDidChange),
name: NSNotification.Name("PowerModeConfigurationsDidChange"),
object: nil
)
}
@objc private func powerModeConfigurationsDidChange() {
Task { @MainActor in
setupPowerModeHotkeys()
}
}
private func setupHotkeyMonitoring() {
removeAllMonitoring()
setupModifierKeyMonitoring()
setupCustomShortcutMonitoring()
setupMiddleClickMonitoring()
}
private func setupModifierKeyMonitoring() {
// Only set up if at least one hotkey is a modifier key
guard (selectedHotkey1.isModifierKey && selectedHotkey1 != .none) || (selectedHotkey2.isModifierKey && selectedHotkey2 != .none) else { return }
globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
guard let self = self else { return }
Task { @MainActor in
await self.handleModifierKeyEvent(event)
}
}
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
guard let self = self else { return event }
Task { @MainActor in
await self.handleModifierKeyEvent(event)
}
return event
}
}
private func setupMiddleClickMonitoring() {
guard isMiddleClickToggleEnabled else { return }
// Mouse Down
let downMonitor = NSEvent.addGlobalMonitorForEvents(matching: .otherMouseDown) { [weak self] event in
guard let self = self, event.buttonNumber == 2 else { return }
self.middleClickTask?.cancel()
self.middleClickTask = Task {
do {
let delay = UInt64(self.middleClickActivationDelay) * 1_000_000 // ms to ns
try await Task.sleep(nanoseconds: delay)
guard self.isMiddleClickToggleEnabled, !Task.isCancelled else { return }
Task { @MainActor in
guard self.canProcessHotkeyAction else { return }
await self.whisperState.handleToggleMiniRecorder()
}
} catch {
// Cancelled
}
}
}
// Mouse Up
let upMonitor = NSEvent.addGlobalMonitorForEvents(matching: .otherMouseUp) { [weak self] event in
guard let self = self, event.buttonNumber == 2 else { return }
self.middleClickTask?.cancel()
}
middleClickMonitors = [downMonitor, upMonitor]
}
private func setupCustomShortcutMonitoring() {
// Hotkey 1
if selectedHotkey1 == .custom {
KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder) { [weak self] in
Task { @MainActor in await self?.handleCustomShortcutKeyDown() }
}
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
Task { @MainActor in await self?.handleCustomShortcutKeyUp() }
}
}
// Hotkey 2
if selectedHotkey2 == .custom {
KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder2) { [weak self] in
Task { @MainActor in await self?.handleCustomShortcutKeyDown() }
}
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder2) { [weak self] in
Task { @MainActor in await self?.handleCustomShortcutKeyUp() }
}
}
}
private func removeAllMonitoring() {
if let monitor = globalEventMonitor {
NSEvent.removeMonitor(monitor)
globalEventMonitor = nil
}
if let monitor = localEventMonitor {
NSEvent.removeMonitor(monitor)
localEventMonitor = nil
}
for monitor in middleClickMonitors {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
middleClickMonitors = []
middleClickTask?.cancel()
resetKeyStates()
}
private func resetKeyStates() {
currentKeyState = false
keyPressStartTime = nil
isHandsFreeMode = false
shortcutCurrentKeyState = false
shortcutKeyPressStartTime = nil
isShortcutHandsFreeMode = false
}
private func handleModifierKeyEvent(_ event: NSEvent) async {
let keycode = event.keyCode
let flags = event.modifierFlags
// Determine which hotkey (if any) is being triggered
let activeHotkey: HotkeyOption?
if selectedHotkey1.isModifierKey && selectedHotkey1.keyCode == keycode {
activeHotkey = selectedHotkey1
} else if selectedHotkey2.isModifierKey && selectedHotkey2.keyCode == keycode {
activeHotkey = selectedHotkey2
} else {
activeHotkey = nil
}
guard let hotkey = activeHotkey else { return }
var isKeyPressed = false
switch hotkey {
case .rightOption, .leftOption:
isKeyPressed = flags.contains(.option)
case .leftControl, .rightControl:
isKeyPressed = flags.contains(.control)
case .fn:
isKeyPressed = flags.contains(.function)
// Debounce Fn key
pendingFnKeyState = isKeyPressed
fnDebounceTask?.cancel()
fnDebounceTask = Task { [pendingState = isKeyPressed] in
try? await Task.sleep(nanoseconds: 75_000_000) // 75ms
if pendingFnKeyState == pendingState {
await self.processKeyPress(isKeyPressed: pendingState)
}
}
return
case .rightCommand:
isKeyPressed = flags.contains(.command)
case .rightShift:
isKeyPressed = flags.contains(.shift)
case .custom, .none:
return // Should not reach here
}
await processKeyPress(isKeyPressed: isKeyPressed)
}
private func processKeyPress(isKeyPressed: Bool) async {
guard isKeyPressed != currentKeyState else { return }
currentKeyState = isKeyPressed
if isKeyPressed {
keyPressStartTime = Date()
if isHandsFreeMode {
isHandsFreeMode = false
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
return
}
if !whisperState.isMiniRecorderVisible {
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
}
} else {
let now = Date()
if let startTime = keyPressStartTime {
let pressDuration = now.timeIntervalSince(startTime)
if pressDuration < briefPressThreshold {
isHandsFreeMode = true
} else {
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
}
}
keyPressStartTime = nil
}
}
private func handleCustomShortcutKeyDown() async {
if let lastTrigger = lastShortcutTriggerTime,
Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval {
return
}
guard !shortcutCurrentKeyState else { return }
shortcutCurrentKeyState = true
lastShortcutTriggerTime = Date()
shortcutKeyPressStartTime = Date()
if isShortcutHandsFreeMode {
isShortcutHandsFreeMode = false
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
return
}
if !whisperState.isMiniRecorderVisible {
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
}
}
private func handleCustomShortcutKeyUp() async {
guard shortcutCurrentKeyState else { return }
shortcutCurrentKeyState = false
let now = Date()
if let startTime = shortcutKeyPressStartTime {
let pressDuration = now.timeIntervalSince(startTime)
if pressDuration < briefPressThreshold {
isShortcutHandsFreeMode = true
} else {
guard canProcessHotkeyAction else { return }
await whisperState.handleToggleMiniRecorder()
}
}
shortcutKeyPressStartTime = nil
}
// Computed property for backward compatibility with UI
var isShortcutConfigured: Bool {
let isHotkey1Configured = (selectedHotkey1 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil) : true
let isHotkey2Configured = (selectedHotkey2 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2) != nil) : true
return isHotkey1Configured && isHotkey2Configured
}
func updateShortcutStatus() {
// Called when a custom shortcut changes
if selectedHotkey1 == .custom || selectedHotkey2 == .custom {
setupHotkeyMonitoring()
}
}
deinit {
Task { @MainActor in
removeAllMonitoring()
}
}
}
// MARK: - PowerMode Hotkey Management
extension HotkeyManager {
func setupPowerModeHotkeys() {
let powerModesWithShortcuts = Set(PowerModeManager.shared.configurations
.filter { $0.hotkeyShortcut != nil }
.map { $0.id })
let idsToRemove = registeredPowerModeIds.subtracting(powerModesWithShortcuts)
idsToRemove.forEach { id in
KeyboardShortcuts.setShortcut(nil, for: .powerMode(id: id))
KeyboardShortcuts.disable(.powerMode(id: id))
registeredPowerModeIds.remove(id)
}
PowerModeManager.shared.configurations.forEach { config in
guard config.hotkeyShortcut != nil else { return }
guard !registeredPowerModeIds.contains(config.id) else { return }
KeyboardShortcuts.onKeyUp(for: .powerMode(id: config.id)) { [weak self] in
guard let self = self else { return }
Task { @MainActor in
await self.handlePowerModeHotkey(powerModeId: config.id)
}
}
registeredPowerModeIds.insert(config.id)
}
}
private func handlePowerModeHotkey(powerModeId: UUID) async {
guard canProcessHotkeyAction else { return }
guard let config = PowerModeManager.shared.getConfiguration(with: powerModeId),
config.hotkeyShortcut != nil else {
return
}
await whisperState.toggleMiniRecorder(powerModeId: powerModeId)
}
}
// MARK: - PowerMode Keyboard Shortcut Names
extension KeyboardShortcuts.Name {
static func powerMode(id: UUID) -> Self {
Self("powerMode_\(id.uuidString)")
}
}