diff options
| -rw-r--r-- | AGENTS.md | 36 | ||||
| -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 |
4 files changed, 195 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`) 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() |
