Setting up a global leader key for macOS using Hammerspoon

Make your keyboard your own

Nethum Lamahewage
8 min readMay 10, 2021

Introduction

Photo by Lukas Hellebrand on Unsplash

I've been using macOS for a little over a year now. Before that, I had only ever known Windows. Once I moved to macOS, it took quite some time to get used to it. I do still occasionally boot into Windows (using Bootcamp), but it's only when I need to use some software that doesn't run on macOS.

At first, I used macOS much like I used to use Windows. However, since I’m doing a CS degree, I needed to get familiar with programming related tools, and that’s how I found Vim. I found it interesting and gave it a shot. I won't go into what that was like here, as there are countless people on the internet who have told their stories of learning Vim. Eventually, I gained an interest in configuring my system to suit me better and match my workflow, and along came Hammerspoon.

Hammerspoon

What is Hammerspoon?

Simply put, it is an automation tool running on Lua. It has numerous APIs for macOS functionality, and with it, you can control pretty much whatever you want to. Basically, you're only limited by your imagination (and your skill at writing the relevant code).

If you’re already familiar with Hammerspoon, you can skip this section.

Installing

Install using either Homebrew with the command brew install hammerspoon or download from their GitHub repository.

Configuration

  • Create a ~/.hammerspoon folder and create a file init.lua in it
  • This is the starting point of your Hammerspoon config. You can split your config into separate files (and you should, if you do any serious configuring), and then use the require function to import them
  • To configure Hammerspoon, a basic knowledge of lua is required to start with. If you’re already familiar with any other programming languages, then a quick run through of the basics of lua and the syntax should be enough, as it is easy to get started with lua. This tutorial should suffice for that. For those not familiar with programming at all, Lua shouldn’t be too difficult to learn (at least to learn what you will need to know to follow along with the rest of this article).

Some basic configuration

Basic Hammerspoon configuration

Here, hs is a globally available object, through which you access the Hammerspoon API. hs.hotkey.bind (docs) takes a table of modifiers, a key, and a function to call when that key binding is pressed.

Click on the Hammerspoon icon on the menubar and click on “Reload config”. After that, you can use the shortcut ⌘+⌥+^+h to reload the Hammerspoon config (here, alt means option).

Now, when the Wi-Fi network you’re connected to changes, you will receive a notification from Hammerspoon.

More information:

You can go to Hammerspoon's Getting Started Guide to see some more examples. And if you want to know more about any part of the API, you will find that it is very well documented.

You can also find 'Spoons', which are basically plugins which provide additional functionality, at this link. And you can find many resources out there about setting up various things using Hammerspoon.
Now let's jump into the RecursiveBinder Spoon.

RecursiveBinder Spoon

About

When I first started configuring Hammerspoon, I set up a hyper key, and added a few key bindings. But I soon hit a roadblock where I was using up all the keys (or at least the most easily accessible ones)
It wasn't that long since I had been introduced to Vim, and I was getting comfortable with the leader key system. It turned out that there was a Spoon for Hammerspoon called RecursiveBinder that could do the same thing.

After setting it up and configuring it, this is what my main RecursiveBinder helper text looks like today:

My current helper text

As you might guess, there are some levels of nesting in there.

Installing

The spoon can be downloaded from here.

Copy it into ~/.hammerspoon/Spoons. The ~/.hammerspoon directory should now look something like this:

~/.hammerspoon directory structure

Configuring

singleKey is a convenience function used to easily create a table representing a key binding with no modifiers, and also automatically translate capital letters to normal letters with shift modifier. For example, singleKey('o', 'open') returns {{}, 'o', 'open'}, and singleKey('O', 'open') returns {{'shift'}, 'o', 'open'}

Here is a simple config for RecursiveBinder

Simple configuration for RecursiveBinder
  • First load the spoon using hs.loadSpoon (docs)
  • RecursiveBinder.escapeKey is the key binding used to abort
  • The next line is just for convenience, so that I can use singleKey without having to type out spoon.RecursiveBinder.singleKey every time
  • Next, I create a table of key bindings. The first two are to open the browser and the terminal respectively, and the next set is a nested group.
  • Pressing ⌥+space will trigger RecursiveBinder. Helper text will pop up at the bottom of your screen with the ‘browser’, ‘terminal’, and ‘domain+’ key bindings
  • Pressing ‘b’ or ‘t’ will call the functions provided, and open Firefox and Terminal respectively (and also dismiss the helper text)
  • Pressing ‘d’ will enter the next layer, and the helper will change to show the ‘g’ and ‘y’ key bindings, and pressing one of those will call the corresponding functions, and open GitHub and YouTube respectively (in your default browser)

The helper text can be styled as well

Styling the RecursiveBinder helperText

Refer to the hs.alert.defaultStyle documentation for general styling, and hs.styledtext (docs) for text styling

Leader key

Finally, we have reached the main part of this article.

Loading from config.json

  • To make later configuration simpler, I set it up so that it loads as much of the config as possible from an easily editable JSON file, using hs.json (docs).
  • The config.json file is in the private folder, which is where personal aspects of the config are stored. This way, you can separate those from the main configuration (especially useful if you want to upload your Hammerspoon config somewhere, without uploading your personal preferences for that config).
local config = hs.json.read("private/config.json")

This is the format of the config.json for the Hammerspoon config I will be explaining. You can add more to the lists of applications, domains, and notes according to your needs.

If your config.json is getting too big, it might be a good idea to convert it into a different file type, such as YAML (as it is easier to read/write). I’ll leave that as an exercise for the reader (partly because I haven’t done that yet either, though I do intend to). As a starting point, you may want to look into this.

Applications & Domains key maps

  • Here, I'm iterating through the list of applications in my config, and adding them to the key map one by one. For this, I can use a function in Hammerspoon called hs.fnutils.each(docs). It takes in a table and a function, which will be called for each element in the table
  • For each application, I'm assigning the corresponding key and a function that will launch it using Hammerspoon's hs.application.launchOrFocusByBundleID (docs) (you can use hs.application.launchOrFocus instead, if you prefer)
  • If you want to find the bundle ID of an application, the following line of AppleScript will return it: id of app 'Firefox' (just replace Firefox with the application name, as it appears in your Applications folder). You can also run this in a shell like this:
osascript -e "id of app 'Firefox'"

If you’re wondering what AppleScript is, here.

The following lua code will add the applications and the domains to two separate lua tables. The code to build the key map for domains is structurally similar, as you can see.

Generating key maps for applications and domains

If you looked at the config above, you may have noticed the notes section. I also set up a key map to open those notes in the browser. I think the format of the config is self-explanatory, so let’s dive into the actual lua code.

Generating key map for notes

This one is more complicated, but I'm including it to show you just how much you can achieve with this.
Let’s go through it part by part.

  • All of my notes are in a folder called notes_html in my $HOME folder (aka ~/), and I've categorized some into sub-folders. For example, there is a sub-folder named programming, with separate notes for each programming language (you can skip this section if you don’t have your notes in a similar format).
  • generate is a recursive function that is called on the notes section of the config. It iterates over the list provided, and for each element, it does one of two things.
  • If it is a sub-folder (a simple way to check this is to check for the contents attribute), then it calls the function again for that folder's list of entries(files or folders), and assigns it to the corresponding key in the key map
  • If it is a file, then it just assigns the corresponding key in the key map and attaches the function to open the note
  • For any programmers reading, the idea is similar to a depth first search of a tree
  • To open the note, I'm using the hs.urlevent.openURL function. They are all HTML files, so they are automatically opened in my default browser
  • While recursively going through the notes, I'm also passing along the current path when calling the function and in the case of a sub-folder appending it to the end of the path
  • Now to use this, you don't really need to understand all of this. Just set all of it in the config.json, making sure to set the correct config.notes.rootPath as well.
Putting it all together

Here, I've also included a couple of key bindings for Hammerspoon. One to reload the config, and the other to open it in VS Code

Bonus

  • If you used this, you may have noticed that the order of the keys in the helper text is not consistent. To fix this, I added some more code to sort the helper text before showing.
  • The following code is to be added to RecursiveBinder.spoon/init.lua
  • Not much needs to change. A function called compareLetters is added, and, the beginning of the for loop(in showHelper) and the part just before it are changed as shown.

To cleanly integrate this into RecursiveBinder, much more changes are required, but for now, this works for me.

Conclusion

OK, time for some closing words.

I’ve been using Hammerspoon for less than a year, and so far, I am beyond impressed. The power it brings is just amazing, and there’s so much you can do with it. Like I said in the beginning, you are only limited by your imagination. On top of all that, it’s an open source project.

If you’re looking for ideas on how to improve your Hammerspoon configuration, look at some sample configurations. Even just browsing through the Hammerspoon API should give you some ideas of what you might want to do.

--

--