Text expander
Watch your keypresses for snippet keywords, dynamically replacing them with whatever text you want.
Next ChapterAPIs used today | Description |
hs.eventtap | Lets you listen for keypresses and respond to them. |
hs.eventtap.event | Lets you build new key events that you can programmatically fire. |
hs.fnutils | A 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:

touch ~/.hammerspoon/text-expander.lua
And require it in your main config:

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:

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:

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:

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:

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:

local snippetTrie = buildTrie(snippets)
p(snippetTrie)
You should see the following output in the console:

{
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:

-- 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:

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 theshiftedKeymap
. - Check if we should attempt to expand the snippet if
return
orspace
is pressed. - If the current trie node contains an
expandFn
- If you’ve pressed
return
orspace
, attempt to expand the snippet - Otherwise, reset counter and trie pointer, and return early.
- If you’ve pressed
- Otherwise, keep advancing the trie pointer if the pressed character is in the next level of the trie.
- If
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:

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:
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:

-- 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
:

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!
- Reload Hammerspoon
- In the Hammerspoon console text input, type out
+email
- Press the
space
key
The text input should now have the string "+emailhi"
in it.
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):

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:
- You need to send enough
delete
keys to delete the+snippet
keyword that’s already been typed. - You need to call the snippet’s function to get the text to replace it with.
- 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-=[]\;',./
).
- If there are any shifted keys (
- Finally, send whichever key was typed that fired the snippet expansion (
space
orreturn
).
Update the snippet watcher to send all these keys by replacing the code in red with the code in green:

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!
- Refresh Hammerspoon
- Try typing in
+email
to the Hammerspoon console’s text input. - Press the
space
bar. - You should see it get replaced with your email address and an extra trailing
space
.