From 2c4b096850a6a2a8559b0dc59d08f7c61652d2c7 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 14:58:31 +0200 Subject: [PATCH] feat: configurable hotkey binding and ModifierKeyTracker Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstCore/HotkeyBinding.swift | 27 ++++++++++++++++ Sources/SstCore/HotkeyMonitor.swift | 17 ++++++---- Sources/SstCore/ModifierKeyTracker.swift | 12 +++++++ Tests/SstCoreTests/HotkeyBindingTests.swift | 32 +++++++++++++++++++ .../ModifierKeyTrackerTests.swift | 18 +++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 Sources/SstCore/HotkeyBinding.swift create mode 100644 Sources/SstCore/ModifierKeyTracker.swift create mode 100644 Tests/SstCoreTests/HotkeyBindingTests.swift create mode 100644 Tests/SstCoreTests/ModifierKeyTrackerTests.swift diff --git a/Sources/SstCore/HotkeyBinding.swift b/Sources/SstCore/HotkeyBinding.swift new file mode 100644 index 0000000..140b710 --- /dev/null +++ b/Sources/SstCore/HotkeyBinding.swift @@ -0,0 +1,27 @@ +import CoreGraphics + +/// A modifier key that can be held to dictate. Left/right keys share a flag, so the key code decides. +public struct HotkeyBinding: Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let keyCode: Int + public let flagRawValue: UInt64 + + public var flag: CGEventFlags { CGEventFlags(rawValue: flagRawValue) } + + public static let fn = HotkeyBinding( + id: "fn", displayName: "Fn", keyCode: 63, flagRawValue: CGEventFlags.maskSecondaryFn.rawValue) + public static let rightOption = HotkeyBinding( + id: "rightOption", displayName: "Rechter Option", keyCode: 61, flagRawValue: CGEventFlags.maskAlternate.rawValue) + public static let rightCommand = HotkeyBinding( + id: "rightCommand", displayName: "Rechter Command", keyCode: 54, flagRawValue: CGEventFlags.maskCommand.rawValue) + public static let rightControl = HotkeyBinding( + id: "rightControl", displayName: "Rechter Control", keyCode: 62, flagRawValue: CGEventFlags.maskControl.rawValue) + public static let leftControl = HotkeyBinding( + id: "leftControl", displayName: "Linker Control", keyCode: 59, flagRawValue: CGEventFlags.maskControl.rawValue) + + public static let allowed: [HotkeyBinding] = [.fn, .rightOption, .rightCommand, .rightControl, .leftControl] + + public static func binding(id: String) -> HotkeyBinding? { allowed.first { $0.id == id } } + public static func binding(keyCode: Int) -> HotkeyBinding? { allowed.first { $0.keyCode == keyCode } } +} diff --git a/Sources/SstCore/HotkeyMonitor.swift b/Sources/SstCore/HotkeyMonitor.swift index 07d4f22..44b1b6f 100644 --- a/Sources/SstCore/HotkeyMonitor.swift +++ b/Sources/SstCore/HotkeyMonitor.swift @@ -6,13 +6,18 @@ public final class HotkeyMonitor { case tapUnavailable public var errorDescription: String? { - "Kan de Fn-toets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." + "Kan de sneltoets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." } } public var onEvent: ((HotkeyEvent) -> Void)? - private var tracker = FnKeyTracker() + /// The key that has to be held. Changing it resets the press tracking. + public var binding: HotkeyBinding = .fn { + didSet { tracker = ModifierKeyTracker() } + } + + private var tracker = ModifierKeyTracker() private var tap: CFMachPort? private var source: CFRunLoopSource? @@ -56,10 +61,10 @@ public final class HotkeyMonitor { case .tapDisabledByTimeout, .tapDisabledByUserInput: if let tap { CGEvent.tapEnable(tap: tap, enable: true) } case .flagsChanged: - // .maskSecondaryFn is also set while arrows/F-keys are held; only react to the Fn key itself. - guard event.getIntegerValueField(.keyboardEventKeycode) == 63 else { return } - let fnDown = event.flags.contains(.maskSecondaryFn) - if let hotkeyEvent = tracker.process(fnDown: fnDown) { + // Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself. + guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return } + let isDown = event.flags.contains(binding.flag) + if let hotkeyEvent = tracker.process(isDown: isDown) { onEvent?(hotkeyEvent) } default: diff --git a/Sources/SstCore/ModifierKeyTracker.swift b/Sources/SstCore/ModifierKeyTracker.swift new file mode 100644 index 0000000..e83880c --- /dev/null +++ b/Sources/SstCore/ModifierKeyTracker.swift @@ -0,0 +1,12 @@ +public struct ModifierKeyTracker { + private var isDown = false + + public init() {} + + /// Feed the current state of the bound modifier flag; returns an event only on a transition. + public mutating func process(isDown: Bool) -> HotkeyEvent? { + guard isDown != self.isDown else { return nil } + self.isDown = isDown + return isDown ? .pressed : .released + } +} diff --git a/Tests/SstCoreTests/HotkeyBindingTests.swift b/Tests/SstCoreTests/HotkeyBindingTests.swift new file mode 100644 index 0000000..0e09999 --- /dev/null +++ b/Tests/SstCoreTests/HotkeyBindingTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import SstCore + +struct HotkeyBindingTests { + @Test func allowedBindingsMatchTheSpec() { + #expect(HotkeyBinding.allowed.map(\.id) == ["fn", "rightOption", "rightCommand", "rightControl", "leftControl"]) + #expect(HotkeyBinding.allowed.map(\.keyCode) == [63, 61, 54, 62, 59]) + } + + @Test func idsAndKeyCodesAreUnique() { + #expect(Set(HotkeyBinding.allowed.map(\.id)).count == HotkeyBinding.allowed.count) + #expect(Set(HotkeyBinding.allowed.map(\.keyCode)).count == HotkeyBinding.allowed.count) + } + + @Test func lookupByKeyCodeAndId() { + #expect(HotkeyBinding.binding(keyCode: 61) == .rightOption) + #expect(HotkeyBinding.binding(id: "leftControl") == .leftControl) + } + + @Test func unsupportedKeysAreRejected() { + #expect(HotkeyBinding.binding(keyCode: 56) == nil) // left shift + #expect(HotkeyBinding.binding(keyCode: 57) == nil) // caps lock + #expect(HotkeyBinding.binding(id: "nope") == nil) + } + + @Test func everyBindingHasAFlagAndName() { + for binding in HotkeyBinding.allowed { + #expect(binding.flagRawValue != 0) + #expect(!binding.displayName.isEmpty) + } + } +} diff --git a/Tests/SstCoreTests/ModifierKeyTrackerTests.swift b/Tests/SstCoreTests/ModifierKeyTrackerTests.swift new file mode 100644 index 0000000..377b04a --- /dev/null +++ b/Tests/SstCoreTests/ModifierKeyTrackerTests.swift @@ -0,0 +1,18 @@ +import Testing +@testable import SstCore + +struct ModifierKeyTrackerTests { + @Test func emitsPressedThenReleasedOnlyOnTransitions() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: false) == nil) + #expect(tracker.process(isDown: true) == .pressed) + #expect(tracker.process(isDown: true) == nil) + #expect(tracker.process(isDown: false) == .released) + #expect(tracker.process(isDown: false) == nil) + } + + @Test func freshTrackerStartsReleased() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: true) == .pressed) + } +}