Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unify-hotkey-capture-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hex-app": patch
---

Cancel hotkey capture when clicking away from a shortcut field.
4 changes: 2 additions & 2 deletions Hex/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,12 @@ struct AppFeature {

private func startPasteLastTranscriptMonitoring() -> Effect<Action> {
.run { send in
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool
@Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget?
@Shared(.hexSettings) var hexSettings: HexSettings

let token = keyEventMonitor.handleKeyEvent { keyEvent in
// Skip if user is setting a hotkey
if isSettingPasteLastTranscriptHotkey {
if hotKeyCaptureTarget != nil {
return false
}

Expand Down
53 changes: 24 additions & 29 deletions Hex/Features/Settings/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,9 @@ import SwiftUI
private let settingsLogger = HexLog.settings
private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer<AudioObjectPropertyAddress>) -> Void

private enum HotKeyCaptureTarget {
case recording
case pasteLastTranscript
}

extension SharedReaderKey
where Self == InMemoryKey<Bool>.Default
{
static var isSettingHotKey: Self {
Self[.inMemory("isSettingHotKey"), default: false]
}

static var isSettingPasteLastTranscriptHotkey: Self {
Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false]
}

static var isRemappingScratchpadFocused: Self {
Self[.inMemory("isRemappingScratchpadFocused"), default: false]
}
Expand All @@ -40,8 +27,7 @@ struct SettingsFeature {
@ObservableState
struct State {
@Shared(.hexSettings) var hexSettings: HexSettings
@Shared(.isSettingHotKey) var isSettingHotKey: Bool = false
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false
@Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget?
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
@Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState
Expand All @@ -59,6 +45,9 @@ struct SettingsFeature {
var modelDownload = ModelDownloadFeature.State()
var shouldFlashModelSection = false

var isSettingHotKey: Bool { hotKeyCaptureTarget == .recording }
var isSettingPasteLastTranscriptHotkey: Bool { hotKeyCaptureTarget == .pasteLastTranscript }

}

enum Action: BindableAction {
Expand All @@ -68,6 +57,7 @@ struct SettingsFeature {
case task
case startSettingHotKey
case startSettingPasteLastTranscriptHotkey
case cancelHotKeyCapture
case clearPasteLastTranscriptHotkey
case keyEvent(KeyEvent)
case toggleOpenOnLogin(Bool)
Expand Down Expand Up @@ -132,23 +122,25 @@ struct SettingsFeature {
}

private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) {
endCapture(state: &state)
state.$hotKeyCaptureTarget.withLock { $0 = target }

switch target {
case .recording:
state.$isSettingHotKey.withLock { $0 = true }
state.currentModifiers = .init(modifiers: [])
case .pasteLastTranscript:
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true }
state.currentPasteLastModifiers = .init(modifiers: [])
}
}

private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) {
private func endCapture(state: inout State) {
guard let target = state.hotKeyCaptureTarget else { return }
state.$hotKeyCaptureTarget.withLock { $0 = nil }

switch target {
case .recording:
state.$isSettingHotKey.withLock { $0 = false }
state.currentModifiers = .init(modifiers: [])
case .pasteLastTranscript:
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false }
state.currentPasteLastModifiers = .init(modifiers: [])
}
}
Expand Down Expand Up @@ -188,7 +180,7 @@ struct SettingsFeature {

private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect<Action> {
if keyEvent.key == .escape {
endCapture(target, state: &state)
endCapture(state: &state)
return .none
}

Expand All @@ -201,13 +193,13 @@ struct SettingsFeature {

if let key = keyEvent.key {
applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state)
endCapture(target, state: &state)
endCapture(state: &state)
return .none
}

if target == .recording, keyEvent.modifiers.isEmpty {
applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state)
endCapture(target, state: &state)
endCapture(state: &state)
}

return .none
Expand Down Expand Up @@ -414,18 +406,21 @@ struct SettingsFeature {
case .startSettingPasteLastTranscriptHotkey:
beginCapture(.pasteLastTranscript, state: &state)
return .none

case .cancelHotKeyCapture:
endCapture(state: &state)
return .none

case .clearPasteLastTranscriptHotkey:
if state.hotKeyCaptureTarget == .pasteLastTranscript {
endCapture(state: &state)
}
state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil }
return .none

case let .keyEvent(keyEvent):
if state.isSettingPasteLastTranscriptHotkey {
return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state)
}

guard state.isSettingHotKey else { return .none }
return handleCapture(keyEvent, for: .recording, state: &state)
guard let target = state.hotKeyCaptureTarget else { return .none }
return handleCapture(keyEvent, for: target, state: &state)

case let .toggleOpenOnLogin(enabled):
state.$hexSettings.withLock { $0.openOnLogin = enabled }
Expand Down
27 changes: 27 additions & 0 deletions Hex/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import ComposableArchitecture
import HexCore
import Inject
Expand All @@ -6,6 +7,7 @@ import SwiftUI
struct SettingsView: View {
@ObserveInjection var inject
@Bindable var store: StoreOf<SettingsFeature>
@State private var localEventMonitor: Any?
let microphonePermission: PermissionStatus
let accessibilityPermission: PermissionStatus
let inputMonitoringPermission: PermissionStatus
Expand Down Expand Up @@ -43,8 +45,33 @@ struct SettingsView: View {
.task {
await store.send(.task).finish()
}
.onAppear(perform: installHotKeyCaptureCancellationMonitor)
.onDisappear {
store.send(.cancelHotKeyCapture)
removeHotKeyCaptureCancellationMonitor()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in
store.send(.cancelHotKeyCapture)
}
.enableInjection()
}

private func installHotKeyCaptureCancellationMonitor() {
guard localEventMonitor == nil else { return }

localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in
guard store.hotKeyCaptureTarget != nil else { return event }
store.send(.cancelHotKeyCapture)
return event
}
}

private func removeHotKeyCaptureCancellationMonitor() {
if let localEventMonitor {
NSEvent.removeMonitor(localEventMonitor)
self.localEventMonitor = nil
}
}
}

// MARK: - Shared Styles
Expand Down
4 changes: 2 additions & 2 deletions Hex/Features/Transcription/TranscriptionFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,13 @@ private extension TranscriptionFeature {
func startHotKeyMonitoringEffect() -> Effect<Action> {
.run { send in
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
@Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget?
@Shared(.hexSettings) var hexSettings: HexSettings

// Handle incoming input events (keyboard and mouse)
let token = keyEventMonitor.handleInputEvent { inputEvent in
// Skip if the user is currently setting a hotkey
if isSettingHotKey {
if hotKeyCaptureTarget != nil {
return false
}

Expand Down
13 changes: 13 additions & 0 deletions Hex/Models/AppHexSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import HexCore
typealias RecordingAudioBehavior = HexCore.RecordingAudioBehavior
typealias HexSettings = HexCore.HexSettings

enum HotKeyCaptureTarget: Equatable, Sendable {
case recording
case pasteLastTranscript
}

extension SharedReaderKey
where Self == FileStorageKey<HexSettings>.Default
{
Expand All @@ -18,6 +23,14 @@ extension SharedReaderKey
}
}

extension SharedReaderKey
where Self == InMemoryKey<HotKeyCaptureTarget?>.Default
{
static var hotKeyCaptureTarget: Self {
Self[.inMemory("hotKeyCaptureTarget"), default: nil]
}
}

// MARK: - Storage Migration

extension URL {
Expand Down