all repos — dot @ master

Shell, editor and terminal configuration, and the tools that go with them

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