summaryrefslogtreecommitdiff
path: root/config/hammerspoon/dotlib.lua
blob: 1f116682f46ef5d42f7a5498c938550d084404c8 (plain)
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