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 /config | |
| 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 'config')
| -rw-r--r-- | config/hammerspoon/dotlib.lua | 134 | ||||
| -rw-r--r-- | config/hammerspoon/init.lua | 17 | ||||
| -rw-r--r-- | config/hammerspoon/local-example.lua | 9 |
3 files changed, 160 insertions, 0 deletions
diff --git a/config/hammerspoon/dotlib.lua b/config/hammerspoon/dotlib.lua index 15fe0b2..1f11668 100644 --- a/config/hammerspoon/dotlib.lua +++ b/config/hammerspoon/dotlib.lua @@ -136,4 +136,138 @@ function M.type_text(text, options) end end +-- Caps Lock as a bindable key, remapped to F20. +-- +-- hidutil remaps inside the IOKit HID driver, below the window server: the key +-- stops being Caps Lock before anything above the driver sees it, so there is +-- no toggle to suppress and F20 is a real key to everything -- other apps, +-- System Settings shortcuts, a password field, a VM. An hs.eventtap cannot do +-- this. Caps Lock is not reachable from there at all, which is why hs.hid +-- exists as a separate IOKit module in the first place. +-- +-- What hidutil will not do is cover a keyboard attached after it ran: +-- `property --set` touches the devices matched at that moment. Three things +-- re-apply it, and the tap is the general answer -- a mapped keyboard cannot +-- produce a Caps Lock event, so an event arriving here is itself proof of a +-- keyboard that was missed, and the fault triggers its own repair. That press +-- is spent on the repair and toggles Caps Lock for an instant; the two watchers +-- exist to catch the common cases -- a keyboard plugged in, the machine waking +-- -- before any key is pressed, so the usual cost is nothing at all. +-- +-- Switch it off on one machine from that machine's local.lua, which loads +-- last: require("dotlib").capslock_off() + +local hidutil = "/usr/bin/hidutil" +-- Keyboard usage page 0x07: 0x39 is Caps Lock, 0x6f is F20 (F13-F20 are +-- 0x68-0x6f). +local capslock_to_f20 = '{"UserKeyMapping":[' .. + '{"HIDKeyboardModifierMappingSrc":0x700000039,' .. + '"HIDKeyboardModifierMappingDst":0x70000006f}]}' +local capslock_cleared = '{"UserKeyMapping":[]}' +local alpha_shift = hs.eventtap.event.rawFlagMasks.alphaShift + +-- Watchers, taps and timers are collected the moment nothing refers to them, +-- so these have to outlive the call that starts them. +local capslock_parts = {} +local capslock_debounce = nil +local capslock_task = nil + +local function hidutil_set(mapping, done) + -- Asynchronous because one caller is an eventtap callback, and macOS + -- disables a tap whose callback is slow to return -- spawning a process and + -- waiting for it is precisely that. + capslock_task = hs.task.new(hidutil, function(code, _, stderr) + if code ~= 0 then + print("capslock: hidutil exited " .. code .. " " .. (stderr or "")) + hs.notify.new({ + title = "Hammerspoon", + informativeText = "Caps Lock remap failed -- see the console", + }):send() + return + end + if done then done() end + end, { "property", "--set", mapping }) + capslock_task:start() +end + +-- Several devices arriving at once -- a dock, a hub -- should cost one run. +local function capslock_apply_soon() + if capslock_debounce then capslock_debounce:stop() end + capslock_debounce = hs.timer.doAfter(0.5, function() + hidutil_set(capslock_to_f20) + end) +end + +-- Caps Lock left on while the key is mapped cannot be turned off from that +-- keyboard: the key sends F20 now. Nothing but this clears it. +local function capslock_unstick() + if hs.hid.capslock.get() then + hs.hid.capslock.set(false) + print("capslock: cleared a stuck Caps Lock") + end +end + +-- Remap Caps Lock to F20 and keep it remapped. Bind f20 like any other key. +function M.capslock_f20() + capslock_unstick() + hidutil_set(capslock_to_f20) + + capslock_parts.usb = hs.usb.watcher.new(function(event) + if event.eventType == "added" then capslock_apply_soon() end + end):start() + + capslock_parts.wake = hs.caffeinate.watcher.new(function(event) + if event == hs.caffeinate.watcher.systemDidWake then + capslock_apply_soon() + end + end):start() + + capslock_parts.tap = hs.eventtap.new( + { hs.eventtap.event.types.flagsChanged }, + function(event) + -- flagsChanged fires on every modifier, so this stays a mask + -- test. Caps Lock is not in event:getFlags() at all -- that table + -- covers cmd/alt/shift/ctrl/fn only, and comes back empty for a + -- Caps Lock event (checked, 2026-10-01). In the raw CGEvent flags + -- it is alphaShift. + if (event:rawFlags() & alpha_shift) == 0 then return false end + print("capslock: unmapped keyboard, re-applying") + -- The toggle has already happened and cannot be suppressed from + -- here, so put the state back and spend the press on the repair. + -- Re-posting it as a synthetic F20 was tried and dropped: an + -- hs.hotkey bound to f20 did not fire for the posted event, and the + -- second press -- which is a real F20 -- costs less than carrying + -- code that only looks like it works. + hs.hid.capslock.set(false) + hidutil_set(capslock_to_f20, capslock_unstick) + return false + end):start() +end + +-- What a shell cannot see from outside: whether the watchers and the tap that +-- keep the mapping alive are still running. require() is cached per Lua state, +-- so `hs -c 'hs.inspect(require("dotlib").capslock_status())'` reports on the +-- ones init.lua started, not a fresh copy. +function M.capslock_status() + local tap = capslock_parts.tap + return { + usb = capslock_parts.usb ~= nil, + wake = capslock_parts.wake ~= nil, + tap = tap ~= nil and tap:isEnabled(), + } +end + +-- Give the key back: stop re-applying, and clear the mapping that is live. +function M.capslock_off() + for name, part in pairs(capslock_parts) do + part:stop() + capslock_parts[name] = nil + end + if capslock_debounce then + capslock_debounce:stop() + capslock_debounce = nil + end + hidutil_set(capslock_cleared) +end + return M diff --git a/config/hammerspoon/init.lua b/config/hammerspoon/init.lua index cb1462a..74ae68f 100644 --- a/config/hammerspoon/init.lua +++ b/config/hammerspoon/init.lua @@ -35,6 +35,23 @@ local type_random_ids = lib.type_random_ids local cs, ca, csa = lib.mods.cs, lib.mods.ca, lib.mods.csa local cag, hyp, spc = lib.mods.cag, lib.mods.hyp, lib.mods.spc +-- Caps Lock sends F20 from here on: dotlib does the HID-level remap and keeps +-- re-applying it, including for keyboards attached later. Nothing is bound to +-- f20 by default -- bind it below or in a machine's local.lua, the same as any +-- other key. A machine that wants its Caps Lock back calls +-- require("dotlib").capslock_off() from its local.lua, which loads last. +-- +-- Early, so the keyboard behaves the same for the rest of this file's startup, +-- but deliberately after the dotlib guard above: a config that failed to load +-- has no bindings either, and a remapped key with nothing bound to it is worse +-- than a working Caps Lock. +lib.capslock_f20() + +-- The message port behind the `hs` CLI, so this config can be queried and +-- driven from a shell -- `hs -c 'hs.hid.capslock.get()'`. Without it there is +-- no way to check what Hammerspoon thinks from outside Hammerspoon. +require("hs.ipc") + local function toggle(appName) return function() local currentSpace = hs.spaces.focusedSpace() -- Get current space diff --git a/config/hammerspoon/local-example.lua b/config/hammerspoon/local-example.lua index 967c14e..19dcc2a 100644 --- a/config/hammerspoon/local-example.lua +++ b/config/hammerspoon/local-example.lua @@ -43,3 +43,12 @@ hotkey(spc, cs, execute('open -a Finder')) hotkey('m', hyp, function() hs.alert.show(hs.host.localizedName()) end) + +-- Caps Lock is F20 on every Mac here (see dotlib.capslock_f20, called from +-- init.lua), and nothing is bound to it by default. Give it a job: +hotkey('f20', {}, focus("Ghostty")) + +-- Or give the key back on this machine only. A side effect cannot be shadowed +-- the way a hotkey can -- the remap has already happened by the time this file +-- loads -- so there is a function for it, which also clears the live mapping. +-- lib.capslock_off() |
