-- 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) -- 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 -- Caps Lock sends F20 from here on: dotlib does the HID-level remap and keeps -- re-applying it, including for keyboards attached later. Nothing is bound to -- f20 by default -- bind it below or in a machine's local.lua, the same as any -- other key. A machine that wants its Caps Lock back calls -- require("dotlib").capslock_off() from its local.lua, which loads last. -- -- Early, so the keyboard behaves the same for the rest of this file's startup, -- but deliberately after the dotlib guard above: a config that failed to load -- has no bindings either, and a remapped key with nothing bound to it is worse -- than a working Caps Lock. lib.capslock_f20() -- The message port behind the `hs` CLI, so this config can be queried and -- driven from a shell -- `hs -c 'hs.hid.capslock.get()'`. Without it there is -- no way to check what Hammerspoon thinks from outside Hammerspoon. require("hs.ipc") 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 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_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 -- 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 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')) -- Machine-local Hammerspoon config, where a machine has any: -- ~/.config/hammerspoon/local.lua, a real file beside the init.lua link and in -- no repo, like ~/.localprofile for the shell. Only init.lua is linked, so that -- directory is the machine's own and anything else in it stays there. -- -- Last, so it can override the hotkeys and settings above, and in a pcall: a -- mistake in it must not take the rest of this config down with it. Reload with -- ctrl-shift-alt-h, the same as any other change here. local local_config = os.getenv("HOME") .. "/.config/hammerspoon/local.lua" if hs.fs.attributes(local_config) then local ok, err = pcall(dofile, local_config) if not ok then print("local.lua: " .. tostring(err)) hs.notify.new({ title = "Hammerspoon", informativeText = "local.lua failed: " .. tostring(err), }):send() end end