Hammerspoon: Creating Text Expander using Hammerspoon

Created on 14 Oct 2016  路  27Comments  路  Source: Hammerspoon/hammerspoon

I am thinking of using Hammerspoon for text expanding in mac , which is obviously similar to Text Expander software. Any Idea of how to achieve this in Hammerspoon.

I used hotkey binding with hs.eventtap.keyStrokes yet this is not as comfortable as expanding an abbreviation.

Example of what I have done.

hs.hotkey.bind({"alt"},"d",function()
hs.eventtap.keyStrokes(os.date("%B %d, %Y"))
end)

The problem with above is that also I need to memorise the shortcuts. Would be great if there is a way to abbreviate the word "date" to "Oct 14 2016" like that.

Most helpful comment

@maxandersen I lol'd at the suggestion when I checked the expression's meaning (English not being my mother tongue). I don't believe there's a need for a name yet but if it becomes an official request to the Spoons repository I'll definitely think of this as one of the suggestions (although it has bad connotation 馃槬).

To kickstart you I converted your version + brought back ability to use functions in the expansion to a HammerText.spoon. Gist is here: https://gist.github.com/maxandersen/d09ebef333b0c7b7f947420e2a7bbbf5

Feel free to modify as you wish and submit as a spoon (wether you use the HammerText name or not :)

One thing I did note is that my keyboard got completely useless if any error happened in the code as it made HammerSpoon console grab focus and/or my keypress was swalloed :) Might want to eventually guard it better against errors/exceptions.

All 27 comments

What do you mean by abbreviate the word "date" to "Oct 14 2016"? Are you looking for something that can look at the nearest word to the cursor and use it as a look up for other text to replace? At present, I don't think Hammerspoon can do that...

You might get close by using hs.eventtap...

Something along the lines of using a hotkey to begin an eventtap watcher for any key press until another hotkey is pressed, which disables the watcher, then using all of the key strokes entered to look up replacement text or a function to execute... that should be possible.

You should also look at hs.chooser -- have a hotkey pop one up and have it's entries choose what to "type" based upon what you choose from the list... probably easier and less prone to timing errors than an eventtap based solution.

@asmagill : I believe going with eventtap watcher will be a good idea compared to hs.chooser. Yet I am not sure of how to set up an eventtap watcher in hammerspoon, (since I couldn't see the same in http://www.hammerspoon.org/docs/hs.eventtap.html if am not wrong) . Could you please help me on this?

watcher is a misnomer in this case and I shouldn't have used that word since we do use it in other modules to mean a specific type of monitoring... any object created with hs.eventtap.new is in effect a "watcher" since its callback function will be invoked whenever any event of the type(s) specified occur. Specifically you'll want to watch for keyUp and keyDown events... I don't think any others will matter, but you'll need to experiment

This is a first pass at something along the lines of what I think your wanting... I've not tested it myself yet, so use this as a starting point only. At a minimum it probably needs the following tests and/or additions to make it "resiliant" to unexpected behaviors:

  • is checking for the Command key modifier sufficient to abort on application hotkey combinations?
  • should there be a timeout so it stops the capture if you delay long enough? (see hs.timer)
  • some sort of window or application watcher to detect if you change focus while in event capture mode? (see hs.application.watcher and/or hs.window.watcher)
  • some sort of visual feedback so you know that it's in capture mode? (see hs.drawing and/or hs.alert)
  • some sort of feedback when a match isn't found? (see hs.alert)
  • are there other cases where it should abort or terminate early?

I may revisit this myself if I get a chance in the next week or so. It's an interesting idea that I hadn't thought to try and I'm not aware of anyone else who has either... I'm curious to see where you go with it, so if you get a chance to post a followup with your version or if you have a github repository of your own that you stick it in eventually, I'd love to see what you do with it.

keywords = {
    ["date"] = function() return os.date("%B %d, %Y") end,
    ["name"] = "my name is MISTER",
}

expander = hs.hotkey.bind({"alt"}, "d", nil, function() -- don't start watching until the keyUp -- don't want to capture an "extra" key at the begining
    local what = ""
    local keyMap = require"hs.keycodes".map -- shorthand... in a formal implementation, I'd do the same for all `hs.XXX` references, but that's me
    local keyWatcher
    keyWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyUp, hs.eventtap.event.types.keyDown }, function(ev)
        local keyCode = ev:getKeyCode()
        local eventType = ev:getType()

        if ev:getFlags().cmd then -- it's part of an application hotkey -- abort!
            keyWatcher:stop()
            return false
        end

        if eventType == hs.eventtap.event.types.keyDown then
            -- these might end capturing, so handle them on the key down since it comes first
            if keyCode == keyMap["return"] then
                keyWatcher:stop()
                local output = keywords[what]
                if type(output) == "function" then
                    local _, o = pcall(output)
                    if not _ then
                        print("~~ expansion for '" .. what .. "' gave an error of " .. o)
                        -- could also set o to nil here so that the expansion doesn't occur below, but I think
                        -- seeing the error as the replacement will be a little more obvious that a print to the
                        -- console which I may or may not have open at the time...
                        -- maybe show an alert with hs.alert instead?
                    end
                    output = o
                end
                if output then
                    -- based on the value in `what`, delete over what was typed in and replace it with whatever we want
                    for i = 1, utf8.len(what), 1 do hs.eventtap.keyStroke({}, "delete") end
                    hs.eventtap.keyStrokes(output)
                end
                return true -- don't pass the "return" keystroke on
            elseif keyCode == keyMap["escape"] then
                keyWatcher:stop()
                return true -- don't pass the "escape" keystroke on
            elseif keyCode == keyMap["left"] or keyCode == keyMap["up"] or keyCode == keyMap["down"] or keyCode == keyMap["right"] then -- should others be in here?
                keyWatcher:stop()
                return false -- pass these on
            elseif keyCode == keyMap["delete"] and #what == 0 then -- if what is empty then delete will exit the capture
                keyWatcher:stop()
                return true -- don't pass the "delete" keystroke on in this case
            end
        elseif eventType == hs.eventtap.event.types.keyUp then
            if keyCode == keyMap["delete"] then
                if #what > 0 then
                    -- while `what = what:sub(1, #what)` is simpler, it would choke on utf8 characters... so we do this
                    local t = {}
                    for p, c in utf8.codes(what) do table.insert(t, c) end
                    table.remove(t, #t) -- pop off the last one
                    what = utf8.char(table.unpack(t))
                else
                    -- shouldn't be possible with the test in keyDown, but I've been wrong before, so just in case...
                    keyWatcher:stop()
                    return true
                end
            else
                local c = ev:getCharacters() -- are we sure this will always return nil if it's not a "printable" character?
                if c then what = what .. c end
            end
        end

        -- if we get here, we've either already captured what we wanted/needed or we don't recognize it;
        -- either way, pass the event on to the focused application for its own use
        return false
    end):start()
end)

_edit: forgot the :start() to actually start the eventtap!_
_edit2: couple of other edits so this actually works, though still needs more testing..._

@asmagill Appreciate the help. Love to see the code doing what it needs to. I would love to see in future the hammerspoon come up with a pre-built module supporting this features, where user can assign the key and abbreviation and let Hammerspoon takes all the scenes behind.

I made a very little tweak to the above code, where I removed the keycode for "left" and "right" and add it to allowables.

-- codes missing --
elseif keyCode == keyMap["up"] or keyCode == keyMap["down"] then -- should others be in here?
keyWatcher:stop()
return false -- pass these on
-- codes missing --

-- codes missing --
if keyCode ~= keyMap["left"] and keyCode ~= keyMap["right"] then
local c = ev:getCharacters() -- are we sure this will always return nil if it's not a "printable" character?
if c then what = what .. c end
end
-- codes missing --

In addition to this, I have also added a timer of 8 seconds which will automatically stop the keywatcher in case we failed to (which could be in the case as you mentioned when we change the window or application or anything)

hs.timer.doAfter(8, function()
keyWatcher:stop()
end)

Let me explore on the possible use cases and tweaks needed, create a github repo and share with community the same.

so considering things like TextExpander, they typically don't have a shortcut to activate them, they are just running their EventTaps all the time, so you could just do the same, and keep a buffer of the most recently typed keystrokes, and test the buffer each time a key is pressed, to see if you've matched any of the replacement shortcuts, then emit enough backspaces to remove the shortcut, then emit the keys to type the replacement text.

I'm not sure if this Issue needs to stay open - I think HS already has everything it needs to do text expansion. If someone wants to write it up into a nice little module, we can look at including it, and if the performance turns out to be terrible, we can look at doing the work in C.

What do people think?

Looking for the same thing! Coming from windows which I use autohotkey. I can define convenient text replace like 'abc' -> 'whateveriwant', this is simply missing in Mac.

@atjshop it's not missing, it's built into the OS, see System Preferences -> Keyboard -> Text. This issue is about whether or not Hammerspoon should also have such a feature, and my contention is that we already have enough basic functionality for someone to write such a feature in pure Lua :)

Since nobody has disagreed since October, I'm going to close this issue out.

That one does not work at lot of places, even not in chrome, slack and sublime etc.

Autohotkey in Windows works everywhere, I miss the convenience

@atjshop that is a fair point. Given that, your choices are buy TextExpander, or write an equivalent in Lua in your Hammerspoon config, then share it with the world for glory and fame :D

I also implement something that can expand the text I typed. One thing I can't figure out how to achieve in hammerspoon is to get what character I actually insert into the textfield.
I mean, the code works perfectly when typing English words because the characters I insert are the same as the key sequences I type. But when I want to type, e.g. Chinese words, I have to use a input method, input some pinyin(English characters represent the pronunciation of a Chinese character), choose the character I want to type, then the character will finally insert into the textfield. These characters can't be recorded using keyDown and keyUp event. Is there a way to do this in hammerspoon? Or maybe I should go deep into object-c to make it?
Forgive my poor English :P

@Ninlives I wish I had a good answer for you here, but I'm afraid I know very little about how the non-latin input methods actually work. It's possible that @knu would know though :)

(and on the general topic of this issue - now would be a super good time for someone to write a generalised text-replacement script for Hammerspoon, because we have just added support for Lua based plugins - Spoons. I would be very happy to help anyone who wants to do it)

I think we don't need to care about how the input method work, just watch the characters changed in the focused uielement 'AXTextField' or 'AXTextArea'. Since the focused uielement is easy to get in hammerspoon, I think this may be easier to make. Not very familiar with Apple's Cocoa API, maybe try to find the solution in the coming holiday :P

@asmagill Thanks for the code snippet! It's working fine for me and solves my current needs for text expansion.

A couple of questions:

  • Is there a technical reason for the keywatcher to require the Alt+d to start listening, instead of running all the time? Will it have a performance cost to leave the watcher running constantly?
  • Deleting every char of the trigger word is slow, is there a way to delete them faster? Perhaps as @Ninlives suggested, if I got it correctly, to access the text area and directly manipulate its contents?

Found the answer to my second question - add 0 as the delay value for hs.eventtap.keyStroke, i.e. change:

for i = 1, utf8.len(what), 1 do hs.eventtap.keyStroke({}, "delete") end

to:

for i = 1, utf8.len(what), 1 do hs.eventtap.keyStroke({}, "delete", 0) end

Also added as a gist.

Hey, @amitkot , thanks for sharing this!

Do you mind explaining how to use it straight away?

I just found out about Hammerspoon and plan to be looking deeper into the docs soon, but right now I want to setup the Text Expansion really quickly to get some work done.

I have downloaded the app and created the ~/.hammerspoon/init.lua with your gist's content. Tried reloading config and stuff but it didn't work.

I'm typing date and name but it isn't working... I have also tried using Alt + D to start the watcher and also changing alt with ctrl and then reloading the config but I haven't had success so far.

Would you mind giving a quick explanation on how to get it to work?

Hey, guys, I was able to figure out how to use the script posted by @amitkot but it didn't fit my use case. So I gave it a try and adapted the code to my own way of using text expander.

1) I'm used to type prefix characters whenever I want to expand something.
2) I like it to expand automatically
3) I didn't want to bind the watcher to a hotkey so I made it a self-executing function so it starts automatically when HS starts.

One known (and accepted) limitation of this version is that the expansion only triggers if you type your word after pressing "Enter" or "Space" or "Up/Down/Left/Right" because the string buffer is cleared after each of these keys (or obviously if you type it for the first time after launching your HS config - meaning the buffer will be clean).

One feature it has is that you can delete characters you may have mistyped and correct it and the expansion will work.

You can find my snippet at: https://gist.github.com/fmaiabatista/672e543dae72bcd24dae2885cf7f4e88

For convenience:

-- Auxiliary function needed to run the feature

function table.contains(table, term)
    for key in pairs(table) do
        if key == term then
            return true
        end
    end
    return false
end

--[[ 
    *************

    Text Expander v0
    Based on: https://github.com/Hammerspoon/hammerspoon/issues/1042

    Features:
    1) Expands automatically
    2) Buffer tracks delete and 

    *************
--]]

keywords = {
    ["..name"] = "Felipe Maia",
}

expander = (function()
    local word = ""
    local keyMap = require"hs.keycodes".map
    local keyWatcher
    local DEBUG = false

    keyWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyUp, hs.eventtap.event.types.keyDown }, function(ev)
        local keyCode = ev:getKeyCode()
        local eventType = ev:getType()
        local char = ev:getCharacters()

        -- capture keydown keyboard event
        if eventType == hs.eventtap.event.types.keyDown then

            -- if "delete" key was pressed, update "word" buffer and send keystroke on to application
            if keyCode == keyMap["delete"] then
                if #word > 0 then
                    local t = {}
                    for _, chars in utf8.codes(word) do
                        table.insert(t, chars)
                    end
                    table.remove(t, #t)
                    word = utf8.char(table.unpack(t))
                    if DEBUG then print("Word after deleting:", word) end
                end
                return false
            end

            -- append char to "word" buffer
            word = word .. char

            if DEBUG then print("Word after appending:", word) end

            -- if one of these "navigational" keys is pressed, clear buffer
            if keyCode == keyMap["return"] or keyCode == keyMap["space"] or keyCode == keyMap["left"] or keyCode == keyMap["up"] or keyCode == keyMap["down"] or keyCode == keyMap["right"] then
                word = ""
            end

            if DEBUG then print("Word to check if hotstring:", word) end

            -- finally, check if word is a hotstring and expand it. clear buffer afterwards.
            if table.contains(keywords, word) then
                local output = keywords[word]
                for i = 1, utf8.len(word), 1 do hs.eventtap.keyStroke({}, "delete", 0) end
                hs.eventtap.keyStrokes(output)
                word = ""
            end
        end
        return false -- pass the event on to the application
    end):start()
end)() -- this is a self-executing function because we want to start the text expander feature automatically

Funnily enough I'm posting this exactly one month later after discovering HS was able to support text expansion. 馃槃

Couple of comments on what otherwise looks pretty useful:

  • you don't need to add the contains function to table... lua treats any value other than false and nil as "true", so you can change if table.contains(keywords, word) then to if keywords[word] then and achieve the same thing
  • expander actually containing nothing because the function it calls doesn't return anything, so technically expander = isn't necessary... in fact, if you're adding this to your init.lua file, or another file it loads with require or dofile, you can get rid of expander = (function() and the final end)() -- this... completely because the code will be executed as part of loading Hammerspoon.
  • because keyWatcher is local to your function (or file if you follow the previous point), it will eventually be collected and expansion will stop working unexpectedly because eventtaps are stopped when their userdata is collected.

If your intention is that expander contains the eventtap so that it doesn't get collected, then ignore my second point above, but add return keyWatcher as a line right after end):start() and before end)() -- ...

Otherwise, nice implementation which I may end up using myself!

It would be cool to have that code available as a Spoon if you鈥檙e interested in doing that (with the one caveat that if it鈥檚 submitted to our official Spoon repo, it can鈥檛 be called Text Expander)

+1 and I can't resist suggesting to call it HammerText :)

Hi, @asmagill , thanks for the code improvement suggestions and encouraging words!

I took another shot at the snippet and I believe I was able to simplify as well as prettify it. It's my first take on Lua so expect the code to be further improvable. 馃槃

@cmsj I really like the idea of turning this into a Spoon but have yet to better grasp the language and the recommendations on the Spoons API.

@maxandersen I lol'd at the suggestion when I checked the expression's meaning (English not being my mother tongue). I don't believe there's a need for a name yet but if it becomes an official request to the Spoons repository I'll definitely think of this as one of the suggestions (although it has bad connotation 馃槬).

Finally, the updated gist is here.

As well as the raw paste for convenience:

--[[ 
    *************

    Text Expander v0.1
    Based on: https://github.com/Hammerspoon/hammerspoon/issues/1042

    How to "install":
    - Simply copy and paste this code in your "init.lua".

    How to use:
    - Add your hotstrings (abbreviations that get expanded) to the "keywords" list following the example format.
    - Save and reload your HammerSpoon config. The text expansion feature will start automatically.

    Features:
    - Text expansion starts automatically in your init.lua config.
    - Hotstring expands immediately.
    - Word buffer is cleared after pressing one of the "navigational" keys.
      PS: The default keys should give a good enough workflow so I didn't bother including other keys.
          If you'd like to clear the buffer with more keys simply add them to the "navigational keys" conditional.

    Limitations:
    - Can't expand hotstring if it's immediately typed after an expansion. Meaning that typing "..name..name" will result in "My name..name".
      This is intentional since the hotstring could be a part of the expanded string and this could cause a loop.
      In that case you have to type one of the "buffer-clearing" keys that are included in the "navigational keys" conditional (which is very often the case).

    *************
--]]

keywords = {
    ["..name"] = "My name",
    ["..addr"] = "My address",
}

expander = (function()
    local word = ""
    local keyMap = require"hs.keycodes".map
    local keyWatcher
    local DEBUG = false

    -- create an "event listener" function that will run whenever the event happens
    keyWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(ev)
        local keyCode = ev:getKeyCode()
        local char = ev:getCharacters()

        -- if "delete" key is pressed
        if keyCode == keyMap["delete"] then
            if #word > 0 then
                -- remove the last char from a string with support to utf8 characters
                local t = {}
                for _, chars in utf8.codes(word) do table.insert(t, chars) end
                table.remove(t, #t)
                word = utf8.char(table.unpack(t))
                if DEBUG then print("Word after deleting:", word) end
            end
            return false -- pass the "delete" keystroke on to the application
        end

        -- append char to "word" buffer
        word = word .. char
        if DEBUG then print("Word after appending:", word) end

        -- if one of these "navigational" keys is pressed
        if keyCode == keyMap["return"]
        or keyCode == keyMap["space"]
        or keyCode == keyMap["up"]
        or keyCode == keyMap["down"]
        or keyCode == keyMap["left"]
        or keyCode == keyMap["right"] then
            word = "" -- clear the buffer
        end

        if DEBUG then print("Word to check if hotstring:", word) end

        -- finally, if "word" is a hotstring
        if keywords[word] then
            for i = 1, utf8.len(word), 1 do hs.eventtap.keyStroke({}, "delete", 0) end -- delete the abbreviation
            hs.eventtap.keyStrokes(keywords[word]) -- expand the word
            word = "" -- clear the buffer
        end

        return false -- pass the event on to the application
    end):start() -- start the eventtap

    -- return keyWatcher to assign this functionality to the "expander" variable to prevent garbage collection
    return keyWatcher
end)() -- this is a self-executing function because we want to start the text expander feature automatically in out init.lua

@maxandersen I lol'd at the suggestion when I checked the expression's meaning (English not being my mother tongue). I don't believe there's a need for a name yet but if it becomes an official request to the Spoons repository I'll definitely think of this as one of the suggestions (although it has bad connotation 馃槬).

To kickstart you I converted your version + brought back ability to use functions in the expansion to a HammerText.spoon. Gist is here: https://gist.github.com/maxandersen/d09ebef333b0c7b7f947420e2a7bbbf5

Feel free to modify as you wish and submit as a spoon (wether you use the HammerText name or not :)

One thing I did note is that my keyboard got completely useless if any error happened in the code as it made HammerSpoon console grab focus and/or my keypress was swalloed :) Might want to eventually guard it better against errors/exceptions.

@maxandersen that gist is great, very helpful! One thing to be aware of - your example usage is incorrect, the keys (e.g. nname appear to need to be ["nname"]). I get a syntax error otherwise. Having said that, my Lua is very weak...

That syntax should be fine for keys that are just using a-z characters.

@maxandersen you're quite right, it does work. I'm not sure why I thought that. I thought it wasn't working when I tried the other day but seems to work now.

Very nice! I started playing with it and it is working quite well,
I changed the prefix character to 'ff', so that I鈥痗ould add '.' (and also 'tab') to the navigational keys.
That way, i can expand several expressions separated by dots, for example to write Rails database queries, like User.microposts.last.comments

Was this page helpful?
0 / 5 - 0 ratings