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