summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md36
-rw-r--r--config/hammerspoon/dotlib.lua134
-rw-r--r--config/hammerspoon/init.lua17
-rw-r--r--config/hammerspoon/local-example.lua9
4 files changed, 195 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 52287de..097985c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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()