summaryrefslogtreecommitdiff
path: root/config/hammerspoon
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
committerIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
commit13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch)
tree684690c61b7b59758c2aac132c65b81c86de0608 /config/hammerspoon
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and szecret, an age-based secret manager whose store lives elsewhere. History restarts here. What came before was not written to be read by anyone else, and carried machine names, addresses and an encrypted secret store; it is kept privately instead. What is left is meant to be safe on any machine, including one whose owner is not me: see CLAUDE.md for the rule it is written to.
Diffstat (limited to 'config/hammerspoon')
-rw-r--r--config/hammerspoon/init.lua586
1 files changed, 586 insertions, 0 deletions
diff --git a/config/hammerspoon/init.lua b/config/hammerspoon/init.lua
new file mode 100644
index 0000000..45ce362
--- /dev/null
+++ b/config/hammerspoon/init.lua
@@ -0,0 +1,586 @@
+-- https://www.hammerspoon.org/
+--
+-- To place this file in ~/.config/hammerspoon/init.lua first run:
+-- 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
+
+local function toggle(appName)
+ return function()
+ local currentSpace = hs.spaces.focusedSpace() -- Get current space
+ local app = hs.application.get(appName) -- Get app
+ if app then -- If app already open:
+ if not app:mainWindow() then -- If no main window, then open a new window
+ app:selectMenuItem("New OS Window", true)
+ elseif app:isFrontmost() then -- If app is already in front, then hide it
+ app:hide()
+ else -- If there is a main window somewhere, bring it to current space and to front
+ hs.spaces.moveWindowToSpace(app:mainWindow(), currentSpace) -- First move the main window to the current space
+ app:activate() -- Activate the app
+ app:mainWindow():raise() -- Raise the main window and position correctly
+ end
+ else -- If app not open, open it
+ hs.application.launchOrFocus(appName)
+ app = hs.application.get(appName)
+ end
+ -- hs.spaces.gotoSpace(currentSpace)
+ end
+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 .. '"')
+end
+
+local function focusDefaultBrowser()
+ local output = hs.execute('/Users/sz/.bin/default-browser', true)
+ if not output then
+ print('ERROR: default-browser bin failed to run')
+ return
+ end
+
+ local marker = "%s%(default%)"
+ -- iterate over each line (\n) in the output
+ for line in string.gmatch(output, "[^\n]+") do
+ -- check if the line contains the marker
+ if string.find(line, marker) then
+ -- extract the name from the line
+ local name = line:gsub(marker, "")
+ print(name)
+ -- focus the browser with the extracted name
+ return focus(name)()
+ end
+ end
+end
+
+local function terminal_opacity(incr)
+ local tool = '/Users/sz/.bin/terminal-set-opacity'
+ local sign = '-'
+ local symbol = '🔽'
+ if incr then
+ symbol = '🔼'
+ sign = ''
+ end
+ local step = 0.02
+ local cmd = string.format('%s %s%s', tool, sign, step)
+ local out = hs.execute(cmd, true)
+ local alert = string.format('%s %s', symbol, out:gsub("\n", ""))
+ hs.alert.closeAll()
+ hs.alert.show(alert)
+end
+function terminal_increase_opacity()
+ terminal_opacity(true)
+end
+
+function terminal_decrease_opacity()
+ terminal_opacity(false)
+end
+
+local function show_battery()
+ local msg = 'Juice: ' .. hs.battery.percentage() .. '%'
+ hs.alert.show(msg)
+end
+local function hammerspoon_console()
+ hs.consoleOnTop(true)
+ hs.toggleConsole()
+end
+local function hammerspoon_reload()
+ hs.reload()
+end
+
+local function whisper_cpp()
+ -- --capture 1
+ local cmd =
+ "tmux neww -d -c '/Volumes/Sense/src/whisper.cpp' './stream --threads 8 --model models/ggml-base.en.bin --step 350 --length 10000 -f /tmp/whisper.txt'"
+ hs.execute(cmd, true)
+ hs.alert.show('Action!')
+end
+
+local function show_current_time()
+ local clock = os.date('%Y-%m-%d %H:%M')
+ hs.alert.show(clock)
+end
+
+-- Audio device preferences (in order of priority, case-insensitive substrings)
+local inputPrefs = { "yeti", "macbook" }
+local outputPrefs = { "soundcore Space One", "haidt", "yeti", "lg ultrafine", "macbook" }
+local audioWatcherEnabled = true
+
+-- Find the best available device from a preference list (case-insensitive substring match)
+local function find_preferred_device(devices, prefs)
+ for _, pref in ipairs(prefs) do
+ for _, device in ipairs(devices) do
+ if device and device:name():lower():find(pref:lower(), 1, true) then
+ return device
+ end
+ end
+ end
+ return nil
+end
+
+-- Set input to the best available preferred device
+local function set_preferred_input()
+ local preferred = find_preferred_device(hs.audiodevice.allInputDevices(), inputPrefs)
+ if not preferred then return false end
+
+ local current = hs.audiodevice.defaultInputDevice()
+ if current and current:uid() == preferred:uid() then return false end
+
+ preferred:setDefaultInputDevice()
+ print("Set input to " .. preferred:name())
+ return true
+end
+
+-- Set output to the best available preferred device
+local function set_preferred_output()
+ local preferred = find_preferred_device(hs.audiodevice.allOutputDevices(), outputPrefs)
+ if not preferred then return false end
+
+ local current = hs.audiodevice.defaultOutputDevice()
+ if current and current:uid() == preferred:uid() then return false end
+
+ preferred:setDefaultOutputDevice()
+ print("Set output to " .. preferred:name())
+ return true
+end
+
+-- Check and correct audio devices with retry logic for race conditions
+local function check_audio_devices()
+ local maxAttempts = 30
+ local delaySecs = 5 / maxAttempts
+ local attempt = 0
+
+ local function check()
+ attempt = attempt + 1
+ local inputChanged = set_preferred_input()
+ local outputChanged = set_preferred_output()
+
+ if not inputChanged and not outputChanged then
+ print("Audio devices correct (attempt " .. attempt .. ")")
+ return
+ end
+
+ print("Audio device correction (attempt " .. attempt .. "/" .. maxAttempts .. ")")
+ if attempt < maxAttempts then
+ hs.timer.doAfter(delaySecs, check)
+ end
+ end
+
+ check()
+end
+
+-- Keyed off the device, not the machine: only a Mac mini has these, and on
+-- any other machine the check simply never matches.
+local function mute_mac_mini_speakers()
+ local output = hs.audiodevice.defaultOutputDevice()
+ if output and output:name():lower():find("mac mini speakers", 1, true) then
+ output:setVolume(0)
+ print("Muted Mac mini Speakers")
+ end
+end
+
+hs.audiodevice.watcher.setCallback(function(event)
+ if not audioWatcherEnabled then return end
+
+ if event == "dev#" then -- device list changed
+ check_audio_devices()
+ elseif event == "dIn " then -- default input changed
+ set_preferred_input()
+ elseif event == "dOut" then -- default output changed
+ mute_mac_mini_speakers()
+ end
+end)
+hs.audiodevice.watcher.start()
+
+mute_mac_mini_speakers()
+local function show_audio_devices()
+ local function formatDeviceList(devices, currentDevice)
+ local list = {}
+ for _, device in ipairs(devices) do
+ local name = device:name()
+ local isCurrent = (currentDevice and device:uid() == currentDevice:uid())
+ if isCurrent then
+ name = "▶ " .. name
+ end
+ table.insert(list, name)
+ end
+ return table.concat(list, "\n")
+ end
+ local outputList = formatDeviceList(
+ hs.audiodevice.allOutputDevices(),
+ hs.audiodevice.defaultOutputDevice()
+ )
+ local inputList = formatDeviceList(
+ hs.audiodevice.allInputDevices(),
+ hs.audiodevice.defaultInputDevice()
+ )
+ local msg = "Output:\n" .. outputList .. "\n\nInput:\n" .. inputList
+ hs.alert.show(msg)
+end
+local function show_current_audio_devices()
+ check_audio_devices()
+ local input = hs.audiodevice.defaultInputDevice()
+ local output = hs.audiodevice.defaultOutputDevice()
+ local msg = "Input: " .. input:name() .. "\nOutput: " .. output:name()
+ hs.alert.show(msg)
+end
+local function toggle_audio_watcher()
+ audioWatcherEnabled = not audioWatcherEnabled
+ local status = audioWatcherEnabled and "enabled" or "disabled"
+ local emoji = audioWatcherEnabled and "✅" or "🚫"
+ hs.alert.show(emoji .. " Audio watcher " .. status)
+ print("Audio watcher " .. status)
+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'
+ local arg = count
+ local cmd = string.format('%s %s %s', shell, script, arg)
+ return function()
+ local out = hs.execute(cmd, true)
+ 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'))
+---@diagnostic disable-next-line: unused-function
+local function demo_dump(filepath)
+ return function()
+ local file = io.open(filepath, 'r')
+ if not file then
+ print('ERROR: File not found: ' .. filepath)
+ hs.alert.show('File not found: ' .. filepath)
+ return
+ end
+ local text = file:read('*a')
+ file:close()
+
+ type_text(text, {min_delay = 0.005, max_delay = 0.15})()
+ end
+end
+
+local function short(name)
+ local file = io.open('/Users/sz/.cloud/shorts/' .. name, 'r')
+ local text = ''
+ if file then
+ text = file:read('*a')
+ file:close()
+ end
+ return function()
+ hs.eventtap.keyStrokes(text)
+ end
+end
+
+local function isZenBrowser(win)
+ if not win then return false end
+ local app = win:application()
+ if not app then return false end
+ local name = app:name() or ""
+ return name:lower():find("zen") ~= nil
+end
+
+local function window_cycle_screen()
+ local win = hs.window.focusedWindow()
+ if not win then
+ print("No focused window")
+ return
+ end
+ local screen = win:screen()
+ if not screen then
+ print("Window has no screen")
+ return
+ end
+ local nextScreen = screen:next()
+ if not nextScreen then
+ print("No next screen available")
+ return
+ end
+ local wasFullScreen = win:isFullScreen()
+ local isZen = isZenBrowser(win)
+
+ local function doMove()
+ if isZen then
+ local nextScreenFrame = nextScreen:frame()
+ local zenMinWidth = 1536
+ local currentFrame = win:frame()
+
+ if nextScreenFrame.w < zenMinWidth then
+ -- For narrow screens: shrink window first, then move
+ local smallerFrame = hs.geometry.rect(
+ currentFrame.x,
+ currentFrame.y,
+ zenMinWidth,
+ nextScreenFrame.h
+ )
+ win:setFrame(smallerFrame, 0)
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ end)
+ end)
+ else
+ -- For wide screens: move then maximize
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setTopLeft(hs.geometry.point(nextScreenFrame.x, nextScreenFrame.y))
+ hs.timer.doAfter(0.3, function()
+ win:setFrameWithWorkarounds(nextScreenFrame, 0)
+ end)
+ end)
+ end
+ else
+ win:moveToScreen(nextScreen)
+ end
+ end
+
+ if wasFullScreen then
+ win:setFullScreen(false)
+ hs.timer.doAfter(0.6, function()
+ doMove()
+ hs.timer.doAfter(0.6, function()
+ win:setFullScreen(true)
+ end)
+ end)
+ else
+ doMove()
+ end
+end
+
+local function window_resize(layout)
+ return function()
+ local win = hs.window.focusedWindow()
+ if not win then return end
+ if isZenBrowser(win) then
+ -- Use setFrameWithWorkarounds for Zen Browser
+ local screen = win:screen()
+ local screenFrame = screen:frame()
+ local newFrame = hs.geometry.new(layout):fromUnitRect(screenFrame):floor()
+ win:setFrameWithWorkarounds(newFrame, 0)
+ else
+ win:moveToUnit(layout, 0)
+ end
+ end
+end
+
+-- local function tprint(tbl, indent)
+-- if not indent then indent = 0 end
+-- local toprint = string.rep(" ", indent) .. "{\r\n"
+-- indent = indent + 2
+-- for k, v in pairs(tbl) do
+-- toprint = toprint .. string.rep(" ", indent)
+-- if (type(k) == "number") then
+-- toprint = toprint .. "[" .. k .. "] = "
+-- elseif (type(k) == "string") then
+-- toprint = toprint .. k .. "= "
+-- end
+-- if (type(v) == "number") then
+-- toprint = toprint .. v .. ",\r\n"
+-- elseif (type(v) == "string") then
+-- toprint = toprint .. "\"" .. v .. "\",\r\n"
+-- elseif (type(v) == "table") then
+-- toprint = toprint .. tprint(v, indent + 2) .. ",\r\n"
+-- else
+-- toprint = toprint .. "\"" .. tostring(v) .. "\",\r\n"
+-- end
+-- end
+-- toprint = toprint .. string.rep(" ", indent-2) .. "}"
+-- 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'))
+-- hotkey('8', cag, type_uuids(1))
+-- hotkey('8', hyp, type_uuids(10))
+hotkey('8', cag, type_random_ids(1))
+hotkey('8', hyp, type_random_ids(10))
+hotkey('9', csa, type_text('07871793085'))
+hotkey('9', cag, type_text('i@soarez.me'))
+hotkey('a', cag, focus('Alacritty'))
+hotkey('a', csa, toggle_audio_watcher)
+-- hotkey('a', hyp, show_audio_devices)
+hotkey('a', hyp, show_current_audio_devices)
+-- hotkey('b', cag, focus('Safari'))
+-- hotkey('b', hyp, set_default_browser('safari'))
+hotkey('b', cag, focus('Brave Browser'))
+hotkey('b', hyp, set_default_browser('brave browser'))
+hotkey('c', cag, focus('Calendar'))
+hotkey('c', hyp, focus('Claude'))
+hotkey('d', cag, show_current_time)
+hotkey('d', hyp, type_current_date)
+hotkey('f', cag, focusDefaultBrowser)
+hotkey('g', cag, focus('Preview'))
+hotkey('h', cag, show_battery)
+hotkey('h', csa, hammerspoon_reload)
+hotkey('h', hyp, hammerspoon_console)
+hotkey('i', cag, focus('Messages'))
+hotkey('j', cag, focus('IntelliJ IDEA', 'IntelliJ IDEA Ultimate', 'IntelliJ IDEA CE'))
+hotkey('k', cag, focus('Slack'))
+hotkey('k', hyp, toggle('Neovide'))
+hotkey('l', cag, focus('Simulator'))
+hotkey('m', cag, focus('Fastmail'))
+hotkey('n', cag, focus('Obsidian'))
+hotkey('n', hyp, focus('Notes'))
+hotkey('p', hyp, short('song'))
+-- hotkey('q', cag, whisper_cpp)
+-- hotkey('r', cag, focus('Radar'))
+hotkey('s', cag, focus('Spotify'))
+hotkey('s', hyp, focus('Signal'))
+hotkey('t', cag, terminal_increase_opacity)
+hotkey('t', hyp, terminal_decrease_opacity)
+-- hotkey('v', cag, execute('/Users/sz/.bin/vpn ikev2'))
+-- hotkey('v', hyp, execute('/Users/sz/.bin/vpn ssl-dc'))
+-- hotkey('w', csa, execute('/Users/sz/.bin/wifi on'))
+hotkey('w', cag, focus('WhatsApp'))
+-- hotkey('w', hyp, execute('/Users/sz/.bin/wifi off'))
+hotkey('y', cag, focus('YouTube Music'))
+hotkey('y', hyp, function()
+ local content = hs.pasteboard.getContents()
+ if content then
+ type_text(content)()
+ end
+end)
+hotkey('x', cag, type_text('𝕏'))
+hotkey('z', cag, toggle('Zen'))
+-- hotkey('z', hyp, focus('Zed'))
+hotkey('z', hyp, set_default_browser('zen'))
+hotkey(spc, cag, focus('Ghostty'))
+
+local geometry = require("hs.geometry")
+hotkey(spc, cs, window_cycle_screen)
+hotkey('1', ca, window_resize(hs.layout.maximized))
+hotkey('2', ca, window_resize(hs.layout.left50))
+hotkey('3', ca, window_resize(hs.layout.right50))
+hotkey('4', ca, window_resize(hs.layout.left30))
+hotkey('5', ca, window_resize(hs.layout.right70))
+hotkey('6', ca, window_resize(hs.layout.left25))
+hotkey('7', ca, window_resize(hs.layout.right75))
+hotkey('8', ca, window_resize(geometry.rect(0, 0, 0.20, 1)))
+hotkey('9', ca, window_resize(geometry.rect(0.20, 0, 0.80, 1)))
+
+-- hotkey('0', csa, demo_dump('/tmp/screencast0.txt'))
+-- hotkey('1', csa, demo_dump('/tmp/screencast1.txt'))
+-- hotkey('2', csa, demo_dump('/tmp/screencast2.txt'))
+-- hotkey('3', csa, demo_dump('/tmp/screencast3.txt'))
+-- hotkey('4', csa, demo_dump('/tmp/screencast4.txt'))
+-- hotkey('5', csa, demo_dump('/tmp/screencast5.txt'))
+-- hotkey('6', csa, demo_dump('/tmp/screencast6.txt'))
+-- hotkey('7', csa, demo_dump('/tmp/screencast7.txt'))
+-- hotkey('8', csa, demo_dump('/tmp/screencast8.txt'))