Useful Hammerspoon Tips

Posted on May 5, 2021

Hammerspoon is a macOS automation framework that allows you to hook into all sort of OS interfaces using Lua scripts. People use it for all sorts of automations, with key remappings and quick window switchers being the most common applications. There are some crazier applications like using voice to control scroll bars! I’m going to describe some ways I use it that are uncommon.

Reducing procrastination

I often run build steps or unit tests that are slow enough that I can’t just twiddle my thumbs at the terminal, but fast enough that I can’t get into another cognitive task. The usual way I respond is to continue reading the next article on my reading list. There is a danger of getting distracted and continuing to read even when the tests are done. I’ve found that my brain is not great at noticing desktop notifications, since they tend to be in the top-right corner of a large display. My solution is to use Hammerspoon to invert the entire display’s colors and keep “flashing” the display until I switch back to the terminal window. This works really well. It is triggered by some shell aliases that I append to the end of the command.

For example, I can run:

bazel build //something ; nag_screen

and my screen will start flashing once the build ends (regardless of success/failure), until I switch to the terminal.

Here is how we hook this up in the Hammerspoon init.lua.

Imports

require("hs.screen")
require("hs.timer")
require("hs.window")
require("hs.window.filter")

Useful functions

function invertScreen()
    hs.screen.setInvertedPolarity(true)
end

function normalScreen()
    hs.screen.setInvertedPolarity(false)
end

function nagScreen()
    -- start nagging
    local toggled = false
    local timer = hs.timer.doEvery(1, function()
        if toggled then
            normalScreen()
        else
            invertScreen()
        end
        toggled = not toggled
    end)

    local wf = hs.window.filter
    termWindow = wf.new("Terminal")
    termWindow:subscribe(wf.windowFocused, function()
        termWindow:unsubscribeAll()
        timer:stop()
        -- restore regardless of previous state.
        normalScreen()
    end)
end

The nagScreen() function starts flashing (inverting colors every 1 second) the screen as soon as it is called. Then it subscribes to OS events around which window is focused, and when any Terminal.app window is focused, it stops the flashing.

Trigger

alias nag_screen='hs -A -c "nagScreen()"'

This uses the Hammerspoon CLI hs to call the function when the alias is run.

Fast hyperlinking

We used JIRA at Dropbox, and I always preferred to have JIRA issue identifiers actually be links to the issue when possible. While JIRA is smart enough to do this within itself (i.e. if you have PROJ-1234 as the ticket, JIRA will link to it), other applications are not.

Of course, one could always copy paste the issue link instead of just the identifier, but the latter often looks cleaner. Plus JIRA has this annoying tendency to use some other URL when showing some issue, and it is easier to just type out the identifier in those cases.

With Hammerspoon, I was able to do the following: 1. Type out or paste the JIRA identifier. 2. Select it. 3. Press a hotkey.

This would replace the selected text, with a link! Apps like Slack and Dropbox Paper are smart enough to treat this replacement as a linking action, since it is accomplished using a regular “paste” as if the user had pressed Cmd+V themselves.

-- Copied from https://github.com/Hammerspoon/Spoons/blob/4224cddc344198e086715a7c24983f90ec0f32fc/Source/PopupTranslateSelection.spoon/init.lua
function currentSelection()
   local elem=hs.uielement.focusedElement()
   local sel=nil
   if elem then
      sel=elem:selectedText()
   end
   if (not sel) or (sel == "") then
      hs.eventtap.keyStroke({"cmd"}, "c")
      hs.timer.usleep(20000)
      sel=hs.pasteboard.getContents()
   end
   return (sel or "")
end

function jiraToJiraLink()
    task_id = currentSelection()
    hs.pasteboard.setContents("https://myjiradomain.com/browse/" .. task_id)
    hs.timer.usleep(20000)
    hs.eventtap.keyStroke({"cmd"}, "v")
end

hs.hotkey.bind({'cmd', 'ctrl'}, 'j', jiraToJiraLink)

This uses the accessibility API to retrieve the currently selected text, but if that doesn’t work, it just simulates pressing Cmd+C to invoke a copy. The short sleep is to allow the copy to propagate through the system and transfer the contents to the clipboard.

The Cmd+Ctrl+J binding simply saves the current selected text, and replaces it with a JIRA URL prefix.

This is actually a very flexible set up where you could specify multiple bindings to do different actions to the selection. Or you could pattern match on the selection itself to do something specific. For example, you could detect a GitHub username/repo pattern and map it to a GitHub URL, or identify some kind of CI job and actually make a request to the CI server to check for the job status. There are all kinds of possibilities.

Zoom Push-to-talk

When using Zoom, one can stay muted, and then hold down Space to temporarily unmute. However, this only works when the Zoom app is in focus. This can get annoying when you are sharing your screen, or doing something in another app while you want to talk. This tip uses Hammerspoon to globally mute the system microphone, and enable it when a particular key is held down, regardless of which app you are in. This way, you always leave Zoom unmuted, but you are still muted because the microphone is off. When you want to speak, just hold down the specific key the entire time you are speaking. This is Push To Talk (PTT).

This is accomplished using the PushToTalk spoon and some config.

Spoon installation

First, download the spoon and install it. Then load it by adding this to your config.

hs.loadSpoon("PushToTalk")

Configuration and key-binding

spoon.PushToTalk.detect_on_start = true
spoon.PushToTalk.app_switcher = {
    ['zoom.us'] = 'push-to-talk'
}
spoon.PushToTalk:start()

This sets up PushToTalk to detect the running apps when it is first loaded. This way if Hammerspoon were to start after Zoom, it would still correctly set up. Then it sets the list of apps where “push to talk” should apply. The Spoon has 4 modes

  1. muted - Microphone is completely switched off, and PTT does not happen even if you hold down the relevant key.
  2. unmuted - The microphone is unmuted. PTT is not relevant.
  3. push-to-talk - The microphone is muted, but becomes unmuted as long as the PTT key is held down.
  4. release-to-talk - The microphone is unmuted, but stays muted as long as the PTT key is held down. This can be useful in meetings where you are the primary speaker.

By default the spoon starts in muted. The app_switcher config tells it to switch to push-to-talk state when Zoom is running.

By default the PushToTalk spoon uses the Fn key on Mac as the PTT key. This is useful because it usually isn’t used anywhere else, and is available in a very distinct spot on Apple keyboards, where it is easy to hit.

When using an external keyboard without an Fn key, you will require some remapping. In macOS Catalina and earlier, I was using Karabiner Elements, but I’ve had problems with it on Big Sur, and just use hidutil instead. This useful website allows you to generate a remapping config file. I map a high function key (like F14) to Fn. For my Ergodox EZ, I customized my layout to map one of the keys to F14 (and have it specially lit so it always stands out!). Then I used the hidutil config to map F14 to Fn.

The Spoon also provides a useful macOS menubar entry reflecting the current microphone state, and allowing you to quickly change the state.