hotkey

package module
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: BSD-3-Clause Imports: 8 Imported by: 0

README

go-macos/hotkey

ci Go Reference License

System-wide keyboard shortcuts on macOS from pure Go — CGO_ENABLED=0, and no permission dialog at all. When the shortcut you want is already taken, it falls back to a neighbour and tells you, in glyphs, which one it got.

h, err := hotkey.Register(hotkey.Combo{Key: hotkey.KeySpace, Mods: hotkey.Option | hotkey.Command}, nil)
if err != nil {
        return err
}
defer h.Close()

if h.Substituted() {
        // ⌥⌘Space is the Finder's search window, so this really happens.
        fmt.Printf("%s was taken; using %s instead\n", h.Wanted(), h.Combo()) // ⌥⌘Space → ⌥⇧⌘Space
}

for ev := range h.C() {
        turnTheRibbon(ev.Combo)
}

The shortcut fires while the user is working in another application. That is the whole point: the consumer is an XR virtual-desktop app whose ribbon of screens is turned from the keyboard while the user types inside the applications on those screens. A shortcut that only works when your own window is focused would be useless to it.

No permission is required

The two obvious routes both demand the Accessibility (TCC) grant, which means a system dialog and a trip to System Settings:

route permission
CGEventTap Accessibility
-[NSEvent addGlobalMonitorForEventsMatchingMask:] Accessibility
Carbon RegisterEventHotKey none

This package uses the third. It is old, it is still present on macOS 26, and it is what every shortcut manager on the platform is built on. Registering ⌥⌘←, ⌥⌘→ and ⌥⌘Space on macOS 26.6.2 produced no dialog and needed no grant.

Three kinds of conflict — and only two of them are detectable

This is the single most important thing about RegisterEventHotKey, and the reason a naive implementation is worse than useless: it does not conflict-check against system shortcuts. ⌥⌘Space is the Finder's search window and ⌥⌘←/→ are Safari's tab navigation, and all three register with status 0 regardless. A fallback driven by the return status alone would never fire for the conflicts a user actually cares about.

1. Another Carbon hot-key holder — DETECTED. Registration returns eventHotKeyExistsErr (−9878), surfaced as ErrComboTaken. This is also how the package's own tests prove a claim is real without anyone pressing a key (see Verification).

2. A macOS system shortcut — DETECTED, by this package, before registering. Carbon will not tell you, so Resolve asks a Reserver first. The default one is a hand-maintained list of macOS defaults with the user's com.apple.symbolichotkeys overrides layered over it. See the next section for exactly how dependable that is; SystemShortcut.Origin tells you, per entry, whether a binding is a fact about this machine or a well-informed guess.

3. An ordinary application's own menu key equivalent — NOT DETECTABLE. Safari's ⌥⌘← for "previous tab" is live only while Safari is frontmost, and nothing on macOS enumerates other applications' menu shortcuts. If you claim one, you win it globally and that application silently stops seeing it. There is no coverage here and this package does not pretend otherwise. Make your shortcuts configurable.

What com.apple.symbolichotkeys really is

It was dumped rather than trusted, and the shape is not what most write-ups imply.

It is an override layer, not a catalogue. On macOS 26.6.2 the domain held 19 entries in 486 bytes, while macOS defines on the order of a hundred symbolic hot keys. ⌥⌘Space — live on that machine as the Finder's search window — was absent from it entirely, because the user had never changed it. Enumerating the domain and stopping there would miss most of the conflicts that matter, and would miss them silently. defaults -currentHost read com.apple.symbolichotkeys reports the domain does not exist at all; there is no /Library/Preferences copy either, and no readable file of the defaults anywhere on the system.

Entries frequently carry no binding. Four of the nineteen (79, 80, 81, 82 — the space-switching shortcuts) were {"enabled": true} and nothing else. The domain says the shortcut is on but not what it is bound to. Only a defaults list can supply that.

The entries that do carry a binding have this shape, confirmed against the real domain:

"61": {"enabled": true, "value": {"type": "standard", "parameters": [32, 49, 786432]}}

parameters is [ASCII character, virtual key code, modifier mask]. The mask is NSEventModifierFlags (Shift 1<<17, Control 1<<18, Option 1<<19, Command 1<<20) — not the Carbon mask RegisterEventHotKey takes, so the two are converted through a single table. 65535 is the "no such parameter" sentinel and an all-65535 triple means unbound; both were present in the real data and both are handled.

So the answer to "is reading it dependable?" is: dependable for what it contains, and it does not contain what you most need. This package therefore ships DefaultShortcuts() — an honest, clearly-marked static list of the macOS shortcuts that are on by default — and layers the domain over it with Merge. Origin reports which of the two a given binding came from. Parsing is deliberately tolerant: a malformed entry is skipped rather than failing the whole read, because a hot key that could not be claimed on account of one odd preference entry would be a bad trade.

The fallback ladder

DefaultLadder is: the same key with Shift added, then with Control added, then with both.

⌥⌘←   →   ⌥⇧⌘←   →   ⌃⌥⌘←   →   ⌃⌥⇧⌘←

Shift first because it collides with least and reads most naturally on a menu; Control after it because ⌃ is heavily used by the terminal and by text-editing key bindings; both together last because it is the most awkward to press. A rung that adds nothing new — because you already asked for that modifier — is skipped, not retried. Pass Options.Ladder to change the order, or an explicitly empty ladder to disable the fallback entirely and get ErrNoCandidate instead of a substitution.

It reports what it got, in a form you can show a person. h.Combo().String() is ⌥⇧⌘Space, not a bitmask; h.Combo().Names() is Option-Shift-Command-Space for logs and accessibility labels. Modifiers render in Apple's canonical order, ⌃⌥⇧⌘ — so Shift+Option+Command prints ⌥⇧⌘, not ⇧⌥⌘. A shortcut the user cannot be told about is worse than none.

Nothing is ever substituted silently and nothing unusable is ever returned. If every rung is taken, Resolve returns ErrNoCandidate naming every combination it tried and why, and registers nothing.

The policy is separate from the operating system. Resolve speaks only to a Registrar interface, so the entire ladder — including "every candidate is taken" — is tested on Linux with no Carbon in sight.

Verification

Registration, exclusivity, release and delivery are all proved by the live suite (-tags integration, plus HOTKEY_INTEGRATION=1), which claims only F13/F14/F15 combinations and releases every one of them.

A claim is provable with no keypress. Registering the same combination twice returns −9878. The control is what makes it a proof rather than a coincidence: a combination that was never claimed registers with status 0.

second claim of ⌥⌘F13 refused with ErrComboTaken — the claim is exclusive
control ⌃⌥⌘F14 claimed with status 0 — the refusal above means TAKEN, not ALWAYS
⌥⌘F15: claimed, refused while held, freed by Release, re-claimed
⌥⌘F13 was held; got ⌥⇧⌘F13, shown as "⌥⇧⌘F13", and that combination is genuinely claimed
every rung held → hotkey: every candidate combination is taken (⌥⌘F14: …; ⌥⇧⌘F14: …; ⌃⌥⌘F14: …; ⌃⌥⇧⌘F14: …)

Firing is proved — including the window server matching a real keystroke.

The consumer's whole reason for existing is that the shortcut fires while someone is working in another application, so that is what was measured:

FIRED from a REAL keystroke while another application was frontmost: ⌥⌘F13 at 2026-08-26T01:32:33.42+02:00

Getting there took two steps, and the first one is a finding in its own right.

CGEventPost — the obvious way to synthesise a press — is silently refused for an unbundled Go binary. Two independent witnesses agree: after posting six key events across all three taps, the system's own CGEventSourceCounterForEventType(…, kCGEventKeyDown) was unchanged, and CGEventSourceSecondsSinceLastEventType kept climbing (96.711 s → 96.743 s) instead of resetting to zero. AXIsProcessTrusted() returned true throughout, so this is not simply a missing Accessibility grant. A global NSEvent monitor in the same process saw nothing either. The probe test reports this and skips; it never fails, because it is measuring the platform, not the package.

So the keystroke was borrowed from something that IS permitted to press keys. TestLiveFiringFromARealKeystroke asks System Events — signed, bundled, and already trusted on the operator's machine — to press ⌥⌘F13. The press travels the ordinary HID path, the window server matches it against this process's registration, and it is delivered while the terminal, not this process, is frontmost. That is the complete chain, with nothing stubbed. osascript appears in that one test and nowhere else: it is a fixture standing in for a human finger, never a dependency of the library, which stays pure Go with no subprocesses.

And the chain below the window server is asserted separately, so a regression is caught even on a machine where System Events is not permitted. TestLiveFiresFromTheEventQueue builds a genuine kEventHotKeyPressed event carrying the very EventHotKeyID that RegisterEventHotKey was handed, posts it with PostEventToQueue, and the ordinary [NSApp run] loop delivers it:

FIRED: handler entered 1 time(s), delivered ⌥⌘F13 at 2026-08-26T01:28:23.02+02:00
an unknown hot-key id reached the handler and was correctly dropped

That covers the handler, its installation on the application event target, the four-character codes, the EventHotKeyID packing and unpacking, the run-loop delivery, the id→Hotkey lookup and the channel. There is also TestLiveManualFiring, which waits for an actual human:

HOTKEY_MANUAL=1 HOTKEY_INTEGRATION=1 go test -tags integration -v -run TestLiveManual .

A trap found along the way that will bite anyone who repeats this.

-[NSUserDefaults persistentDomainForName:] returns a dictionary that already has AppleSymbolicHotKeys as its single top-level key. Wrapping it in another dictionary under that name — the obvious thing to write — nests it twice, and the parser then sees one non-numeric key and discards every override in silence: you still get a perfectly usable shortcut set that has quietly forgotten everything the user changed. This package had exactly that bug, and it was invisible until the live suite was made to go back to the raw domain, count the overrides that name a combination, and insist the merged set credits that many entries to preferences. On the machine above that is 2 of 19.

And a conclusion that looked obvious and was wrong, recorded because the next person will reach it too. When the first firing attempt failed, the natural explanation was that the hand-rolled -[NSApplication nextEventMatchingMask:untilDate:inMode:dequeue:] pump in use at the time drains the AppKit queue without dispatching Carbon events. It does not: put to a real keystroke, that pump delivers hot-key presses perfectly well, and so does [NSApp run]. The failure was entirely the refused CGEventPost — one cause, wearing the costume of another. What actually matters is only that some AppKit run loop is being pumped on the process's main OS thread.

Running the tests

# The portable suite: no Carbon, no display, no permission. Runs anywhere.
CGO_ENABLED=0 go test ./...
CGO_ENABLED=0 GOOS=linux go test ./...

# The live suite. It really claims system-wide keys (F13/F14/F15 only) and
# releases every one of them.
HOTKEY_INTEGRATION=1 go test -tags integration -v -run TestLive .

The portable layer — the ladder, the formatting, the preference parsing, the error mapping — is at 100% statement coverage, gated in CI on both the darwin and the linux lane, and run (not merely compiled) on all six of Go's 64-bit architectures.

Using it

An NSApplication must exist and its run loop must be running on the process's main OS thread, with runtime.LockOSThread held there. Hot-key events are delivered by that run loop and by nothing else.

func main() {
        runtime.LockOSThread()

        h, err := hotkey.Register(hotkey.Combo{Key: hotkey.KeyLeftArrow,
                Mods: hotkey.Option | hotkey.Command}, nil)
        if err != nil {
                log.Fatal(err)
        }
        defer h.Close()
        log.Printf("listening on %s", h.Combo())

        go func() {
                for ev := range h.C() {
                        turnTheRibbon(ev.Combo)
                }
        }()

        objc.RunApp(1) // NSApplicationActivationPolicyAccessory
}

Register itself may be called from any goroutine. Delivery is buffered by one and non-blocking: a consumer that is not listening drops presses rather than wedging the run loop, which would freeze the whole UI.

Platforms

macOS only. Every other platform compiles and reports ErrUnsupported, so consumers cross-compile without a build tag of their own — verified on linux/{amd64,arm64,riscv64,loong64,ppc64le,s390x}, windows/{amd64,arm64}, darwin/amd64 and freebsd/amd64. The whole policy layer works on all of them, which is what makes it testable off a Mac.

Licence

BSD-3-Clause.

Documentation

Overview

Package hotkey claims system-wide keyboard shortcuts on macOS from pure Go (CGO_ENABLED=0), and falls back to a neighbouring combination when the one you asked for is already taken.

A hot key registered here fires while the user is working in ANOTHER application. That is the whole point: the consumer is an XR virtual-desktop app whose ribbon of screens is turned from the keyboard while the user types inside the applications on those screens. A shortcut that only works when your own window is focused would be useless to it.

No permission is required

The two obvious routes — CGEventTap and -[NSEvent addGlobalMonitorForEventsMatchingMask:] — both demand the Accessibility (TCC) grant, which means a system dialog and a trip to System Settings. This package instead uses Carbon's hot-key API (RegisterEventHotKey), which is still present on macOS 26 and needs no permission at all. Registering ⌥⌘← on macOS 26.6.2 produced no dialog.

Three kinds of conflict, two of them detectable

RegisterEventHotKey does NOT conflict-check against system shortcuts. This is the single most important thing to understand about it, and the reason a naive implementation is useless:

  1. Another Carbon hot-key holder — DETECTED. Registration returns eventHotKeyExistsErr (-9878), surfaced as ErrComboTaken.
  2. A macOS system shortcut (⌥⌘Space is the Finder's search window) — NOT detected by registration, which returns 0 for it anyway. This package catches these itself, before registering, with SystemShortcuts. See that type for exactly how dependable that is.
  3. An ordinary application's own menu key equivalent — for example Safari's ⌥⌘← for "previous tab". NOT DETECTABLE, by this package or any other. Nothing on macOS enumerates other applications' menu shortcuts, and such a shortcut is only live while that application is frontmost. If you claim one, you will win it globally and that application will silently stop seeing it. There is no coverage here and this package does not pretend otherwise.

Portability

Every exported symbol is defined on all platforms so consumers cross-compile. On non-darwin GOOS the registration entry points report ErrUnsupported; the whole policy layer — the fallback ladder, combination formatting, and the parsing of the system-shortcut data — is OS-independent and fully testable anywhere.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrUnsupported is returned by the registration entry points on
	// non-darwin platforms (Carbon is macOS-only).
	ErrUnsupported = errors.New("hotkey: unsupported on this platform (darwin only)")

	// ErrComboTaken reports that the combination is already held. It wraps
	// both detectable conflict kinds: another Carbon hot-key holder
	// (eventHotKeyExistsErr, -9878) and a macOS system shortcut found by
	// [SystemShortcuts].
	ErrComboTaken = errors.New("hotkey: combination already taken")

	// ErrNoCandidate reports that neither the wanted combination nor any
	// rung of the fallback ladder could be claimed. Nothing was registered.
	ErrNoCandidate = errors.New("hotkey: every candidate combination is taken")

	// ErrNoModifier reports a combination with no modifier at all. Carbon
	// accepts one, but claiming a bare key system-wide would swallow that
	// key everywhere, in every application, which is never what a caller
	// means.
	ErrNoModifier = errors.New("hotkey: a system-wide hot key needs at least one modifier")

	// ErrClosed reports use of a [Hotkey] that has already been released.
	ErrClosed = errors.New("hotkey: hot key already released")
)

Errors reported by the package. They are stable and may be tested with errors.Is.

View Source
var DefaultLadder = []Modifier{Shift, Control, Shift | Control}

DefaultLadder is the order in which Resolve tries neighbouring combinations when the wanted one is taken: the same key with Shift added, then with Control added, then with both.

The order is deliberate. Shift first because ⇧ combined with an existing modifier set is the least likely to collide with anything and reads most naturally on a menu; Control last-but-one because ⌃ is heavily used by the terminal and by text-editing key bindings; both together last because it is the most awkward to press.

View Source
var ErrParse = errors.New("hotkey: unreadable combination")

ErrParse says a written combination could not be read.

Functions

func KeyNames added in v0.2.0

func KeyNames() string

KeyNames lists every key name this package accepts, sorted, for an error message that tells a person what they may write instead of what they wrote.

func Resolve

func Resolve(want Combo, ladder []Modifier, reg Registrar, reserved Reserver) (Combo, Claim, error)

Resolve walks the fallback ladder and claims the first combination that is free, returning which one it got.

Each candidate is first put to reserved (the system-shortcut check, conflict kind 2), and only then to reg.Claim (conflict kind 1). The order matters: a system shortcut registers with status 0, so asking Carbon first would "succeed" at claiming a combination the user cannot actually use.

If every candidate is taken, Resolve returns ErrNoCandidate and nothing is registered. It never silently returns an unusable claim.

func ResolveBare added in v0.6.0

func ResolveBare(want Combo, ladder []Modifier, reg Registrar, reserved Reserver) (Combo, Claim, error)

ResolveBare is Resolve for a combination with no modifier. See Options.BareKey for when that is a reasonable thing to want, and for the obligation that comes with it.

Types

type Claim

type Claim interface {
	Release() error
}

Claim is a held hot key. Release gives the combination back to the system.

type Combo

type Combo struct {
	Key  Key
	Mods Modifier
}

Combo is a key plus its modifiers — one keyboard shortcut.

func Candidates

func Candidates(want Combo, ladder []Modifier) []Combo

Candidates returns the combinations Resolve will try, in order: the wanted one first, then the wanted one with each ladder rung's modifiers added.

A rung that adds nothing new — because the caller already asked for that modifier — is skipped rather than retried, so asking for ⇧⌥⌘← does not try ⇧⌥⌘← twice. Duplicate rungs are likewise collapsed.

func ParseCombo added in v0.2.0

func ParseCombo(s string) (Combo, error)

ParseCombo reads a combination a person wrote down.

It exists because a configuration file is where a shortcut is CHANGED, and a person editing one should not have to reach for the glyph palette. All three of these are the same combination:

option+command+space
Option-Command-Space
⌥⌘Space

Modifiers and the key may be separated by "+", "-", or spaces, in any order and any case; the glyph forms need no separator at all. Every name this package prints is accepted, so Combo.String and Combo.Names both round trip through here — which is the property its test asserts, over every key the package names.

A combination with no modifier is refused. Claiming a bare key system-wide takes it away from every application on the machine, including whatever the person is typing into.

func (Combo) Glyphs added in v0.7.0

func (c Combo) Glyphs() string

Glyphs renders the combination as macOS prints it on a menu -- "⌃⌥⌘=" where Combo.String gives "⌃⌥⌘Equal".

Three renderings rather than two, because they answer three questions. String is what a settings file round-trips. Names is what a font that has no ⌘ can still show. This is what goes on a menu row beside the thing it does, and there a key that says "Equal" is a key somebody looks for and does not find.

⛔ IT ASKS THE KEYBOARD FIRST. A Key is a POSITION, and this package's names are the ANSI legends for those positions -- so on a French layout the key this package calls Equal is printed "-", and a menu row saying "⌃⌥⌘=" would be sending a person to a key that does nothing. Key.Char is what the system says is printed there; the ANSI name is the fallback for a key that prints nothing and for a platform with no layout to ask.

func (Combo) Names

func (c Combo) Names() string

Names renders the combination in words — "Option-Command-←" — for logs and accessibility labels.

func (Combo) String

func (c Combo) String() string

String renders the combination the way macOS shows it to a person: "⌥⌘←", "⌃⌥⇧⌘Space". This is what you put in front of the user when the fallback gives them something other than what they asked for. A shortcut the user cannot be told about is worse than none.

func (Combo) Valid

func (c Combo) Valid() bool

Valid reports whether the combination can be claimed system-wide. It requires at least one modifier; see ErrNoModifier.

type Event

type Event struct {
	// Combo is the combination that fired.
	Combo Combo
	// At is when the press was delivered.
	At time.Time
}

Event is one press of a hot key. No press is ever delivered on this platform.

type Hotkey

type Hotkey struct {
	// contains filtered or unexported fields
}

Hotkey is a live system-wide hot key. On non-darwin platforms one can never be created, so no value of this type is ever handed out by Register; the type exists so that consumer code naming it still compiles.

func Register

func Register(want Combo, opts *Options) (*Hotkey, error)

Register reports ErrUnsupported: system-wide hot keys here are Carbon's, and Carbon is macOS-only.

func (*Hotkey) C

func (h *Hotkey) C() <-chan Event

C returns the channel on which presses arrive. Nothing is ever sent on it here.

func (*Hotkey) Close

func (h *Hotkey) Close() error

Close releases the hot key. There is nothing to release here.

func (*Hotkey) Combo

func (h *Hotkey) Combo() Combo

Combo returns the combination actually claimed.

func (*Hotkey) Substituted

func (h *Hotkey) Substituted() bool

Substituted reports whether the fallback ladder had to be used.

func (*Hotkey) Wanted

func (h *Hotkey) Wanted() Combo

Wanted returns the combination originally asked for.

type Key

type Key uint16

Key is a macOS virtual key code (the kVK_* constants from HIToolbox/Events.h). It is a hardware position, not a character: Key(0) is the key labelled "A" on a US layout and "Q" on a French one.

const (
	KeyA     Key = 0x00
	KeyS     Key = 0x01
	KeyD     Key = 0x02
	KeyF     Key = 0x03
	KeyH     Key = 0x04
	KeyG     Key = 0x05
	KeyZ     Key = 0x06
	KeyX     Key = 0x07
	KeyC     Key = 0x08
	KeyV     Key = 0x09
	KeyB     Key = 0x0B
	KeyQ     Key = 0x0C
	KeyW     Key = 0x0D
	KeyE     Key = 0x0E
	KeyR     Key = 0x0F
	KeyY     Key = 0x10
	KeyT     Key = 0x11
	KeyO     Key = 0x1F
	KeyU     Key = 0x20
	KeyI     Key = 0x22
	KeyP     Key = 0x23
	KeyL     Key = 0x25
	KeyJ     Key = 0x26
	KeyK     Key = 0x28
	KeyN     Key = 0x2D
	KeyM     Key = 0x2E
	KeyN1    Key = 0x12
	KeyN2    Key = 0x13
	KeyN3    Key = 0x14
	KeyN4    Key = 0x15
	KeyN5    Key = 0x17
	KeyN6    Key = 0x16
	KeyN7    Key = 0x1A
	KeyN8    Key = 0x1C
	KeyN9    Key = 0x19
	KeyN0    Key = 0x1D
	KeySlash Key = 0x2C
	// KeyMinus and KeyEqual are the two keys either side of the number row's
	// end, which is where a keyboard puts "smaller" and "larger". The virtual
	// codes are the US layout's, like every other key here: a hot key is
	// registered by CODE and the code is a POSITION, so on a French keyboard
	// these are the same two keys in the same place whatever is printed on them.
	KeyMinus Key = 0x1B
	KeyEqual Key = 0x18
	// KeyLeftBracket and KeyRightBracket are the pair after P on the top row,
	// which is where a keyboard puts a matched set of opposites that nothing has
	// told anybody the meaning of. Same reasoning as Minus and Equal, and the same
	// caveat: the CODE is a position, so on a French keyboard these are the two
	// keys in that place whatever is printed on them.
	KeyLeftBracket  Key = 0x21
	KeyRightBracket Key = 0x1E
	// KeyISOSection is the EXTRA key an ISO keyboard has and an ANSI one does
	// not: the short one between the left Shift and the Z position, which Apple
	// calls kVK_ISO_Section.
	//
	// ⭐ IT IS WHERE A FRENCH MAC PRINTS "@". Measured on this machine:
	// position 0x0A prints "@" unshifted. There is nowhere else to look for it
	// -- no ANSI position on a French layout prints one -- so a shortcut on "@"
	// is this key or it is nothing.
	//
	// ⛔ NAMED AS A POSITION, deliberately, like Minus and the brackets. The
	// name is a WORD, so [onThisKeyboard] leaves it alone: a key named for what
	// it prints would be moved to the local key printing that legend, and this
	// key IS the local one. A layout that prints something else here -- "§" on
	// a Swiss keyboard, "`" on a British one -- gets the same physical key,
	// which is what a person pointing at their keyboard means.
	KeyISOSection Key = 0x0A
	KeyReturn     Key = 0x24
	KeyTab        Key = 0x30
	KeySpace      Key = 0x31
	KeyDelete     Key = 0x33
	KeyEscape     Key = 0x35
	KeyF1         Key = 0x7A
	KeyF2         Key = 0x78
	KeyF3         Key = 0x63
	KeyF4         Key = 0x76
	KeyF5         Key = 0x60
	KeyF6         Key = 0x61
	KeyF7         Key = 0x62
	KeyF8         Key = 0x64
	KeyF9         Key = 0x65
	KeyF10        Key = 0x6D
	KeyF11        Key = 0x67
	KeyF12        Key = 0x6F
	KeyF13        Key = 0x69
	KeyF14        Key = 0x6B
	KeyF15        Key = 0x71
	KeyLeftArrow  Key = 0x7B
	KeyRightArrow Key = 0x7C
	KeyDownArrow  Key = 0x7D
	KeyUpArrow    Key = 0x7E
)

The virtual key codes this package names. Any other code is usable; it simply renders as "key 0x…" in a Combo string.

func KeyForChar added in v0.8.0

func KeyForChar(ch string) (Key, bool)

KeyForChar is the key that PRINTS this character on the current keyboard.

The inverse of Key.Char, and the one a settings file needs: somebody writing "=" means the key with "=" printed on it, not the position ANSI keeps "=" at. Matching is case-insensitive, so "a" and "A" find the same key.

It searches the codes this package NAMES, and no others: a layout can put a character on a position with no name here, and claiming one of those would produce a combination that cannot be written down again.

func (Key) Char added in v0.8.0

func (k Key) Char() string

Char is what this key PRINTS on the keyboard in front of the person.

A Key is a virtual key code, which is a POSITION, and the names in this package are the ANSI legends for those positions. On a layout that is not ANSI the two come apart -- on French the position ANSI calls Equal prints "-", and "=" is over on the position ANSI calls Slash -- so a name is a claim about a keyboard nobody is typing on.

It answers "" where the system cannot say: a platform with no layout service, an input METHOD rather than a layout, or a key with no printed character at all -- an arrow, Escape, Return. A caller then has Key.String, which is at least a name a person can look up.

func (Key) Glyph added in v0.7.0

func (k Key) Glyph() string

Glyph is the key as macOS prints it on a menu: "=" rather than "Equal".

It is Key.String for every key but the four whose printed character is one a written combination uses for something else. Use it for a MENU and for anything else drawn rather than parsed; use String where the result may be read back.

func (Key) Name added in v0.3.0

func (k Key) Name() string

Name spells the key out, where Key.String would print the glyph macOS puts on a menu: "Left" rather than "←", "Return" rather than "↩".

The glyphs are right on a menu and in a terminal, and they are NOT in every font. Rendered in a window with a font that lacks them, "⌥⌘←" comes out as "Option-Command-" and stops — a line whose whole job is to say which combination was granted, saying nothing. So anywhere the font is not known, this is the one to use.

A key with no name of its own still renders as its hexadecimal code, which is honest rather than wrong.

func (Key) String

func (k Key) String() string

String renders the key as macOS would print it on a menu — "←" for the left arrow, "Space" for the space bar. An unnamed code renders as its hexadecimal virtual key code, which is honest rather than wrong: this package does not consult the active keyboard layout, so it cannot know what character an arbitrary code produces.

type Modifier

type Modifier uint8

Modifier is a set of modifier keys, as a bit set. It is deliberately NOT the Carbon bitmask nor the Cocoa one; both of those are derived from it, so a caller never has to know either.

const (
	Control Modifier = 1 << iota
	Option
	Shift
	Command
)

The modifier keys, in Apple's canonical display order.

func ParseModifier added in v0.2.0

func ParseModifier(s string) (Modifier, error)

ParseModifier reads one modifier name — "shift", "Control", "⌘" — or a sum of them, "control+shift". It is what a fallback ladder is written with.

func (Modifier) Names

func (m Modifier) Names() []string

Names renders the modifier set as English words, in the same order — for logs and for accessibility labels, where the glyphs read badly.

func (Modifier) String

func (m Modifier) String() string

String renders the modifier set with the standard glyphs, in the order macOS itself uses in menus: ⌃⌥⇧⌘. The empty set renders as "".

type NoReserved

type NoReserved struct{}

NoReserved is a Reserver that reserves nothing. Use it to opt out of the system-shortcut check.

func (NoReserved) Reserved

func (NoReserved) Reserved(Combo) (string, bool)

Reserved implements Reserver. It never reports a combination as taken.

type Options

type Options struct {
	// Ladder overrides [DefaultLadder]. An explicitly empty (non-nil, len 0)
	// ladder disables the fallback entirely: the wanted combination is
	// claimed or [ErrNoCandidate] is returned.
	Ladder []Modifier

	// Reserved overrides the system-shortcut check. Leave it nil to use the
	// machine's effective set ([LoadSystemShortcuts]). Set it to
	// NoReserved{} to skip the check and let Carbon be the only authority,
	// accepting that conflict kind 2 then goes undetected.
	Reserved Reserver

	// BareKey allows a combination with NO MODIFIER — a plain arrow, Return,
	// Escape — which is otherwise refused with [ErrNoModifier].
	//
	// It is refused by default because a bare key claimed system-wide is taken
	// from every application on the machine, and a caller who did that by
	// accident would break typing everywhere. That reasoning holds for a claim
	// that lasts as long as the program.
	//
	// It does not hold for a claim that lasts as long as a MODE. go-xrkit/desk
	// puts a full-screen gallery on a pair of glasses and wants the arrows to
	// move the selection in it — plain arrows, because a person looking at a
	// grid should not have to hold three modifiers to walk it — and gives them
	// back the moment the gallery closes. The person is not typing into
	// anything while a gallery covers their view.
	//
	// So it is opt-in and named, and a caller who sets it is saying they know
	// what they are taking. RELEASE IT: a bare key left claimed is a keyboard
	// somebody else cannot use.
	BareKey bool

	// OnThisKeyboard reads each key's name as the LEGEND PRINTED ON THE KEY, and
	// claims whichever position prints it here.
	//
	// ⛔ WITHOUT IT A SETTINGS FILE MEANS SOMETHING DIFFERENT ON EVERY LAYOUT AND
	// SAYS NOTHING ABOUT IT. A [Key] is a virtual key code, which is a POSITION,
	// and this package's names are the ANSI legends for those positions. On a
	// French Mac the position called Equal prints "-", and "=" is over on the
	// position called Slash -- so "ctrl+alt+cmd+Equal" claimed the key printed
	// "-", the shortcut was granted, it fired, and the person pressing the key
	// printed "=" reached nothing at all. Every check reported it as granted,
	// because it was.
	//
	// It is an OPTION and not the default because the two readings are both
	// legitimate: a game wants the position (WASD is a shape under the hand,
	// whatever is printed there) and a shortcut wants the legend (a person
	// presses what the menu says). This package cannot tell which a caller means.
	//
	// It applies to [Register] alone, and once -- which is the point of it being
	// here rather than a method on a combination. Reading a key's ANSI name and
	// moving to the local key that prints it is a ONE-WAY interpretation: the
	// result is a position whose own ANSI name says something else, so a
	// transform a caller could apply twice would walk. Doing it at the moment a
	// combination becomes a claim is the one place it cannot happen twice.
	//
	// Keys with no printed character of their own -- the arrows, Return, Escape,
	// the function keys -- are never moved: no layout moves them, and there is
	// nothing to match on. Neither is a key whose legend this keyboard does not
	// print anywhere, which is what "[" is on French: nothing is silently
	// swapped, the claim stays where it was, and [Combo.Glyphs] then reports what
	// that key actually prints.
	//
	// [Hotkey.Wanted] is the combination as WRITTEN, so a caller can still say
	// what was asked for.
	OnThisKeyboard bool
}

Options tunes Register. The zero value is the sensible default: the DefaultLadder, and the system-shortcut check switched on.

type Origin

type Origin int

Origin says where a SystemShortcut's binding came from — which is the difference between a fact and a well-informed guess.

const (
	// FromDefaults means the binding comes from this package's built-in
	// table of macOS defaults. It is a hand-maintained LIST, not a query.
	FromDefaults Origin = iota
	// FromPreferences means the user rebound the shortcut and the key and
	// modifiers were read out of com.apple.symbolichotkeys. This is a fact
	// about this machine.
	FromPreferences
)

func (Origin) String

func (o Origin) String() string

String renders the origin.

type Override

type Override struct {
	// ID is the entry number.
	ID int
	// Enabled is the entry's "enabled" flag.
	Enabled bool
	// Combo is the rebound combination, valid only when HasCombo is true.
	Combo Combo
	// HasCombo reports whether the entry carried a usable
	// value.parameters triple. Entries with only an "enabled" flag, and
	// entries whose parameters are the 65535 "unbound" sentinel, do not.
	HasCombo bool
}

Override is one entry read out of the com.apple.symbolichotkeys domain. It is an override of a default, which is why Combo is optional: an entry may say only that a shortcut is switched off, without restating what it is bound to.

func ParseSymbolicHotKeys

func ParseSymbolicHotKeys(raw map[string]any) []Override

ParseSymbolicHotKeys reads the decoded com.apple.symbolichotkeys dictionary — the value of its "AppleSymbolicHotKeys" key — into a list of overrides, sorted by ID.

The shape it expects, confirmed by dumping the real domain on macOS 26.6.2:

{"65": {"enabled": true,
        "value": {"type": "standard",
                  "parameters": [32, 49, 1572864]}}}

parameters is [ASCII character, virtual key code, NSEventModifierFlags mask]. The first element is 65535 when the shortcut has no character equivalent, and an all-65535 triple means the shortcut is unbound; both are handled.

Parsing is deliberately tolerant. This is a preference file a user or a third-party tool may have written, so a malformed entry is skipped rather than failing the whole read: a hot key that cannot be claimed because one preference entry was odd would be a bad trade.

type Registrar

type Registrar interface {
	Claim(Combo) (Claim, error)
}

Registrar is the seam between the fallback policy and the operating system. Resolve speaks only to this, so the whole ladder — including the case where every candidate is taken — is testable on any platform with no Carbon at all.

Claim must report ErrComboTaken (or an error wrapping it) when the combination is held by another Carbon hot-key holder. Any other error aborts the ladder, because it means something is wrong with the process rather than with this particular combination.

type Reserver

type Reserver interface {
	Reserved(Combo) (reason string, taken bool)
}

Reserver reports combinations that are known to be taken WITHOUT asking the operating system to register them — the macOS system shortcuts that RegisterEventHotKey would happily hand out anyway. SystemShortcuts implements it. A nil Reserver means "check nothing", in which case only conflict kind 1 is detected.

type SystemShortcut

type SystemShortcut struct {
	// ID is the com.apple.symbolichotkeys entry number.
	ID int
	// Name is what System Settings > Keyboard > Keyboard Shortcuts calls it.
	Name string
	// Combo is the key combination it occupies.
	Combo Combo
	// Enabled reports whether it is currently switched on. A disabled
	// shortcut does not reserve its combination.
	Enabled bool
	// Origin says whether Combo was read from this machine's preferences or
	// taken from the built-in defaults list.
	Origin Origin
}

SystemShortcut is one macOS system-wide shortcut: a Mission Control or Spotlight or input-source binding, the kind RegisterEventHotKey will hand you without complaint even though the window server will keep swallowing it.

func DefaultShortcuts

func DefaultShortcuts() []SystemShortcut

DefaultShortcuts returns a copy of the built-in defaults list. Every entry has FromDefaults as its origin. Callers may use it to show the user what this package believes is reserved, and to see plainly that it is a list.

func (SystemShortcut) String

func (s SystemShortcut) String() string

String renders the shortcut for a diagnostic listing.

type SystemShortcuts

type SystemShortcuts struct {
	// contains filtered or unexported fields
}

SystemShortcuts is the effective set of macOS system shortcuts on a machine: the built-in defaults list with the com.apple.symbolichotkeys overrides layered over it. It implements Reserver.

func LoadSystemShortcuts

func LoadSystemShortcuts() (*SystemShortcuts, error)

LoadSystemShortcuts returns the built-in defaults list alone. There is no com.apple.symbolichotkeys domain to layer over it on this platform, so the result is what macOS reserves BY DEFAULT — useful for showing a user what their Mac would refuse, and for testing the merge, but it describes macOS rather than the machine this is running on.

func Merge

func Merge(defaults []SystemShortcut, overrides []Override) *SystemShortcuts

Merge layers preference overrides over a defaults list and returns the effective set.

Three things happen, and each corresponds to something really seen in the domain on macOS 26.6.2:

  • An override with a binding (entry 60: parameters [32, 49, 262144]) replaces the default's combination.
  • An override with only an "enabled" flag (entries 79-82) changes only whether the default is on. Its binding still comes from the list.
  • An override for an ID the list does not know is kept if it carries a binding, and dropped if it does not — an unknown ID with no binding tells us nothing usable.

Disabled shortcuts are retained in the set but do not reserve their combination; see SystemShortcuts.Reserved.

func (*SystemShortcuts) All

func (s *SystemShortcuts) All() []SystemShortcut

All returns the effective shortcuts, ordered by the defaults list and then by any extra entries found in preferences.

func (*SystemShortcuts) Describe

func (s *SystemShortcuts) Describe() string

Describe renders the effective set as a diagnostic listing, one shortcut per line. It is what to print when a user asks why they did not get the combination they wanted.

func (*SystemShortcuts) Reserved

func (s *SystemShortcuts) Reserved(c Combo) (string, bool)

Reserved implements Reserver. It reports a combination as taken when an ENABLED system shortcut occupies it, and says which one — so the caller can tell the user "⌥⌘Space is the Finder's search window" rather than just "no".

Directories

Path Synopsis
cmd
hotkeycheck command
Command hotkeycheck claims a system-wide shortcut and reports what it got.
Command hotkeycheck claims a system-wide shortcut and reports what it got.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL