diff options
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() |
