C.W.K.
Stream
Lesson 02 of 04 · published

How RegisterEventHotKey Actually Works

~13 min · carbon, register-event-hotkey, event-handler, modifiers

Level 0Cold Flint
0 XP0/34 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Register the chord, install one handler, translate the modifiers — and never let Carbon leak past this file."

The Registration Call

The mechanism has three moving parts. First, you register a chord: RegisterEventHotKey takes a virtual key code, a modifier mask, an EventHotKeyID you make up (a signature plus a small integer, so you can tell your chords apart), a target to route events to, and returns a reference you keep so you can unregister later. Registration returns an OSStatus — a non-noErr result is your first signal of a collision, which the next lesson handles.

One Handler for All Chords

Second, you install a single Carbon event handler for kEventHotKeyPressed. Every registered chord routes through that one handler; inside it you pull the EventHotKeyID back out of the event, look up which macro that id maps to, and run its action. You do not install one handler per hotkey — you install one handler and dispatch by id. That keeps the Carbon surface tiny: one install, one callback, a dictionary from id to closure.

Carbon modifier masks are not Cocoa modifier flags. This is the classic bug. NSEvent.ModifierFlags and Carbon's cmdKey / optionKey / controlKey / shiftKey are different bit layouts. If you pass a Cocoa flag value straight into RegisterEventHotKey, your chord silently registers as the wrong combination — Cmd+K becomes some other keys, or nothing. You must translate explicitly, one modifier at a time, at the boundary.

Register, Unregister, Rebind

Third, you keep the reference. When a macro's chord changes, you unregister the old reference with UnregisterEventHotKey and register the new one — you never leave a dangling registration, because the OS keeps firing a chord you forgot to release. Wrap all of this in one type — a hotkey center — that holds the id-to-reference and id-to-action maps. Everything Carbon lives inside it; the rest of the app hands it a key code, a modifier set, and a closure, and never imports Carbon at all.

Code

A HotkeyCenter — the only file that touches Carbon·swift
import Carbon.HIToolbox

final class HotkeyCenter {
    private var refs: [UInt32: EventHotKeyRef] = [:]    // our id -> OS registration
    private var actions: [UInt32: () -> Void] = [:]     // our id -> what to run

    init() {
        var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
                                 eventKind: UInt32(kEventHotKeyPressed))
        // ONE handler for ALL chords; dispatch by id inside it.
        InstallEventHandler(GetEventDispatcherTarget(), { _, event, userData in
            var hkID = EventHotKeyID()
            GetEventParameter(event, EventParamName(kEventParamDirectObject),
                              EventParamType(typeEventHotKeyID), nil,
                              MemoryLayout<EventHotKeyID>.size, nil, &hkID)
            let center = Unmanaged<HotkeyCenter>
                .fromOpaque(userData!).takeUnretainedValue()
            center.actions[hkID.id]?()      // run the macro this chord maps to
            return noErr
        }, 1, &spec, Unmanaged.passUnretained(self).toOpaque(), nil)
    }

    func register(id: UInt32, keyCode: UInt32, carbonModifiers: UInt32,
                  action: @escaping () -> Void) {
        let hkID = EventHotKeyID(signature: OSType(0x464C4E54), id: id)  // 'FLNT'
        var ref: EventHotKeyRef?
        let status = RegisterEventHotKey(keyCode, carbonModifiers, hkID,
                                         GetEventDispatcherTarget(), 0, &ref)
        guard status == noErr, let ref else { return }   // non-noErr => collision
        refs[id] = ref
        actions[id] = action
    }

    func unregister(id: UInt32) {
        if let ref = refs.removeValue(forKey: id) { UnregisterEventHotKey(ref) }
        actions[id] = nil    // never leave a chord firing after you release it
    }
}
Translate Cocoa modifiers to Carbon masks at the boundary·swift
// The bug that eats an afternoon: passing NSEvent flags straight in.
// Carbon's masks are different bits — translate one at a time.
func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
    var m: UInt32 = 0
    if flags.contains(.command) { m |= UInt32(cmdKey) }
    if flags.contains(.option)  { m |= UInt32(optionKey) }
    if flags.contains(.control) { m |= UInt32(controlKey) }
    if flags.contains(.shift)   { m |= UInt32(shiftKey) }
    return m
}

External links

Exercise

Sketch the lifecycle of a single macro's hotkey from the moment the user assigns Cmd+Shift+K to the moment they change it to Cmd+Shift+L. Name every step where Carbon is involved: translating the modifiers, registering, the handler firing, unregistering the old chord, registering the new one. Where exactly would a Cocoa-vs-Carbon modifier mismatch silently break it, and where would a forgotten unregister leave a ghost chord firing?
Hint
The modifier mismatch bites at registration: the wrong bits register a different chord than the user chose, so their key does nothing and some other combination fires instead. The ghost chord appears on rebind: if you register the new chord without unregistering the old reference, the OS keeps firing the old one — so unregister-then-register, always in that order, keyed by the macro's stable id.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.