diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-01 18:05:25 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-01 18:05:25 +0300 |
| commit | 6c7eb1b380cec100663c7c11d0252f9e6b46d34a (patch) | |
| tree | 572c254c008df0dad812476850711e3861349c31 /AGENTS.md | |
| parent | 7fc9494ac2b7d905cebff10ff5b8be5a3de80403 (diff) | |
hammerspoon: Caps Lock sends F20, and stays that way
hidutil remaps inside the IOKit HID driver, below the window server, so the
key stops being Caps Lock before anything above the driver sees it: no toggle
to suppress, and F20 is a real key to other apps, to System Settings
shortcuts, to a password field. An eventtap cannot do this -- Caps Lock is not
reachable from there, which is why hs.hid exists as its own IOKit module.
What hidutil will not do is cover a keyboard attached after it ran, so dotlib
re-applies on USB attach, on wake, and on a Caps Lock event. That last one is
the general answer: a mapped keyboard cannot produce a Caps Lock event, so an
event arriving at all is proof of a keyboard the remap missed, and the fault
triggers its own repair. The watchers are there to get the common cases before
a key is pressed, since the repairing press is spent repairing.
No launchd job and no link/ part. The remap lives and dies with Hammerspoon,
which already owns the keyboard on these machines, and a plist would only have
bought independence from it for the second or two before it starts -- a user
agent does not run at the login window either.
Nothing is bound to f20 here; bind it like any other key. A machine gives the
key back with capslock_off() from its local.lua, which loads last: shadowing
cannot undo a side effect that has already run. hs.ipc is required so
capslock_status() answers from a shell.
Diffstat (limited to 'AGENTS.md')
| -rw-r--r-- | AGENTS.md | 36 |
1 files changed, 35 insertions, 1 deletions
@@ -169,7 +169,7 @@ tracked config says so, and in no repo: | `~/.localprofile` | interactive setup: aliases, prompt, anything that forks | `config/profile`, last | | `~/.config/ssh/config-local` | ssh, this host only. See `config/ssh/config-local-example` | `config/ssh/entry`, before the shared defaults | | `~/.config/git/config-local` | git, this host only -- the work identity, `includeIf` blocks. See `config/git/config-local-example` | `config/git/config`, after the `[user]` block above it, and git takes the last value | -| `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it. `require("dotlib")` for the same helpers init.lua uses. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` | +| `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it, and `capslock_off()` gives this machine its Caps Lock back. `require("dotlib")` for the same helpers init.lua uses. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` | | `~/.config/ghostty-local` | Ghostty: later values replace earlier ones, `keybind` accumulates. See `config/ghostty/ghostty-local-example` | `config/ghostty/config`, last, as an optional `config-file` | | `~/.local/share/alacritty/local.toml` | Alacritty | imported by `config/alacritty/alacritty.toml` | | `~/.config/dot/skip` | which `link/` parts this machine does not want, one name per line | `link/lib.sh`, as each part starts | @@ -356,6 +356,40 @@ Key environment features: - Custom aliases and utility functions - Performance profiling capabilities +### Keyboard + +Caps Lock sends F20 on every Mac running the Hammerspoon config. +`dotlib.capslock_f20()`, called early from `config/hammerspoon/init.lua`, does +it with `hidutil`: the remap happens in the IOKit HID driver, below the window +server, so the key is F20 to everything -- other apps, System Settings +shortcuts, a password field, a VM. An `hs.eventtap` cannot reach Caps Lock at +all, which is why `hs.hid` exists as a separate IOKit module, and why this is +not a hotkey. + +`hidutil property --set` only touches the keyboards attached at that moment, so +three things re-apply it: the call at startup, watchers for USB attach and +system wake, and an `hs.eventtap` on `flagsChanged`. The tap is the general +answer -- a mapped keyboard cannot produce a Caps Lock event, so an event +arriving at all is proof of a keyboard that was missed, and the fault triggers +its own repair. That press is spent repairing and toggles Caps Lock for an +instant, cleared with `hs.hid.capslock.set(false)` -- also the only way to clear +a stuck Caps Lock once the key itself sends F20. Re-posting the press as a +synthetic F20 was tried and dropped: an `hs.hotkey` bound to `f20` did not fire +for a posted event. The watchers are there to catch the two common cases -- +plugging a keyboard in, waking the machine -- before any key is pressed, which +is why the repair press is rare. Caps Lock is `alphaShift` in the raw CGEvent flags and is absent from +`event:getFlags()`, which covers only cmd/alt/shift/ctrl/fn. + +There is no launchd job and no `link/` part for this. The remap lives and dies +with Hammerspoon, which already owns the keyboard on these machines. + +Nothing is bound to `f20` here; bind it like any other key. One machine gives +the key back with `require("dotlib").capslock_off()` in its `local.lua` -- +shadowing cannot work, because the remap has already run by the time +`local.lua` loads. `hs -c 'hs.inspect(require("dotlib").capslock_status())'` +reports whether the watchers and the tap are still up; that needs `hs.ipc`, +which `init.lua` requires for exactly this kind of check. + ### Utility Scripts The `bin/` directory contains various utility scripts for: - Theme management (`theme`) |
