Now, on my Mac laptop, if I am clocked into a task, shutting my laptop, clocks out of the task before going to sleep.
13 KiB
Hammerspoon Configuration
A literate programming file for configuring Hammerspoon.
Introduction
Ever since I got a Mac, I’ve used various tools to script it. With my limited number of applications, my UI needs are simple (I mean, what more do you need once Emacs is in full screen). That said, I’ve been using Hammerspoon for those few needs. While I refer to the standard documentation, I often steal snippets of code from others.
Simple to use. Use hs.execute to run a script, ha.alert.show to print a small message on the screen, and for something longer:
hs.notify.new({title="Hammerspoon", informativeText="Hello World"}):send()
The Command Line
The hs binary talks to a running Hammerspoon over a Mach port, and that port only exists while the hs.ipc module is loaded. Without this line, the tool answers every request with can’t access Hammerspoon message port, however well it is installed:
require("hs.ipc")
For instance, using the Zoom spoon, I can toggle the mute button from a script:
hs -c "spoon.Zoom:toggleMute()" # your Zoom spoon, from anywhere
Or callable from Emacs.
Other ideas:
- Tail the Hammerspoon console in a terminal with
hs -C, which shows anything called withprintf. -
While I already built out a notification system, I could use Hammerspoon to replace
beepCLI:make build; hs -c 'hs.notify.new({title="Build", informativeText="done"}):send()' -
Query Hammerspoon to query the system:
hs -c 'return hs.wifi.currentNetwork()'This should return
iTerm2, orEmacswhen running from my literate config:hs -c "return hs.application.frontmostApplication():name()"Or screwing with my monitors:
hs -c "return hs.screen.mainScreen():name()"Or the volume level as a float value:
hs -c "return hs.audiodevice.defaultOutputDevice():volume()"
Flags worth knowing, from the binary's usage text:
-iinteractive REPL-qquiet (only the result, ideal for scripting)-crepeatable-Aauto-launch Hammerspoon if not running, and a bare file path to run a.luafile.
Shortcut Keybindings
I’ve created left and right Meh keys on my Moonlander keyboard:
- Left Meh Key:
C-M-S-s(Control Option/Meta Command Shift) - Right Meh Key:
C-M-S(Control Option/Meta Shift)
To create specific key bindings for starting applications, I can:
----------------------------------------------------------------------
-- Launcher replaces iCanHazShortcuts
hs.hotkey.bind({"alt", "ctrl", "shift"}, "T", function()
hs.application.launchOrFocus("iTerm")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "S", function()
hs.application.launchOrFocus("Slack")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "F", function()
hs.application.launchOrFocus("Vivaldi")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "C", function()
-- hs.osascript.applescriptFromFile("~/bin/chrome.scr")
hs.execute("~/bin/chrome.scr")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "B", function()
hs.application.launchOrFocus("Microsoft Outlook")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "Z", function()
hs.application.launchOrFocus("zoom.us")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "Q", function()
hs.application.launchOrFocus("KeepassXC")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "G", function()
hs.application.launchOrFocus("Ghostty")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "W", function()
hs.application.launchOrFocus("VLC")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "E", function()
hs.execute("FOR_WORK=yes open -a /Applications/Emacs.app")
end)
-- Special Emacs Guys
-- Right Meh key:
hs.hotkey.bind({"alt", "ctrl", "shift"}, "X", function()
hs.execute("~/bin/emacs-capture")
end)
-- Left Meh key:
hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "X", function()
hs.execute("~/bin/emacs-capture-clock")
end)
hs.hotkey.bind({"alt", "ctrl", "shift"}, "M", function()
hs.execute("~/bin/emacs-capture-meeting")
end)
-- Current music system is actually in Emacs:
hs.hotkey.bind({"alt", "ctrl", "shift"}, "R", function()
hs.execute("/opt/homebrew/bin/emacsclient -s work -e '(ready-player-toggle-play-stop)'")
end)
Zoom
Spoons are library extensions to Hammerspoon: usally small Lua scripts, like init.lua, stored in the Spoons subdirectory. Installable from a git clone, typically. To use the Zoom spoon, clone it:
git clone https://github.com/jpf/Zoom.spoon.git
I noticed that this library does most of its work by calling Zoom’s menus, and with the latest version of Zoom, they changed the case of the menu, meaning that I needed to create a pull request.
From this nice essay, we create a menu bar item that shows the status, as well as allowing me to click it to toggle:
zoomStatusMenuBarItem = hs.menubar.new(true)
zoomStatusMenuBarItem:setClickCallback(function()
spoon.Zoom:toggleMute()
end)
updateZoomStatus = function(event)
hs.printf("updateZoomStatus(%s)", event)
if (event == "from-running-to-meeting") then
zoomStatusMenuBarItem:returnToMenuBar()
elseif (event == "muted") then
zoomStatusMenuBarItem:setTitle("🔴")
elseif (event == "unmuted") then
zoomStatusMenuBarItem:setTitle("🟢")
elseif (event == "from-meeting-to-running") then
zoomStatusMenuBarItem:removeFromMenuBar()
end
end
Now we can load, instantiate it, as well as create a callback loop to call updateZoomStatus:
hs.loadSpoon("Zoom")
spoon.Zoom:setStatusCallback(updateZoomStatus)
spoon.Zoom:start()
And bind a key to the mute ability that works good for both keyboards:
hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "M", function()
spoon.Zoom:toggleMute()
end)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "M", function()
spoon.Zoom:toggleMute()
end)
Lovely bit of code and shows the true power of Hammerspoon to fix the various app issues.
Automatically Adjust Volume
Leaving my home office and hopping on the train can be socially awkward when my laptop suddenly screams, so if I leave my home, I just readjust the volume and turn it off:
----------------------------------------------------------------------
-- When leaving house, turn off the volume:
wifiWatcher = nil
homeSSID = "Intertubes"
workSSID = "workdaysecure"
hotspotSSID = "Pixie Dust"
lastSSID = hs.wifi.currentNetwork()
function ssidChangedCallback()
newSSID = hs.wifi.currentNetwork()
if newSSID == homeSSID and lastSSID ~= homeSSID then
-- joined our home WiFi network
hs.audiodevice.defaultOutputDevice():setVolume(25)
elseif newSSID == workSSID and lastSSID ~= workSSID then
-- joined our work WiFi network
hs.audiodevice.defaultOutputDevice():setVolume(0)
elseif newSSID == hotspotSSID and lastSSID ~= hotspotSSID then
-- joined our hotspot WiFi network
hs.audiodevice.defaultOutputDevice():setVolume(0)
elseif newSSID ~= homeSSID and lastSSID == homeSSID then
-- departed our home WiFi network
hs.audiodevice.defaultOutputDevice():setVolume(0)
end
lastSSID = newSSID
end
wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
wifiWatcher:start()
Why yes, I’m looking for features to use Hammerspoon.
Clocking out a Task
I want to use Org’s task clocking ability, but I often forget to clock out. This code allows me to clock out whenever my computer goes to sleep … which works well when closing the laptop lid:
function sleepWatch(eventType)
if (eventType == hs.caffeinate.watcher.systemWillSleep) then
if hs.application.find("Emacs") then
hs.execute("/opt/homebrew/bin/emacsclient --socket work --eval '(ha-clock-out)'")
end
end
end
sleepWatcher = hs.caffeinate.watcher.new(sleepWatch)
sleepWatcher:start()
Discovered a subtle bug, solving it meant assigning the watcher to a variable, rather than starting it and dropping the value. For instance, if I call the start this way:
hs.caffeinate.watcher.new(sleepWatch):start()
Since nothing else refers to that object once the line finishes, Lua may collect it, and a collected watcher stops delivering events without a word about it.
Note if I want to do something for Wake:
if (eventType == hs.caffeinate.watcher.systemDidWake) then
...
end
Oh, and here is a helper for kanata to put the display to sleep:
hs.hotkey.bind({}, "F16", function()
hs.execute("pmset displaysleepnow")
end)
Monitors
My company gave me a nice monitor … maybe a little too nice, as I don’t care to shift my neck to the extreme sides (serious first-world problem), so here I can center a window Keeping it my field of view:
----------------------------------------------------------------------
-- Centering a window on the large monitors at Work:
function centerWindow()
local win = hs.window.focusedWindow()
if not win then return end -- Safety check in case no window is focused
local app = win:application()
local screen = hs.screen.find("DELL P3424WE")
local maxBounds = screen:frame() -- This gets the absolute coordinates of that specific monitor
-- 2. Define your desired size
local desiredW = 2200
local desiredH = 1470
if app then
local name = app:name()
if name == "Slack" or name == "iTerm2" then
desiredH = 1200
end
end
-- 3. Calculate relative position based on the external monitor's bounds
-- This centers the window perfectly on the target screen
local f = {
x = maxBounds.x + ((maxBounds.w - desiredW) / 2),
y = maxBounds.y, -- + ((maxBounds.h - desiredH) / 2),
w = desiredW,
h = desiredH
}
win:setFrame(f)
hs.alert.show("Centered Window")
end
hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "Y", centerWindow)
Auto Reload Configuration
Whenever my configuration file is altered (or with a Meh-R key), I reload the configuration:
----------------------------------------------------------------------
-- Automatically reload the Hammerspoon configuration
hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "R", function()
hs.reload()
hs.alert.show("Reloaded Hammerspoon Config")
end)
function reloadConfig(files)
doReload = false
for _,file in pairs(files) do
if file:sub(-4) == ".lua" then
doReload = true
end
end
if doReload then
hs.reload()
end
end
configWatcher = hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig)
configWatcher:start()
Every watcher in this file now lives in a variable of its own, for the reason described under clocking out. This one failing is the quietest of the three, as a dead configuration watcher looks exactly like a file that saved without incident.