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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
86 changes: 35 additions & 51 deletions Sources/OpenWisprLib/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
39 changes: 37 additions & 2 deletions Sources/OpenWisprLib/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/OpenWisprLib/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
Expand Down
49 changes: 49 additions & 0 deletions Sources/OpenWisprLib/RecordingHotkeyState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
80 changes: 78 additions & 2 deletions Tests/OpenWisprTests/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand All @@ -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"])
Expand Down
Loading