diff --git a/README.md b/README.md index 12d270a..80af187 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,22 @@ To bind multiple hotkeys, use the `hotkeys` array instead: ```json { "hotkeys": [ - { "keyCode": 63, "modifiers": [] }, - { "keyCode": 96, "modifiers": [] } - ] + { "keyCode": 63, "modifiers": [], "mode": "hold" }, + { "keyCode": 96, "modifiers": [], "mode": "toggle" } + ], + "toggleMode": false } ``` Both `hotkey` (single) and `hotkeys` (array) are supported. If both are present, `hotkeys` takes precedence. +Each hotkey can optionally set `"mode": "hold"` or `"mode": "toggle"`. Hotkeys without `mode` use the global `toggleMode` setting. | Option | Default | Values | |---|---|---| | **hotkey** | `63` | Globe (`63`), Right Option (`61`), F5 (`96`), or any key code | | **hotkeys** | — | Array of hotkey objects — bind multiple keys to trigger dictation | | **modifiers** | `[]` | `"cmd"`, `"ctrl"`, `"shift"`, `"opt"` — combine for chords | +| **mode** | — | Optional per-hotkey mode: `"hold"` or `"toggle"`. Falls back to `toggleMode` when omitted. | | **modelSize** | `"base.en"` | See model table below | | **language** | `"en"` | `"auto"` for auto-detect, or any [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) — e.g. `it`, `fr`, `de`, `es` | | **spokenPunctuation** | `false` | Say "comma", "period", etc. to insert punctuation instead of auto-punctuation | diff --git a/Sources/OpenWisprLib/AppDelegate.swift b/Sources/OpenWisprLib/AppDelegate.swift index 62d889b..5ebe29a 100644 --- a/Sources/OpenWisprLib/AppDelegate.swift +++ b/Sources/OpenWisprLib/AppDelegate.swift @@ -7,7 +7,7 @@ public class AppDelegate: NSObject, NSApplicationDelegate { var transcriber: Transcriber! var inserter: TextInserter! var config: Config! - var isPressed = false + var recordingHotkeyState = RecordingHotkeyState() var isReady = false public var lastTranscription: String? @@ -123,23 +123,7 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } private func startListening() { - for m in hotkeyManagers { m.stop() } - hotkeyManagers = [] - for hk in config.hotkeys { - let manager = HotkeyManager( - keyCode: hk.keyCode, - modifiers: hk.modifierFlags - ) - manager.start( - onKeyDown: { [weak self] in - self?.handleKeyDown() - }, - onKeyUp: { [weak self] in - self?.handleKeyUp() - } - ) - hotkeyManagers.append(manager) - } + rebuildHotkeyManagers() isReady = true statusBar.state = .idle @@ -186,19 +170,7 @@ public class AppDelegate: NSObject, NSApplicationDelegate { transcriber.spokenPunctuation = config.spokenPunctuation?.value ?? false inserter = TextInserter() - for m in hotkeyManagers { m.stop() } - hotkeyManagers = [] - for hk in config.hotkeys { - let manager = HotkeyManager( - keyCode: hk.keyCode, - modifiers: hk.modifierFlags - ) - manager.start( - onKeyDown: { [weak self] in self?.handleKeyDown() }, - onKeyUp: { [weak self] in self?.handleKeyUp() } - ) - hotkeyManagers.append(manager) - } + rebuildHotkeyManagers() if !wasDownloading && !Transcriber.modelExists(modelSize: config.modelSize) { statusBar.state = .downloading @@ -231,33 +203,46 @@ public class AppDelegate: NSObject, NSApplicationDelegate { print("Config updated: lang=\(config.language) model=\(config.modelSize) hotkey=\(hotkeyDesc)") } - private func handleKeyDown() { - guard isReady else { return } + private func rebuildHotkeyManagers() { + for m in hotkeyManagers { m.stop() } + hotkeyManagers = [] + for hk in config.hotkeys { + let binding = hk.binding + let mode = hk.resolvedMode(globalToggle: config.toggleMode?.value ?? false) + let manager = HotkeyManager( + keyCode: hk.keyCode, + modifiers: hk.modifierFlags + ) + manager.start( + onKeyDown: { [weak self] in self?.handleKeyDown(binding: binding, mode: mode) }, + onKeyUp: { [weak self] in self?.handleKeyUp(binding: binding, mode: mode) } + ) + hotkeyManagers.append(manager) + } + } - let isToggle = config.toggleMode?.value ?? false + private func handleKeyDown(binding: HotkeyBinding, mode: HotkeyMode) { + guard isReady else { return } - if isToggle { - if isPressed { - handleRecordingStop() - } else { - handleRecordingStart() - } - } else { - guard !isPressed else { return } + switch recordingHotkeyState.keyDown(binding: binding, mode: mode) { + case .start: handleRecordingStart() + case .stop: + handleRecordingStop() + case .none: + break } } - private func handleKeyUp() { - let isToggle = config.toggleMode?.value ?? false - if isToggle { return } + private func handleKeyUp(binding: HotkeyBinding, mode: HotkeyMode) { + guard isReady else { return } - handleRecordingStop() + if recordingHotkeyState.keyUp(binding: binding, mode: mode) == .stop { + handleRecordingStop() + } } private func handleRecordingStart() { - guard !isPressed else { return } - isPressed = true statusBar.state = .recording do { let outputURL: URL @@ -269,14 +254,13 @@ public class AppDelegate: NSObject, NSApplicationDelegate { try recorder.startRecording(to: outputURL) } catch { print("Error: \(error.localizedDescription)") - isPressed = false + recordingHotkeyState.reset() statusBar.state = .idle } } private func handleRecordingStop() { - guard isPressed else { return } - isPressed = false + recordingHotkeyState.reset() guard let audioURL = recorder.stopRecording() else { statusBar.state = .idle diff --git a/Sources/OpenWisprLib/Config.swift b/Sources/OpenWisprLib/Config.swift index 9bdc7ce..81a6830 100644 --- a/Sources/OpenWisprLib/Config.swift +++ b/Sources/OpenWisprLib/Config.swift @@ -29,7 +29,7 @@ public struct Config: Codable { private static func deduplicateHotkeys(_ list: [HotkeyConfig]) -> [HotkeyConfig] { var out: [HotkeyConfig] = [] - for h in list where !out.contains(h) { + for h in list where !out.contains(where: { $0.hasSameBinding(as: h) }) { out.append(h) } return out @@ -322,13 +322,48 @@ public struct FlexBool: Codable { } } +public enum HotkeyMode: String, Equatable { + case hold + case toggle +} + +public struct HotkeyBinding: Equatable, Hashable { + public var keyCode: UInt16 + public var modifierFlags: UInt64 + + public init(keyCode: UInt16, modifierFlags: UInt64) { + self.keyCode = keyCode + self.modifierFlags = modifierFlags + } +} + public struct HotkeyConfig: Codable, Equatable { public var keyCode: UInt16 public var modifiers: [String] + /// Per-key recording mode: "hold" or "toggle". When nil, falls back to the + /// global Config.toggleMode. Lets different hotkeys use different modes. + public var mode: String? - public init(keyCode: UInt16, modifiers: [String]) { + public init(keyCode: UInt16, modifiers: [String], mode: String? = nil) { self.keyCode = keyCode self.modifiers = modifiers + self.mode = mode + } + + public var binding: HotkeyBinding { + HotkeyBinding(keyCode: keyCode, modifierFlags: modifierFlags) + } + + public func hasSameBinding(as other: HotkeyConfig) -> Bool { + binding == other.binding + } + + public func resolvedMode(globalToggle: Bool) -> HotkeyMode { + switch mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case HotkeyMode.toggle.rawValue: return .toggle + case HotkeyMode.hold.rawValue: return .hold + default: return globalToggle ? .toggle : .hold + } } public var modifierFlags: UInt64 { diff --git a/Sources/OpenWisprLib/HotkeyManager.swift b/Sources/OpenWisprLib/HotkeyManager.swift index 1e31b3a..2ae6f9f 100644 --- a/Sources/OpenWisprLib/HotkeyManager.swift +++ b/Sources/OpenWisprLib/HotkeyManager.swift @@ -55,6 +55,7 @@ class HotkeyManager { guard currentMods & requiredModifiers == requiredModifiers else { return } } if event.type == .keyDown { + guard !event.isARepeat else { return } onKeyDown?() } else if event.type == .keyUp { onKeyUp?() diff --git a/Sources/OpenWisprLib/RecordingHotkeyState.swift b/Sources/OpenWisprLib/RecordingHotkeyState.swift new file mode 100644 index 0000000..c6fcec0 --- /dev/null +++ b/Sources/OpenWisprLib/RecordingHotkeyState.swift @@ -0,0 +1,49 @@ +enum RecordingHotkeyAction: Equatable { + case none + case start + case stop +} + +struct RecordingHotkeyState { + private(set) var activeBinding: HotkeyBinding? + private(set) var activeMode: HotkeyMode? + + var isRecording: Bool { + activeBinding != nil + } + + mutating func keyDown(binding: HotkeyBinding, mode: HotkeyMode) -> RecordingHotkeyAction { + if mode == .toggle { + if activeMode == .toggle { + reset() + return .stop + } + + guard activeBinding == nil else { return .none } + start(binding: binding, mode: mode) + return .start + } + + guard activeBinding == nil else { return .none } + start(binding: binding, mode: mode) + return .start + } + + mutating func keyUp(binding: HotkeyBinding, mode: HotkeyMode) -> RecordingHotkeyAction { + guard mode == .hold else { return .none } + guard activeMode == .hold, activeBinding == binding else { return .none } + + reset() + return .stop + } + + mutating func reset() { + activeBinding = nil + activeMode = nil + } + + private mutating func start(binding: HotkeyBinding, mode: HotkeyMode) { + activeBinding = binding + activeMode = mode + } +} diff --git a/Tests/OpenWisprTests/ConfigTests.swift b/Tests/OpenWisprTests/ConfigTests.swift index 7d60349..78cf062 100644 --- a/Tests/OpenWisprTests/ConfigTests.swift +++ b/Tests/OpenWisprTests/ConfigTests.swift @@ -289,6 +289,47 @@ final class ConfigTests: XCTestCase { XCTAssertEqual(config.modifierFlags, UInt64(1 << 20)) } + // MARK: - HotkeyConfig mode + + func testHotkeyModeResolvesExplicitModes() { + let hold = HotkeyConfig(keyCode: 63, modifiers: [], mode: "hold") + let toggle = HotkeyConfig(keyCode: 61, modifiers: [], mode: "toggle") + + XCTAssertEqual(hold.resolvedMode(globalToggle: true), .hold) + XCTAssertEqual(toggle.resolvedMode(globalToggle: false), .toggle) + } + + func testHotkeyModeFallbackUsesGlobalToggle() { + let missing = HotkeyConfig(keyCode: 63, modifiers: []) + let unknown = HotkeyConfig(keyCode: 61, modifiers: [], mode: "press") + + XCTAssertEqual(missing.resolvedMode(globalToggle: false), .hold) + XCTAssertEqual(missing.resolvedMode(globalToggle: true), .toggle) + XCTAssertEqual(unknown.resolvedMode(globalToggle: false), .hold) + XCTAssertEqual(unknown.resolvedMode(globalToggle: true), .toggle) + } + + func testConfigDecodesPerHotkeyModes() throws { + let json = """ + { + "hotkeys": [ + {"keyCode": 63, "modifiers": [], "mode": "hold"}, + {"keyCode": 61, "modifiers": [], "mode": "toggle"} + ], + "modelSize": "base.en", + "language": "en", + "toggleMode": false + } + """.data(using: .utf8)! + let config = try Config.decode(from: json) + + XCTAssertEqual(config.hotkeys.count, 2) + XCTAssertEqual(config.hotkeys[0].mode, "hold") + XCTAssertEqual(config.hotkeys[0].resolvedMode(globalToggle: true), .hold) + XCTAssertEqual(config.hotkeys[1].mode, "toggle") + XCTAssertEqual(config.hotkeys[1].resolvedMode(globalToggle: false), .toggle) + } + // MARK: - Multiple hotkeys func testConfigDecodesHotkeysArray() throws { @@ -323,12 +364,45 @@ final class ConfigTests: XCTestCase { XCTAssertEqual(config.hotkeys.count, 1) } + func testConfigDeduplicatesSameBindingWithDifferentModes() throws { + let json = """ + { + "hotkeys": [ + {"keyCode": 63, "modifiers": [], "mode": "hold"}, + {"keyCode": 63, "modifiers": [], "mode": "toggle"} + ], + "modelSize": "base.en", + "language": "en" + } + """.data(using: .utf8)! + let config = try Config.decode(from: json) + + XCTAssertEqual(config.hotkeys.count, 1) + XCTAssertEqual(config.hotkeys[0].mode, "hold") + } + + func testConfigDeduplicatesEquivalentModifierBindings() throws { + let json = """ + { + "hotkeys": [ + {"keyCode": 49, "modifiers": ["cmd"]}, + {"keyCode": 49, "modifiers": ["command"]} + ], + "modelSize": "base.en", + "language": "en" + } + """.data(using: .utf8)! + let config = try Config.decode(from: json) + + XCTAssertEqual(config.hotkeys.count, 1) + } + func testConfigEncodeRoundtripPreservesHotkeys() throws { let json = """ { "hotkeys": [ - {"keyCode": 63, "modifiers": []}, - {"keyCode": 96, "modifiers": []} + {"keyCode": 63, "modifiers": [], "mode": "hold"}, + {"keyCode": 96, "modifiers": [], "mode": "toggle"} ], "modelSize": "base.en", "language": "en" @@ -338,6 +412,8 @@ final class ConfigTests: XCTestCase { let data = try JSONEncoder().encode(config) let again = try Config.decode(from: data) XCTAssertEqual(again.hotkeys.count, 2) + XCTAssertEqual(again.hotkeys[0].mode, "hold") + XCTAssertEqual(again.hotkeys[1].mode, "toggle") let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] XCTAssertNotNil(obj?["hotkey"]) XCTAssertNotNil(obj?["hotkeys"]) diff --git a/Tests/OpenWisprTests/RecordingHotkeyStateTests.swift b/Tests/OpenWisprTests/RecordingHotkeyStateTests.swift new file mode 100644 index 0000000..e334c0a --- /dev/null +++ b/Tests/OpenWisprTests/RecordingHotkeyStateTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import OpenWisprLib + +final class RecordingHotkeyStateTests: XCTestCase { + private let holdBinding = HotkeyBinding(keyCode: 63, modifierFlags: 0) + private let toggleBinding = HotkeyBinding(keyCode: 61, modifierFlags: 0) + private let otherToggleBinding = HotkeyBinding(keyCode: 96, modifierFlags: 0) + + func testHoldKeyStartsAndStopsOnSameKeyRelease() { + var state = RecordingHotkeyState() + + XCTAssertEqual(state.keyDown(binding: holdBinding, mode: .hold), .start) + XCTAssertTrue(state.isRecording) + XCTAssertEqual(state.keyUp(binding: holdBinding, mode: .hold), .stop) + XCTAssertFalse(state.isRecording) + } + + func testDifferentHoldKeyReleaseDoesNotStopActiveRecording() { + var state = RecordingHotkeyState() + + XCTAssertEqual(state.keyDown(binding: holdBinding, mode: .hold), .start) + XCTAssertEqual(state.keyUp(binding: toggleBinding, mode: .hold), .none) + XCTAssertTrue(state.isRecording) + XCTAssertEqual(state.keyUp(binding: holdBinding, mode: .hold), .stop) + } + + func testHoldKeyReleaseDoesNotStopToggleRecording() { + var state = RecordingHotkeyState() + + XCTAssertEqual(state.keyDown(binding: toggleBinding, mode: .toggle), .start) + XCTAssertEqual(state.keyDown(binding: holdBinding, mode: .hold), .none) + XCTAssertEqual(state.keyUp(binding: holdBinding, mode: .hold), .none) + XCTAssertTrue(state.isRecording) + XCTAssertEqual(state.keyDown(binding: toggleBinding, mode: .toggle), .stop) + XCTAssertFalse(state.isRecording) + } + + func testToggleKeyDoesNotStopHoldRecording() { + var state = RecordingHotkeyState() + + XCTAssertEqual(state.keyDown(binding: holdBinding, mode: .hold), .start) + XCTAssertEqual(state.keyDown(binding: toggleBinding, mode: .toggle), .none) + XCTAssertTrue(state.isRecording) + XCTAssertEqual(state.keyUp(binding: holdBinding, mode: .hold), .stop) + XCTAssertFalse(state.isRecording) + } + + func testAnyToggleKeyStopsToggleRecording() { + var state = RecordingHotkeyState() + + XCTAssertEqual(state.keyDown(binding: toggleBinding, mode: .toggle), .start) + XCTAssertEqual(state.keyDown(binding: otherToggleBinding, mode: .toggle), .stop) + XCTAssertFalse(state.isRecording) + } +}