Hammerspoon — the best Mac software you’ve never heard of

Rob Howlett
7 min readJul 24, 2017

--

Recently I started a job in a new office, where I like to work from the basement of the building. Frequently though, I’d discover that my wifi was dropping out, only to find that my laptop had connected to the sushi shop next door rather than my own work hotspot. I thought it’d be nice, if rather than having to click on the wifi in the menu bar to check which network I was on, I could just glance at the menu bar where it could be displayed at all times.

I quick Google search failed to dig up any apps that do this. However I did find… Hammerspoon.

Like an actual hammer combined with an actual spoon, it does almost everything you could possibly want. Well, sort of. It can display notifications, move windows, talk to other applications, add things to your menu bar, draw on the screen, watch for changes to your battery, carry out actions when you plug things into your computer. And loads more stuff.

But the best thing is, its config file is a script that you write yourself. There’s no tick boxes and options, it’s all written by you, so you can hook up all the functionality in any way you want.

Let’s take a look…

Oh, before we do, I should quickly mention Karabiner. As soon as I’d installed Hammerspoon, I also installed Karabiner Elements (Download from https://pqrs.org/latest/karabiner-elements-latest.dmg). As a long time hater of the Caps Lock key, once of the first things I do when I get a new Mac is disable it:

You can still type capital letters without a caps lock key — I keep discovering, to my amazement, people that don’t know that you can hold down the shift key to type capital letters!!!

With Karabiner, I can actually make the Caps Lock key do something useful. Karabiner allows you to fiddle around with your keyboard mappings — I set Cap Lock to be the same as pressing Cmd-Ctrl-Shift-Alt.

Now we can starting setting up some stuff in Hammerspoon. Click its menu icon and select “Open Config”. The file should open in your preferred text editor. Let’s set our newly mapped Caps Lock key up as a variable as we’ll be using it a lot:

hyper = {"ctrl", "alt", "cmd", "shift"}

And now using one of the examples from the Hammerspoon documentation, we can set up a keyboard combination for a fancy type of paste:

hs.hotkey.bind(hyper, "v", function()hs.eventtap.keyStrokes(hs.pasteboard.getContents()) end)

Save the file and selected “Reload Config” from the Hammerspoon menu.

Caps Lock-v will now “type” out whatever’s in the clipboard. This defeats those websites that don’t allow you to paste your password. It’s also handy for pasting text without the formatting, something that some applications won’t let you do with a keyboard shortcut at all and others require you to use the finger-mangling combination of Cmd-Alt-Shift-V.

What about my original use case? I wanted to display my wifi network name in the menu bar:

local wifiMenu = hs.menubar.new()function ssidChangedCallback()
SSID = hs.wifi.currentNetwork()
if SSID == nil then
SSID = "Disconnected"
end
wifiMenu:setTitle("(" .. SSID .. ")" )
end
wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
wifiWatcher:start()
ssidChangedCallback()

Simple! Now we have the name of our current wifi network shown in brackets in the menu.

I extended this bit of code further with an “if” clause to determine if I was connected to my home network or not. If I am, then it runs:

hs.audiodevice.defaultOutputDevice():setMuted(false)
hs.caffeinate.set("displayIdle", true, true)

This un-mutes my speaker and sets the screen to not go to sleep. I also have code to do the opposite if I’m not at home. I previously used an application specifically to do this — I’ve now uninstalled it.

I also had an application to allow me to resize my application windows with keyboard shortcuts. I’ve uninstalled that too:

function move_window(direction)
return function()
local win = hs.window.focusedWindow()
local app = win:application()
local app_name = app:name()
local f = win:frame()
local screen = win:screen()
local max = screen:frame()
if direction == "left" then
f.x = max.x + 6
f.w = (max.w / 2) - 9
elseif direction == "right" then
f.x = (max.x + (max.w / 2)) + 3
f.w = (max.w / 2) - 9
elseif direction == "up" then
f.x = max.x + 6
f.w = max.w - 12
elseif direction == "down" then
f.x = (max.x + (max.w / 8)) + 6
f.w = (max.w * 3 / 4) - 12
end
f.y = max.y + 6
f.h = max.h - 12
win:setFrame(f, 0.0)
end
end
hs.hotkey.bind(hyper, "Left", move_window("left"))
hs.hotkey.bind(hyper, "Right", move_window("right"))
hs.hotkey.bind(hyper, "Up", move_window("up"))
hs.hotkey.bind(hyper, "Down", move_window("down"))

(This code was very heavily(!) influenced by Oliver Andrich’s blog post).

Using the Caps Lock key in combination with the cursor keys, we can get a window to occupy the left or right hand halves of the screen, go full screen or centred and three-quarter width.

Once I got this far, I got carried away. The great thing with Hammerspoon was that every time I thought “wouldn’t it be cool if my Mac did…”, I could make it do happen.

“Wouldn’t it be cool if my Mac let me know if my network connection is working”

I commute a lot and I tether to my iPhone. When a website fails to work, I have to get my phone out of my pocket to check my signal, or go to a random website to see if that loads. Now I can ping Google’s DNS server and get feedback on ping times at the press of button (Caps Lock-p).

function pingResult(object, message, seqnum, error)
if message == "didFinish" then
avg = tonumber(string.match(object:summary(), '/(%d+.%d+)/'))
if avg == 0.0 then
hs.alert.show("No network")
elseif avg < 200.0 then
hs.alert.show("Network good (" .. avg .. "ms)")
elseif avg < 500.0 then
hs.alert.show("Network poor(" .. avg .. "ms)")
else
hs.alert.show("Network bad(" .. avg .. "ms)")
end
end
end
hs.hotkey.bind(hyper, "p", function()hs.network.ping.ping("8.8.8.8", 1, 0.01, 1.0, "any", pingResult)end)

“Wouldn’t it be cool if my Mac let me know when I’ve accidentally stolen someone else’s power supply”

function batteryChangedCallback()
psuSerial = hs.battery.psuSerial()
if psuSerial ~= 5848276 and psuSerial ~=0 and psuSerial ~= lastPsuSerial then
hs.alert.show("That's not your power supply!")
end
lastPsuSerial = psuSerial
end
lastPsuSerial = 9999999
batteryWatcher = hs.battery.watcher.new(batteryChangedCallback)
batteryWatcher:start()

“Wouldn’t it be cool my Mac could tile two application windows and minimise the rest”

Quite often I will be comparing information in two different tabs in Chrome. I’ll separate one tab into it’s own new window and then I want to put the two windows side by side. Now I can with Caps Lock-2:

function layoutWindows(layoutType)
return function()
if layoutType == 2 then
windowLayoutObject = hs.window.layout.new({hs.window.filter.new(),"move 1 foc [50,0,100,100] 0,0 | move 1 foc [0,0,50,100] 0,0 | min"})
end
hs.window.layout.applyLayout(windowLayoutObject)
end
end
hs.hotkey.bind(hyper, "2", layoutWindows(2))

In my own config, I actually have some other layouts set up — hence the seeming pointless “if” clause.

The window layout syntax isn’t the prettiest — that’s definitely one to check the documentation for.

“Wouldn’t it be cool if my Mac let me turn on and off the microphone, displayed the status of the microphone in the menu bar and put a coloured border around the screen so that I can easily see what state it’s in — oh, and it should update correctly when I plug in my headset”

micMuteStatusMenu = hs.menubar.new()
micMuteStatusLine = nil
function displayMicMuteStatus()
local currentAudioInput = hs.audiodevice.current(true)
local currentAudioInputObject = hs.audiodevice.findInputByUID(currentAudioInput.uid)
muted = currentAudioInputObject:inputMuted()
if muted then
micMuteStatusMenu:setIcon(os.getenv("HOME") .. "/.hammerspoon/muted.png")
micMuteStatusLineColor = {["red"]=1,["blue"]=0,["green"]=0,["alpha"]=0.7}
else
micMuteStatusMenu:setIcon(os.getenv("HOME") .. "/.hammerspoon/unmuted.png")
micMuteStatusLineColor = {["red"]=1,["blue"]=0,["green"]=1,["alpha"]=0.7}
end
if micMuteStatusLine then
micMuteStatusLine:delete()
end
max = hs.screen.primaryScreen():fullFrame()
micMuteStatusLine = hs.drawing.rectangle(hs.geometry.rect(max.x, max.y, max.w, max.h))
micMuteStatusLine:setStrokeColor(micMuteStatusLineColor)
micMuteStatusLine:setFillColor(micMuteStatusLineColor)
micMuteStatusLine:setFill(false)
micMuteStatusLine:setStrokeWidth(30)
micMuteStatusLine:show()
end
for i,dev in ipairs(hs.audiodevice.allInputDevices()) do
dev:watcherCallback(displayMicMuteStatus):watcherStart()
end
function toggleMicMuteStatus()
local currentAudioInput = hs.audiodevice.current(true)
local currentAudioInputObject = hs.audiodevice.findInputByUID(currentAudioInput.uid)
currentAudioInputObject:setInputMuted(not muted)
displayMicMuteStatus()
end
displayMicMuteStatus()
hs.hotkey.bind(hyper, "m", toggleMicMuteStatus)
micMuteStatusMenu:setClickCallback(toggleMicMuteStatus)
function toggleMicMuteStatusAlert()
if micMuteStatusAlert then
micMuteStatusAlert = false
micMuteStatusLine:delete()
else
micMuteStatusAlert = true
displayMicMuteStatus()
end
end

“Um, wouldn’t it be cool if I could make the big border around my screen disappear again”

Ah yes, yes it would:

function clearScreen()
if micMuteStatusLine then
micMuteStatusLine:delete()
end
end
hs.hotkey.bind(hyper, "c", clearScreen)

Hopefully this has been a nice quick introduction to Hammerspoon with some examples that you can copy. I love it and my config file will grow as I come across more use cases.

If only you didn’t have to reload the Hammerspoon config each time you make changes to it…

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
myWatcher = hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig):start()
hs.alert.show("Config loaded")

--

--