feat: configurable hotkey binding and ModifierKeyTracker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 } }
|
||||||
|
}
|
||||||
@@ -6,13 +6,18 @@ public final class HotkeyMonitor {
|
|||||||
case tapUnavailable
|
case tapUnavailable
|
||||||
|
|
||||||
public var errorDescription: String? {
|
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)?
|
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 tap: CFMachPort?
|
||||||
private var source: CFRunLoopSource?
|
private var source: CFRunLoopSource?
|
||||||
|
|
||||||
@@ -56,10 +61,10 @@ public final class HotkeyMonitor {
|
|||||||
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
||||||
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
|
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
|
||||||
case .flagsChanged:
|
case .flagsChanged:
|
||||||
// .maskSecondaryFn is also set while arrows/F-keys are held; only react to the Fn key itself.
|
// Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself.
|
||||||
guard event.getIntegerValueField(.keyboardEventKeycode) == 63 else { return }
|
guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return }
|
||||||
let fnDown = event.flags.contains(.maskSecondaryFn)
|
let isDown = event.flags.contains(binding.flag)
|
||||||
if let hotkeyEvent = tracker.process(fnDown: fnDown) {
|
if let hotkeyEvent = tracker.process(isDown: isDown) {
|
||||||
onEvent?(hotkeyEvent)
|
onEvent?(hotkeyEvent)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user