đź“–   Chapter 17

Text expander

Watch your keypresses for snippet keywords, dynamically replacing them with whatever text you want.

Next Chapter
APIs used today Description
hs.eventtapLets you listen for keypresses and respond to them.
hs.eventtap.eventLets you build new key events that you can programmatically fire.
hs.fnutilsA collection of functional programming utility functions.

What you’re building #

In this chapter, you’re going to build a watcher in Hammerspoon that waits for you to type in a snippet keyword you define. Once the watcher detects you’ve typed the right keyword, it will replace the keyword with an expanded snippet of text.

For example, if you type in +email anywhere, you might want that to be expanded to youremail@gmail.com. This chapter will let you do this!

Create a new file #

First, make a config file to hold all your code for this project:

copy
touch ~/.hammerspoon/text-expander.lua

And require it in your main config:

copy
require("text-expander")

Define some snippets #

I’m going to make myself a couple of snippets to start with. When I type the keyword in anywhere, we’ll replace that typed keyword with the expanded text. Here’s the snippets I chose for myself:

Keyword Expanded text Description
+date August 31, 2021 Today’s date
+email d@balatero.com My email address
+meet https://zoom.us/12345678 A permalink to my personal Zoom meeting.

You can come up with your own snippets, but for now add these snippets to the top of your config file:

copy
local snippets = {
  ["+date"] = function()
    return os.date("%B %d, %Y", os.time())
  end,
  ["+email"] = "d@balatero.com", -- FIXME: replace with your email address!
  ["+meet"] = "https://zoom.us/12345678",
}

As you can see, the +email and +meet keywords will expand to static strings. However, when you type in +date, we’ll calculate today’s date on the fly and replace the snippet.

Build a trie! #

You need an efficient data structure in order to search through your snippets as you type. I’ve chosen a trie to represent our snippets in a traversable form.

From Wikipedia:

In computer science, a trie, also called digital tree or prefix tree, is a type of search tree, a tree data structure used for locating specific keys from within a set. These keys are most often strings, with links between nodes defined not by the entire key, but by individual characters. In order to access a key (to recover its value, change it, or remove it), the trie is traversed depth-first, following the links between nodes, which represent each character in the key.

At its essence, a trie is a map containing characters, which points to another map of characters, and so on, until it ends up at a leaf node.

You could make a basic trie holding the string “hello” like this:

copy
trie = {}
trie["h"] = {}
trie["h"]["e"] = {}
trie["h"]["e"]["l"] = {}
trie["h"]["e"]["l"]["l"] = {}
trie["h"]["e"]["l"]["l"]["o"] = "hello"

To traverse this structure, you’d just keep a pointer to the current node as you walk through it character by character:

copy
trie = {}
trie["h"] = {}
trie["h"]["e"] = {}
trie["h"]["e"]["l"] = {}
trie["h"]["e"]["l"]["l"] = {}
trie["h"]["e"]["l"]["l"]["o"] = "hello"

currentNode = trie
searchString = "hello"

for i = 1, searchString:len() do
  local char = searchString:sub(i, i)

  if currentNode[char] then
    -- Advance the current pointer forward by one character.
    currentNode = currentNode[char]
  else
    -- Didn't find anything, so break early
    break
  end
end

result = currentNode

if result then
  p("Found result in trie: " .. result)
else
  p("Could not find " .. searchString .. " in trie.")
end

If you paste the above example into the Hammerspoon console, it should print out Found result in trie: hello.

Convert your snippets into a trie #

Now that you understand the basics behind the trie structure, it’s time to make a function that takes your snippets table and converts into a trie that we can search through.

Each node in the trie will have the following keys:

  • children - A table containing the next level of nodes below it.
  • expandFn - The snippet expansion function to call when we reach this node.

If a node contains a expandFn, you can assume that you’re at a leaf node in the trie.

Add the function in green below your snippets definition:

copy
local snippets = { ... }

local function buildTrie(snippets)
  local trie = {
    expandFn = nil,
    children = {},
  }

  for shortcode, snippet in pairs(snippets) do
    local currentElement = trie

    -- Loop through each character in the snippet keyword and insert a tree
    -- of nodes into the trie.
    for i = 1, #shortcode do
      local char = shortcode:sub(i, i)

      currentElement.children[char] = currentElement.children[char] or {
        expandFn = nil,
        children = {},
      }

      currentElement = currentElement.children[char]

      -- If we're on the last character, save off the snippet function
      -- to the node as well.
      local isLastChar = i == #shortcode

      if isLastChar then
        if type(snippet) == "function" then
          -- If the snippet is a function, just save it off to be called
          -- later.
          currentElement.expandFn = snippet
        else
          -- If the snippet is a static string, convert it to a function so that
          -- everything is uniformly a function.
          currentElement.expandFn = function()
            return snippet
          end
        end
      end
    end
  end

  return trie
end

Next, add a line to build the trie, and print it out to the Hammerspoon console:

copy
local snippetTrie = buildTrie(snippets)
p(snippetTrie)

You should see the following output in the console:

copy
{
  children = {
    ["+"] = {
      children = {
        d = {
          children = {
            a = {
              children = {
                t = {
                  children = {
                    e = {
                      children = {},
                      expandFn = <function 1>
                    }
                  }
                }
              }
            }
          }
        },
        e = {
          children = {
            m = {
              children = {
                a = {
                  children = {
                    i = {
                      children = {
                        l = {
                          children = {},
                          expandFn = <function 2>
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        m = {
          children = {
            e = {
              children = {
                e = {
                  children = {
                    t = {
                      children = {},
                      expandFn = <function 3>
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

As you type, we’ll walk character by character down this trie structure, until we get to a leaf node containing a function. Once you hit a leaf node, you know that you’ve successfully typed out a snippet and can now insert the snippet text in place of the keyword.

Add snippet watcher #

In this section, you’re going to add a watcher that listens for key presses, and detects when a snippet +keyword has been typed.

Add a shifted keymap #

One problem with the hs.eventtap listener is that key presses come in as a pair of (modifiers, keycode).

This means if a shifted key gets typed (like @, which is shift + 2), the key event will be in its raw form:

copy
-- Pseudo representation of the key down event
event = {
  flags = {'shift'},
  key = '2',
}

This means if we want to convert a shift + 2 press into its shifted form @, we’re going to need a map to convert with.

Add this map to the top of your config file, and we’ll use it later when it comes time to detect keypresses:

copy
local shiftedKeymap = {
  ["1"] = "!",
  ["2"] = "@",
  ["3"] = "#",
  ["4"] = "$",
  ["5"] = "%",
  ["6"] = "^",
  ["7"] = "&",
  ["8"] = "*",
  ["9"] = "(",
  ["0"] = ")",
  ["`"] = "~",
  ["-"] = "_",
  ["="] = "+",
  ["["] = "{",
  ["]"] = "}",
  ["\\"] = "|",
  ["/"] = "?",
  [","] = "<",
  ["."] = ">",
  ["'"] = "\"",
  [";"] = ":",
}

Detect when a snippet keyword is typed #

You’re going to set up an hs.eventtap to watch for snippet keywords being typed. The basic scheme goes like this:

  • Keep track of how many keys have been pressed, as well as our current position in the trie.
  • Every time we get a key press:
    • If shift was held, convert it to its shifted form with the shiftedKeymap.
    • Check if we should attempt to expand the snippet if return or space is pressed.
    • If the current trie node contains an expandFn
      • If you’ve pressed return or space, attempt to expand the snippet
      • Otherwise, reset counter and trie pointer, and return early.
    • Otherwise, keep advancing the trie pointer if the pressed character is in the next level of the trie.

The watcher will advance the trie pointer as long as you keep typing characters within a snippet keyword. Once it detects you’ve hit a leaf node (if currentTrieNode.expandFn), then it can attempt to expand the snippet.

Add the code in green to the config file:

copy
local snippetTrie = buildTrie(snippets)
local numPresses = 0
local currentTrieNode = snippetTrie

snippetWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(event)
  local keyPressed = hs.keycodes.map[event:getKeyCode()]

  if event:getFlags():containExactly({'shift'}) then
    -- Convert the keycode to the shifted version of the key,
    -- e.g. "=" turns into "+", etc.
    keyPressed = shiftedKeymap[keyPressed] or keyPressed
  end

  local shouldFireSnippet = keyPressed == "return" or keyPressed == "space"

  local reset = function()
    currentTrieNode = snippetTrie
    numPresses = 0
  end

  if currentTrieNode.expandFn then
    if shouldFireSnippet then
      hs.alert.show("Detected a snippet!")
    end

    reset()
    return false
  end

  if currentTrieNode.children[keyPressed] then
    currentTrieNode = currentTrieNode.children[keyPressed]
    numPresses = numPresses + 1
  else
    reset()
  end

  return false
end)

snippetWatcher:start()

Test this out by reloading Hammerspoon, typing +email, and pressing the space key. You should see an alert pop up on your screen, like in this example video:

Detecting when a snippet keyword is typed.

Replace keyword with some dummy text #

Instead of popping up an alert when a snippet expansion is detected, let’s type out some text instead, so you can see how returning events from a hs.eventtap works.

When a key gets pressed, two events are actually fired sequentially for the same key: key down, and key up.

We have to send a bunch of key press events in the next section, so to make this easier, you’ll add a function to the top of your config file that creates both the key up/key down events for a given (modifiers, keycode) pair:

copy
-- Returns a table with a key down and key up event for a given (mods, key)
-- key press.
local function keySequence(mods, key)
  return {
    hs.eventtap.event.newKeyEvent(mods, key, true),  -- keydown = true
    hs.eventtap.event.newKeyEvent(mods, key, false), -- keydown = false
  }
end

Next, modify the snippet watcher. The code changes below will type out the string "hi" when you type out a snippet and hit space. Make these changes now to your snippetWatcher:

copy
snippetWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(event)
  local keyPressed = hs.keycodes.map[event:getKeyCode()]

  if event:getFlags():containExactly({'shift'}) then
    -- Convert the keycode to the shifted version of the key,
    -- e.g. "=" turns into "+", etc.
    keyPressed = shiftedKeymap[keyPressed] or keyPressed
  end

  local shouldFireSnippet = keyPressed == "return" or keyPressed == "space"

  local reset = function()
    currentTrieNode = snippetTrie
    numPresses = 0
  end

  if currentTrieNode.expandFn then
    if shouldFireSnippet then
      hs.alert.show("Detected a snippet!")
    end

    reset()
    return false
    if shouldFireSnippet then
      -- Send:
      --
      --   keydown: h,
      --   keyup:   h,
      --   keydown: i,
      --   keyup:   i
      --
      -- to simulate typing in "hi"
      local keyEventsToPost = hs.fnutils.concat(
        keySequence({}, "h"),
        keySequence({}, "i")
      )

      reset()

      -- Instead of passing through the current key press (return/space),
      -- return the "hi" key events instead.
      return true, keyEventsToPost
    else
      reset()
      return false
    end
  end

  if currentTrieNode.children[keyPressed] then
    currentTrieNode = currentTrieNode.children[keyPressed]
    numPresses = numPresses + 1
  else
    reset()
  end

  return false
end)

Time to test it out!

  1. Reload Hammerspoon
  2. In the Hammerspoon console text input, type out +email
  3. Press the space key

The text input should now have the string "+emailhi" in it.

Typing extra characters from an eventtap.

Replace the keyword with some real text #

Now that you have the "hi" dummy text typing out when you detect a snippet being typed, let’s replace the keyword instead with the actual contents of the snippet.

First, add an unshifted keymap to the very top of your config file. This map is the inverse of the shiftedKeymap you added before, and will allow us to convert a shifted key (@) to its unshifted form (2):

copy
local unshiftedKeymap = {
  ["!"] = "1",
  ["@"] = "2",
  ["#"] = "3",
  ["$"] = "4",
  ["%"] = "5",
  ["^"] = "6",
  ["&"] = "7",
  ["*"] = "8",
  ["("] = "9",
  [")"] = "0",
  ["~"] = "`",
  ["_"] = "-",
  ["+"] = "=",
  ["{"] = "[",
  ["}"] = "]",
  ["|"] = "\\",
  ["?"] = "/",
  ["<"] = ",",
  [">"] = ".",
  ["\""] = "'",
  [":"] = ";",
}

Here’s the keypresses we need to send once we detect a snippet keyword:

  1. You need to send enough delete keys to delete the +snippet keyword that’s already been typed.
  2. You need to call the snippet’s function to get the text to replace it with.
  3. For each char in the expanded text, send a key event.
    • If there are any shifted keys (!@#$%^&*()_+{}|:"<>?) typed, you have to convert them to their unshifted form (1234567890-=[]\;',./).
  4. Finally, send whichever key was typed that fired the snippet expansion (space or return).

Update the snippet watcher to send all these keys by replacing the code in red with the code in green:

copy
snippetWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(event)
  local keyPressed = hs.keycodes.map[event:getKeyCode()]

  if event:getFlags():containExactly({'shift'}) then
    -- Convert the keycode to the shifted version of the key,
    -- e.g. "=" turns into "+", etc.
    keyPressed = shiftedKeymap[keyPressed] or keyPressed
  end

  local shouldFireSnippet = keyPressed == "return" or keyPressed == "space"

  local reset = function()
    currentTrieNode = snippetTrie
    numPresses = 0
  end

  if currentTrieNode.expandFn then
    if shouldFireSnippet then
      -- Send:
      --
      --   keydown: h,
      --   keyup:   h,
      --   keydown: i,
      --   keyup:   i
      --
      -- to simulate typing in "hi"
      local keyEventsToPost = hs.fnutils.concat(
        keySequence({}, "h"),
        keySequence({}, "i")
      )

      reset()

      -- Instead of passing through the current key press (return/space),
      -- return the "hi" key events instead.
      return true, keyEventsToPost
      local keyEventsToPost = {}

      -- Delete backwards however many times a key has been typed, to remove
      -- the snippet "+keyword"
      for i = 1, numPresses do
        keyEventsToPost = hs.fnutils.concat(
          keyEventsToPost,
          keySequence({}, 'delete')
        )
      end

      -- Call the snippet's function to get the snippet expansion.
      local textToWrite = currentTrieNode.expandFn()

      -- Loop through each character of the expanded snippet, and add it to the
      -- list of keys to "press".
      for i = 1, textToWrite:len() do
        local char = textToWrite:sub(i, i)
        local flags = {}

        -- If you encounter a shifted character, like "*", you have to convert
        -- it back to its modifiers + keycode form.
        --
        -- Example:
        --   If char == "*"
        --   Send `shift + 8` instead.
        if unshiftedKeymap[char] then
          flags = {'shift'}
          char = unshiftedKeymap[char]
        end

        keyEventsToPost = hs.fnutils.concat(
          keyEventsToPost,
          keySequence(flags, char)
        )
      end

      -- Send along the keypress that activated the snippet (either space or
      -- return).
      -- hs.eventtap.keyStroke(event:getFlags(), keyPressed, 0)
      keyEventsToPost = hs.fnutils.concat(
        keyEventsToPost,
        keySequence(event:getFlags(), event:getKeyCode())
      )

      -- Reset our pointers back to the beginning to get ready for the next
      -- snippet.
      reset()

      -- Don't pass thru the original keypress, and return our replacement key
      -- events instead.
      return true, keyEventsToPost
    else
      reset()
      return false
    end
  end

  if currentTrieNode.children[keyPressed] then
    currentTrieNode = currentTrieNode.children[keyPressed]
    numPresses = numPresses + 1
  else
    reset()
  end

  return false
end)

Time to test it!

  1. Refresh Hammerspoon
  2. Try typing in +email to the Hammerspoon console’s text input.
  3. Press the space bar.
  4. You should see it get replaced with your email address and an extra trailing space.
It works!!
All the APIs!