diff options
Diffstat (limited to 'config/hammerspoon/dotlib.lua')
| -rw-r--r-- | config/hammerspoon/dotlib.lua | 134 |
1 files changed, 134 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 |
