diff options
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | config/hammerspoon/dotlib.lua | 139 | ||||
| -rw-r--r-- | config/hammerspoon/init.lua | 140 | ||||
| -rw-r--r-- | config/hammerspoon/local-example.lua | 19 | ||||
| -rwxr-xr-x | link/hammerspoon.sh | 3 |
5 files changed, 183 insertions, 120 deletions
@@ -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 |
