summaryrefslogtreecommitdiff
path: root/config/hammerspoon/dotlib.lua
diff options
context:
space:
mode:
Diffstat (limited to 'config/hammerspoon/dotlib.lua')
-rw-r--r--config/hammerspoon/dotlib.lua134
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