-- Helpers shared by init.lua and a machine's own ~/.config/hammerspoon/ -- local.lua, which reach them with `require("dotlib")`. -- -- They used to be locals of init.lua, which meant a local.lua -- a separate -- chunk -- could not see them and had to copy whatever it needed. Hammerspoon -- puts the config directory first on package.path (its Contents/Resources/ -- setup.lua), and init.lua prepends it again from its own location, so this -- file is found wherever the config is linked. -- -- link/hammerspoon.sh links this file next to init.lua. Anything added here is -- part of what a local.lua may call, so keep the names stable. local M = {} -- Bind a key. Argument order is the one init.lua has always used: key first, -- then the modifier table, then the function. function M.hotkey(key, mods, fn) hs.hotkey.bind(mods, key, fn) end -- The modifier combinations these configs bind against. M.mods = { cs = { "ctrl", "shift" }, ca = { "ctrl", "alt" }, csa = { "ctrl", "shift", "alt" }, cag = { "ctrl", "alt", "cmd" }, hyp = { "ctrl", "alt", "cmd", "shift" }, spc = "space", } -- Focus an app. Given several names, the first one installed wins, so one -- binding can cover machines that have different apps for the same job. function M.focus(...) local appNames = { ... } local selectedAppName = nil -- If only one argument is provided, use it directly without searching if #appNames == 1 then selectedAppName = appNames[1] else -- For multiple arguments, find the first app that exists on disk local appDirs = { "/Applications/", os.getenv("HOME") .. "/Applications/" } for _, appName in ipairs(appNames) do for _, dir in ipairs(appDirs) do local path = dir .. appName .. ".app" if hs.fs.attributes(path) then selectedAppName = appName break end end if selectedAppName then break end end if not selectedAppName then print("No apps found: " .. table.concat(appNames, ", ")) end end return function() if selectedAppName then print('Opening ' .. selectedAppName) hs.application.launchOrFocus(selectedAppName) else print("No apps found: " .. table.concat(appNames, ", ")) end end end -- Run a shell command. function M.execute(cmd) return function() hs.execute(cmd, true) end end -- Type today's date. function M.type_current_date() local clock = os.date('%Y-%m-%d') hs.eventtap.keyStrokes(clock) end -- Type ids from random-id.ts, through a login shell so it is on PATH. function M.type_random_ids(count) local cmd = string.format('/bin/bash --login -c "random-id.ts %s"', count) return function() local out = hs.execute(cmd, true) if count == 1 then out = out:gsub("%s+$", "") end hs.eventtap.keyStrokes(out) end end -- Type a string. With min_delay/max_delay it is typed character by character, -- at a randomised pace, for a screencast; without, all at once. function M.type_text(text, options) return function() if options and (options.min_delay or options.max_delay) then -- Type with delays between characters local min_delay = options.min_delay or 0 local max_delay = options.max_delay or min_delay local chars = {} for char in text:gmatch(".") do table.insert(chars, char) end local function typeNextChar(index) if index > #chars then return end -- Handle newlines specially by sending return key if chars[index] == "\n" then hs.eventtap.keyStroke({}, "return") else hs.eventtap.keyStrokes(chars[index]) end if index < #chars then local delay = min_delay if max_delay > min_delay then -- Use squared random for skewed distribution -- Most delays will be near min_delay, higher delays are rarer local rand_squared = math.random() ^ 2 delay = min_delay + rand_squared * (max_delay - min_delay) end hs.timer.doAfter(delay, function() typeNextChar(index + 1) end) end end typeNextChar(1) else -- Fast typing without delays hs.eventtap.keyStrokes(text) end 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