or, Defining Global and Application-Specific Shortcuts with Hammerspoon

👉friendship ender👈🔥👌

—————— </meme> ——————

This is a follow-up to my 2019 post on creating system-wide readline shortcuts for macOS with BetterTouchTool.

Table of Contents

  1. What?
  2. Why?
    1. Free
    2. Config is Lua code
    3. Better Repeat Performance
    4. Automatically Release Existing Modifiers in Shortcut Remap
  3. How?
    1. Global Shortcuts
    2. Repeatable Shortcuts
    3. Application-specific Shortcuts With Modals
    4. Remapping Keys within Modal

What?

What is Hammerspoon?

This is a tool for powerful automation of OS X. At its core, Hammerspoon is just a bridge between the operating system and a Lua scripting engine. What gives Hammerspoon its power is a set of extensions that expose specific pieces of system functionality, to the user.

Why?

Free

…as in both speech and beer.

Config is Lua code

Making it easy to:

  • Script: You can write functions to wrap common logic, whereas in BetterTouchTool you would have do a lot of repeated clicking and dragging.

  • Version control and edit: As someone that writes code and docs for a living, the appeal of dealing with plaintext is real. Being plaintext means you can version control it and use your favorite text editor. Note that even though BetterTouchTool can also export config as plaintext, it produces a ~1600-line JSON file – not very helpful.

  • Share config between different computers with slight variations: I use different applications on my personal and work laptop, for example I won’t ever need to launch my company’s internal IDE on my personal Mac, vice versa for my password manager. I would have to maintain two set of configs for personal and work computers, but with Hammerspoon I could sync the common parts and locally source the machine-specific configuration.

Better Repeat Performance

Hammerspoon uses the system repeat delay and frequency, configured here:

Key repeat settings in System Preference

Which makes the configured shortcuts behave the same as native ones, whereas BetterTouchTool’s repeat is noticeably slower even if you configure the delay and frequency to be the same as in system preference (0.03 & 0.025). Not to mention this genius UI:

Configuring key repeat in BetterTouchTool

What else you are going to use a slider for? Phone number input?

Automatically Release Existing Modifiers in Shortcut Remap

One annoying thing about BetterTouchTool+Chrome is, if I simply add a Ctrl+m -> Enter remap, it will actually get registered as Ctrl+Enter which does a different thing because Ctrl is not released in the process. With Hammerspoon it is possible to temporarily unpress existing modifiers to make sure the target shortcut doesn’t get any extra modifiers.

As simple as this:

for _, currentModifier in ipairs(fromModifiers) do
  hs.eventtap.event.newKeyEvent(currentModifier, false):post()
end 

How?

Refer to Getting Started with Hammerspoon and Hammerspoon Docs for more.

Global Shortcuts

The simplest case is mapping a shortcut to a function globally:

hs.hotkey.bind({"cmd", "ctrl"}, "h", function() launchOrFocus("Google Chrome") end)

This allows you to switch to Chrome by pressing Command+Ctrl+h anywhere.

Repeatable Shortcuts

In some cases, you would want the shortcut to be repeatable, like when adjusting volume, so you can provide the other 2 optional arguments to hs.hotkey.bind:

function soundUp()
    hs.eventtap.event.newSystemKeyEvent("SOUND_UP", true):post()
    hs.eventtap.event.newSystemKeyEvent("SOUND_UP", false):post()
end

hs.hotkey.bind({"ctrl"}, "8", --[[pressFn]] soundUp, --[[releaseFn]] nil, --[[repeatFn]] soundUp)

soundUp() sends 2 key events: 1. Pressing down volume up key 2. Releasing volume up key, and we pass it as a function object to both pressFn and repeatFn, so when you hold down ctrl+8, the volume will keep on increasing.

However, it is tedious to write this for each binding, not to mention that you should also check for existing bindings (and remove them) before creating global shortcuts, so we get to take advantage of writing config in a programming language: functions!

function sendSystemKey(key)
    hs.eventtap.event.newSystemKeyEvent(key, true):post()
    hs.eventtap.event.newSystemKeyEvent(key, false):post()
end


-- Does not cancel out fromModifiers before sending shortcut
-- Handles repeatfn
-- Only use for things can needs to be repeated!
function globalRepeatable(fromModifiers, fromKey, toFn)
  if not hs.hotkey.assignable(fromModifiers, fromKey) then
    hs.hotkey.deleteAll(fromModifiers, fromKey)
  end
  hs.hotkey.bind(fromModifiers, fromKey, toFn, nil, toFn)
end

Then everything can be done with a one-liner:

globalRepeatable({"ctrl"}, "1", function() sendSystemKey("BRIGHTNESS_DOWN") end)
globalRepeatable({"ctrl"}, "2", function() sendSystemKey("BRIGHTNESS_UP") end)
globalRepeatable({"ctrl"}, "8", function() sendSystemKey("SOUND_DOWN") end)
globalRepeatable({"ctrl"}, "9", function() sendSystemKey("SOUND_UP") end)
hs.hotkey.bind({"ctrl"}, "0", function() sendSystemKey("MUTE") end)

Application-specific Shortcuts With Modals

See my personal philosophy on remapping keys in a previous blog post, TLDR: a lot of it is about creating application-specific shortcuts.

This is quite tricky and I didn’t find a lot of documentation on this, and the solution I found is based on modals: For each application, define a modal that activates whenever you switch to that specific application and deactivates when you switch away, then define shortcuts for that modal. For example:

function newModal(name, conditionEnter, conditionExit)
  local modal = hs.hotkey.modal.new()

  -- function modal:entered() hs.alert(string.format("Enter %s", name)) end
  -- function modal:exited() hs.alert(string.format("Exit %s", name)) end

  local appWatcherCallback =
    function(appName, eventType, appObject)
      if conditionEnter(appName, eventType, appObject) then
        modal:enter()
      elseif conditionExit(appName, eventType, appObject) then
        modal:exit()
      end
    end
  local watcher = hs.application.watcher.new(appWatcherCallback)
  watcher:start()

  return modal, watcher
end

chrome, chromeWatcher = newModal("chrome",
 -- conditionEnter
  function(appName, eventType, appObject)
    return appName == "Google Chrome" and eventType == hs.application.watcher.activated end,
 -- conditionExit
  function(appName, eventType, appObject)
    return appName == "Google Chrome" and eventType == hs.application.watcher.deactivated end)

-- tab switching
remap(chrome, {"cmd"}, "n", {"ctrl"}, "tab")
remap(chrome, {"cmd"}, "h", {"ctrl", "shift"}, "tab")
-- avoid accidentally creating bookmarks
chrome:bind({"cmd"}, "d", function() end)

Note that you’ll have to keep the watcher object so it doesn’t get GC’ed.

(We’ll get to the definition of remap() in the next section.)

In the same way, you can define shortcuts that only activates only when you are NOT in some specific application:

notTerminal, notTerminalWatcher = newModal("notTerminal",
 -- conditionEnter
  function(appName, eventType, appObject)
    return appName == "iTerm2" and eventType == hs.application.watcher.deactivated end,
 -- conditionExit
  function(appName, eventType, appObject)
    return appName == "iTerm2" and eventType == hs.application.watcher.activated end)

Then we can have our good old readline shortcuts (and more) whenever we are NOT in a terminal:

remap(notTerminal, {"ctrl"}, "c", {}, "escape")
remap(notTerminal, {"ctrl"}, "m", {}, "return")
remap(notTerminal, {"ctrl"}, "h", {}, "delete")
remap(notTerminal, {"ctrl"}, "w", {"alt"}, "delete")
remap(notTerminal, {"ctrl"}, "u", {"cmd"}, "delete")
-- movement and selection (Shift-Ctrl-bnpfae works out of the box for text fields, but this allow you to cover other form of selections like selecting items in Things.app)
remap(notTerminal, {"ctrl"}, "p", {}, "up")
remap(notTerminal, {"shift", "ctrl"}, "p", {"shift"}, "up")
remap(notTerminal, {"ctrl"}, "g", {}, "up")
remap(notTerminal, {"shift", "ctrl"}, "g", {"shift"}, "up")
remap(notTerminal, {"ctrl"}, "n", {}, "down")
remap(notTerminal, {"shift", "ctrl"}, "n", {"shift"}, "down")
remap(notTerminal, {"ctrl"}, "b", {}, "left")
remap(notTerminal, {"shift", "ctrl"}, "b", {"shift"}, "left")
remap(notTerminal, {"ctrl"}, "f", {}, "right")
remap(notTerminal, {"shift", "ctrl"}, "f", {"shift"}, "right")
remap(notTerminal, {"ctrl"}, "s", {"alt"}, "left")
remap(notTerminal, {"shift", "ctrl"}, "s", {"shift", "alt"}, "left")
remap(notTerminal, {"ctrl"}, "-", {"alt"}, "right")
remap(notTerminal, {"shift", "ctrl"}, "-", {"shift", "alt"}, "right")

Good lucking creating all that (then setting repeat frequency and delay) by clicking and dragging in BetterTouchTool.

Remapping Keys within Modal

As mentioned earlier, while remapping shortcuts we should release existing modifiers to avoid them interfering with the target shortcut. Here’s my function for defining remaps for a specific modal:

function remap(modal, fromModifiers, fromKey, toModifiers, toKey)
  local pressFn = function()
    -- Unpress all current modifiers (e.g. so that application does not receive ctrl+return instead of just return)
    for _, currentModifier in ipairs(fromModifiers) do
      hs.eventtap.event.newKeyEvent(currentModifier, false):post()
    end 
    hs.eventtap.keyStroke(toModifiers, toKey, 0)
  end
  local repeatFn = function()
    hs.eventtap.keyStroke(toModifiers, toKey, 0)
  end
  modal:bind(fromModifiers, fromKey, --[[pressfn=]] pressFn, --[[releasefn]] nil, --[[repeatfn=]] repeatFn)
end

Note that:

  • I constructed different functions for press and repeat for better performance, because you don’t need to release existing modifiers for repeat functions.
  • For keyStroke(): another option is to make two hs.eventtap.event.newKeyEvent():post() calls for key down and key up, but using hs.eventtap.keyStroke gives better repeat performance in practice.