config/hammerspoon/dotlib.lua (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 |
-- 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
|