Hammerspoon: A Better, Better Hyper Key

hammerspoon logo

I use Hammerspoon to enhance my OSX with custom UI and automations, while also creating "monotasking" deep work environments to keep me focused.

This all started with Hyper. I talked in the last post about my history with the concept, how I learned from Steve Losh's post on the topic and borrowed from Brett Terpstra… and I've expanded the idea a bit. At the moment, my hyper implementation is contained in a lua module called hyper.lua, with some dependencies on Karabiner-Elements.app.

I'm using hs.hotkey.modal to capture an F19 keystroke, and only sending the "hyper chord" of ⌘⌥⇧⌃ if absolutely required. The code isn't that complex so this post will be focused on the advantages of this approach.

Before: hyper chord

Traditionally, a Hyper key is implemented by sending to the Operating System "hyper chord" of ⌘⌥⇧⌃ by modifying the keyboard firmware or using Karabiner-elements.app.

The user would then use some kind of automation software like Alfred or Keyboard Maestro to listen for the "hyper chord" and fire different automations. You can absolutely do this in Hammerspoon if you want.

While it works well, it has its limitations. Using the "hyper chord" as the entire "hyper key", you can't add any more modifiers, because it is already all the modifiers. Because of this a lot of hyper key setups are limited to "leader key" style interactions.

After: hs.hotkey.modal

Using a single keycode as your "hyper" key, and handling the translation at the automation layer is much more expressive. In my case, Hammerspoon becomes a single "router" to all the automation and UI customization on my Mac.

I use a single often-unused key (in my case, F19) to trigger a hs.hotkey.modal in Hammerspoon. Instead of having every single application listening to all the keystrokes, I can control it one place. In this as in all things, I am not the first. Brett Terpstra first wrote about this in "A Useful Caps Lock Key" in 2012.

Hyper… shifted?

One big advantage to using Hammerspoon as a "man-in-the-middle" is using modifiers with your hyper key. Because your "hyper key" is not a cluster of modifier keys, you can actually use it in conjunction with any normal modifiers.

-- Press `HYPER+r`, get the Hammerspoon console.
hyper:bind({}, 'r', nil, function()
  hs.console.hswindow():focus()
end)

-- Because my `HYPER` is actually F19,
-- I can press `HYPER+SHIFT+R`
--
-- Press `HYPER+⇧+R`, reload Hammerspoon configuration.
hyper:bind({'shift'}, 'r', nil, function()
  hs.reload()
end)

(from init.lua)

I presently only use this in a few bindings, but I'm excited about all kinds of interesting ways to use it: quitting instead of launching an app, automatically choosing a window layout, the options are endless!

Truly Global Hotkeys: Local Bindings

There is one problem… if you want to use your hyper key to bind to an in-app Preference… F19 is not supported by all applications.

Preference pane for Things 3, showing the Quick Capture.

Things will not recognize F19 as a keycode, or a modifier.

To handle this, when I press F19+. Hammerspoon translates that local binding as if I'm pressing ⌘⇧⌥⌃+..

Many apps give you the ability to set a hotkey in their preference panes for certain "global" tasks.

  • Create to-do's in Things 3.
  • Open the Quick Capture in Drafts.app.
  • Open the clipboard history, file manager, and main search window of Alfred.

To answer this, I have developed a concept of "local bindings." If you set any local_binding key for the configuration, hyper.lua will intercept the F19+<key> keypress and instead send the traditional ⌘⇧⌥⌃+<key> to the operating system… just like the Traditional model. First, set up the local bindings to the app in Hammerspoon. Then, use your new local bindings in the preference pane of the app of your choice.

if app.local_bindings then
  -- for key in app.local_bindings
  for _, key in pairs(app.local_bindings) do
    hyper:bind({}, key, nil, function()
      [..]
        -- send hyper chord + key
        hs.eventtap.keyStroke({'cmd','alt','shift','ctrl'}, key)
      [..]
    end)
  end
end

(from hyper.lua)

Even when the app is closed

In addition, I have incorporated an idea from Shawn Blanc's OopsieThings applescript. If the you trigger a local binding and Hammerspoon sees that app isn't open, it'll open it for you and then send the binding.

-- if the app is open
if hs.application.find(app.bundleID) then
  -- send hyper chord
  hs.eventtap.keyStroke({'cmd','alt','shift','ctrl'}, key)
else
  -- launch the app
  hyper.launch(app)
  -- wait for it to launch
  hs.timer.waitWhile(
    function() return hs.application.find(app.bundleID) == nil end,
    function()
      -- then send hyper chord + key
      hs.eventtap.keyStroke({'cmd','alt','shift','ctrl'}, key)
    end)
end

That way my "send a tweet using Tweetbot" or "make a Draft" or especially "make a to-do in Things" buttons always work, regardless of whether the app is accidentally closed. 👍

Configuration

Hammerspoon loads an init.lua file by default… so I have a couple of configuration tables declared in there that I pass into the other modules.

I used to declare config as a global and just let all the other modules use it, but I wanted to make sure the modules can use whatever anyone wants to use,1 so now each module has a .start() method that takes as an argument a config table. Since lua stores nearly everything as a reference, I'm not worried about blowing out memory.

Here is a minimalist example of a config for hyper.lua. I've removed some of the other keys and values for some of the other modules for the sake of discussion... just the config to launch Things 3, create local bindings for the capture options, and a quick function to reload the Hammerspoon config file.

config = {}
config.applications = {
  ['Things'] = {
    bundleID = 'com.culturedcode.ThingsMac',
    hyper_key = 't',
    local_bindings = {',', '.'}
  }
}

-- load as global, it's going to be used
hyper = require('hyper')
        hyper.start(config)

hyper:bind({'shift'}, 'r', nil, function()
  hs.reload()
end)

It's very straightforward. My most common use of Hyper.lua is to launch an application, so I have a table of applications that I can define a "hyper key" for, and optionally some local bindings that I bind inside that application to use globally.

I use hyper.lua as the "entry point" for nearly all my Hammerspoon based automation... it's great. All my keybindings are declared in one place, and I know they will never conflict with any new applications that I download. No more discovering that weird app behavior is due to a double-bound keybinding.

As this series continues, I'll list the examples of how I connect hyper.lua to other automations here.

If you want to read Hyper.lua at the time of this blog post, it's available on my GitHub. It's possible it's gone through new versions since this post. There's a few features in there now that I am not covering in this post, but we'll get to them later in this series.


  1. A lot of the configuration files I've seen for Hammerspoon use a similar pattern. 


Changelog
  • 2022-06-08 11:31:29 -0500
    Rename articles

  • 2020-09-28 09:20:43 -0500
    Change repo name

    I decided to change the name of my hammerspoon configuration repo to
    `hammerspoon-config` to avoid confusion... it's not the actual
    hammerspoon executable, it's a configuration for that executable.

  • 2020-06-14 15:51:50 -0500
    Update articles with series and series partials

    After the refactor, needed to move the series metadata to the actual
    name.

  • 2020-06-09 08:30:12 -0500
    Morning edits for clarity

  • 2020-06-08 20:08:20 -0500
    Adjust date

  • 2020-06-08 19:59:01 -0500
    Write up: Hyper