summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-09-21 18:30:47 +0300
committerIgor Soarez <igor@soarez.org>2026-09-21 18:30:47 +0300
commit94a8f1ff31a804a4dfa46ce287e30bf4691ae637 (patch)
tree44e5e891ac462711326d334bda905f4cd1823fc6
parent7f0049952bba4ef0e22bf25dc85da6cdfe38f104 (diff)
hammerspoon: shared helpers move to dotlib.lua, which local.lua can require
A machine's local.lua is a separate chunk, so init.lua's locals were invisible to it and anything it wanted -- hotkey(), the mod tables, type_text -- had to be copied. They live in dotlib.lua now, linked beside init.lua, and both files reach them the same way: require("dotlib"). init.lua keeps its own vocabulary by aliasing what it uses, so the 500 lines below are untouched. It prepends its own directory to package.path first: Hammerspoon already puts the config directory there (Contents/Resources/ setup.lua), but taking it from this file's path holds however the config was found. A machine that pulls without re-linking has no dotlib.lua beside init.lua, which would lose every hotkey silently -- that case notifies and names the fix. Checked with a stubbed hs: the module's hotkey, focus, execute, type_text and type_current_date all behave, and local-example.lua binds its four keys through it.
-rw-r--r--CLAUDE.md2
-rw-r--r--config/hammerspoon/dotlib.lua139
-rw-r--r--config/hammerspoon/init.lua140
-rw-r--r--config/hammerspoon/local-example.lua19
-rwxr-xr-xlink/hammerspoon.sh3
5 files changed, 183 insertions, 120 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 546678f..bc2784e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,7 +60,7 @@ tracked config says so, and in no repo:
| `~/.localprofile` | interactive setup: aliases, prompt, anything that forks | `config/profile`, last |
| `~/.config/ssh/config-local` | ssh, this host only. See `config/ssh/config-local-example` | `config/ssh/entry`, before the shared defaults |
| `~/.config/git/config-local` | git, this host only -- the work identity, `includeIf` blocks. See `config/git/config-local-example` | `config/git/config`, after the `[user]` block above it, and git takes the last value |
-| `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` |
+| `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it. `require("dotlib")` for the same helpers init.lua uses. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` |
| `~/.config/ghostty-local` | Ghostty: later values replace earlier ones, `keybind` accumulates. See `config/ghostty/ghostty-local-example` | `config/ghostty/config`, last, as an optional `config-file` |
| `~/.local/share/alacritty/local.toml` | Alacritty | imported by `config/alacritty/alacritty.toml` |
| `~/.config/dot/skip` | which `link/` parts this machine does not want, one name per line | `link/lib.sh`, as each part starts |
diff --git a/config/hammerspoon/dotlib.lua b/config/hammerspoon/dotlib.lua
new file mode 100644
index 0000000..15fe0b2
--- /dev/null
+++ b/config/hammerspoon/dotlib.lua
@@ -0,0 +1,139 @@
+-- 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
+
+return M
diff --git a/config/hammerspoon/init.lua b/config/hammerspoon/init.lua
index 93950a9..cb1462a 100644
--- a/config/hammerspoon/init.lua
+++ b/config/hammerspoon/init.lua
@@ -4,40 +4,36 @@
-- defaults write org.hammerspoon.Hammerspoon MJConfigFile "$HOME/.config/hammerspoon/init.lua"
-- hs.application.enableSpotlightForNameSearches(true)
-local function 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
+-- The shared helpers, in dotlib.lua beside this file. A machine's own
+-- ~/.config/hammerspoon/local.lua requires the same module, which is the point:
+-- as locals of this file they were invisible to it.
+--
+-- Hammerspoon already puts the config directory first on package.path; this
+-- prepend takes the directory from this file's own path instead, so the module
+-- is found however the config was located.
+package.path = (debug.getinfo(1, "S").source:match("^@(.*)/[^/]+$") or ".")
+ .. "/?.lua;" .. package.path
+-- A machine that pulled this file but has not re-linked yet has no dotlib.lua
+-- beside it, and every hotkey below would be gone with nothing on screen to say
+-- why. Name the fix instead.
+local ok, lib = pcall(require, "dotlib")
+if not ok then
+ hs.notify.new({
+ title = "Hammerspoon",
+ informativeText = "dotlib.lua is missing -- run link.sh yes in the account repo",
+ }):send()
+ error(lib, 0)
+end
+
+local focus = lib.focus
+local execute = lib.execute
+local hotkey = lib.hotkey
+local type_text = lib.type_text
+local type_current_date = lib.type_current_date
+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
local function toggle(appName)
return function()
@@ -62,12 +58,6 @@ local function toggle(appName)
end
-local function execute(cmd)
- return function()
- hs.execute(cmd, true)
- end
-end
-
local default_browser_bin = '/Users/sz/.bin/default-browser'
local function set_default_browser(browserName)
return execute(default_browser_bin .. ' "' .. browserName .. '"')
@@ -274,11 +264,6 @@ local function toggle_audio_watcher()
end
-local function type_current_date()
- local clock = os.date('%Y-%m-%d')
- hs.eventtap.keyStrokes(clock)
-end
-
local function type_uuids(count)
local shell = '/bin/bash'
local script = '/Volumes/Sense/Work/kafka/kafka/.ignore/tools/gen-uuids.sh'
@@ -289,62 +274,6 @@ local function type_uuids(count)
hs.eventtap.keyStrokes(out)
end
end
-local function type_random_ids(count)
- -- use a login shell
- 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
-
-local function 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
-
-- Useful to setup a hotkey to dump text for screencasting. e.g.
-- hotkey('1', csa, demo_dump('/tmp/screencast1.txt'))
-- hotkey('2', csa, demo_dump('/tmp/screencast2.txt'))
@@ -493,17 +422,6 @@ end
-- return toprint
-- end
-local function hotkey(key, mods, fn)
- hs.hotkey.bind(mods, key, fn)
-end
-
-local cs = { "ctrl", "shift" }
-local ca = { "ctrl", "alt" }
-local csa = { "ctrl", "shift", "alt" }
-local cag = { "ctrl", "alt", "cmd" }
-local hyp = { "ctrl", "alt", "cmd", "shift" }
-local spc = 'space'
-
hotkey('0', cag, execute('/Users/sz/.bin/theme --set light'))
hotkey('0', hyp, execute('/Users/sz/.bin/theme --set dark'))
hotkey('1', cag, focus('Activity Monitor'))
diff --git a/config/hammerspoon/local-example.lua b/config/hammerspoon/local-example.lua
index 2ddfe15..cf2963d 100644
--- a/config/hammerspoon/local-example.lua
+++ b/config/hammerspoon/local-example.lua
@@ -8,22 +8,25 @@
--
-- Reload with ctrl-shift-alt-h, as with any other change.
--
--- Note what is NOT available here: init.lua's `hotkey()` helper and its mod
--- tables (cs, ca, csa, cag, hyp) are locals of that file, and this is a
--- separate chunk. Call hs.hotkey.bind directly and write the modifiers out.
+-- The helpers init.lua uses are in dotlib.lua, linked beside it, so this file
+-- calls them the same way it does: hotkey(key, mods, fn), the mod tables, and
+-- focus/execute/type_text. hs.* is available too, for anything not in there.
+local lib = require("dotlib")
+local hotkey, mods = lib.hotkey, lib.mods
-- Take a key over. Binding a combination init.lua already bound wins, because
-- hs.hotkey stacks bindings per combination and this one is enabled last.
-hs.hotkey.bind({ "ctrl", "alt" }, "1", function()
- hs.application.launchOrFocus("Slack")
-end)
+hotkey('1', mods.ca, lib.focus("Slack"))
+
+-- Type something only this machine knows about.
+hotkey('9', mods.csa, lib.type_text('someone@example.com'))
-- Give a key back to the OS or another app: bind it to nothing. A binding from
-- init.lua cannot be deleted from here -- it keeps no reference to the hotkey
-- object -- so shadowing it with a no-op is how it is switched off.
-hs.hotkey.bind({ "ctrl", "alt", "cmd" }, "0", function() end)
+hotkey('0', mods.cag, function() end)
-- Anything else Hammerspoon can do belongs here just as well.
-hs.hotkey.bind({ "ctrl", "alt" }, "m", function()
+hotkey('m', mods.ca, function()
hs.alert.show(hs.host.localizedName())
end)
diff --git a/link/hammerspoon.sh b/link/hammerspoon.sh
index 71c9c91..7c3f2a0 100755
--- a/link/hammerspoon.sh
+++ b/link/hammerspoon.sh
@@ -9,6 +9,9 @@ set -euo pipefail
source "$(dirname "$0")/lib.sh" "$@"
link "$config_dir/hammerspoon/init.lua" "$xdg_config_dir/hammerspoon/init.lua"
+# The helpers init.lua and a machine's own local.lua both require. Linked as its
+# own file, not the directory: that directory is where local.lua lives.
+link "$config_dir/hammerspoon/dotlib.lua" "$xdg_config_dir/hammerspoon/dotlib.lua"
if [[ "$(uname)" == "Darwin" ]]
then