<-home

Automatically Switch to English When Pressing ESC in Vim with Hammerspoon

Why wasn’t the mode applying properly?

It’s obviously good for developers to write comments in English, but for Koreans, there’s nothing as fast and convenient as writing comments in their native language. I also try to use English as much as possible, but when I’m writing a post like this one or writing notes or comments for my own quick understanding, I generally use Korean.

As I mentioned in my last post, Development Story: Creating a Vim Cheatsheet VSCode Extension Using Claude Code, I always have the ambition to code stylishly(?) using Vim. Because I couldn’t memorize the shortcuts well, it seemed to be decreasing my productivity, so I even made a VSCode-based extension to help with this.

However, there was another reason that made Vim difficult to use: ‘Korean/English switching’. Vim supports normal/insert/visual modes, and the commands operate in normal and visual modes. To get to the state for entering commands, you have to switch from insert -> normal/visual mode, and at this time, Vim understands English words.

As a result, if you’re writing in Korean and then change modes, the input source remains Korean, forcing you to switch the language one more time, which is very cumbersome and reduces productivity. While searching for a way to solve this problem, I discovered it can be solved using Hammerspoon and am sharing it.

Hammerspoon

Hammerspoon is a tool that allows you to control parts of a MacOS’s functionality using a script called Lua. Users can write and apply scripts to insert Hooks between actions occurring between the OS and applications, or, as in this case, make a custom action execute when a specific button is pressed.

You can install it from here.

How to Write and Apply the Script

When you install Hammerspoon, a hammer-shaped icon will appear in the menu bar.

  • Open Config: By default, Hammerspoon allows you to configure scripts via the ~/Users/<YOUR_USERNAME>/.hammerspoon/init.lua file. This menu loads that configuration file.
  • Reload Config: Restarts Hammerspoon with the settings written in the ~/Users/<YOUR_USERNAME>/.hammerspoon/init.lua file. Used to apply changed scripts immediately.
  • Console: Opens the Hammerspoon console to run or debug scripts.

There are a few other menus, but they have the same functions as in other typical apps.

Applying the Script

1. Make it work only in a specific app

My goal is ‘to automatically switch to English when the ESC key is pressed while working in Vim within VSCode’. Previously, I used Karabiner to make it switch to Korean/English unconditionally when ESC was pressed, but this caused the language to switch even in applications unrelated to VSCode where the switch wasn’t necessary.

In Hammerspoon, these aspects could be finely tuned using a script, so I was able to set it to switch to English only when the ESC key is pressed in VSCode by using the targetAppBundleID.

There are several ways to check the bundle ID of the desired application, but I decided to try using the Hammerspoon console. If you put the code below into the console, it will print the bundle ID of the frontmost (active) app after 5 seconds.

-- print the bundle ID of the frontmost application after 5 seconds
hs.timer.doAfter(5, function()
  local app = hs.application.frontmostApplication()
  if app then
    print("Bundle ID of frontmost application: " .. app:bundleID())
    hs.alert.show("Bundle ID: " .. app:bundleID())
  else
    print("Could not find the frontmost application.")
  end
end)

At first, I didn’t include the condition to run it after 5 seconds, and doing so printed the console window’s bundle ID, i.e., Hammerspoon’s bundle ID. So, I set a leisurely amount of time (frankly, 5 seconds is a bit long) to be able to put the command in the console and click the desired app.

Since I actually use Windsurf, I was able to confirm that com.exafunction.windsurf was printed.

2. Applying the script

I wrote the script using the bundle ID obtained above.

-- ID of the target application.
local targetAppBundleID = "com.exafunction.windsurf"

-- If a listener already exists upon reloading the config, stop it to prevent conflicts.
if escape_keyevent then
    escape_keyevent:stop()
end

-- Create a new event tap listener for key down events.
escape_keyevent = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(event)
    local flags = event:getFlags()
    local keycode = hs.keycodes.map[event:getKeyCode()]
    local frontApp = hs.application.frontmostApplication()

    -- 1. First, check if a key was pressed within the target application.
    if frontApp and frontApp:bundleID() == targetAppBundleID then

        -- 2. Check if the pressed key is ESC or the Ctrl+C combination.
        if keycode == 'escape' or (keycode == 'c' and flags.ctrl) then

            -- Add a 0.03-second delay to resolve timing issues.
            hs.timer.doAfter(0.03, function()
                local input_english = "com.apple.keylayout.ABC"
                local current_source = hs.keycodes.currentSourceID()

                -- [CASE 1] If the ESC key was pressed (original logic).
                if keycode == 'escape' then
                    if current_source ~= input_english then
                        hs.keycodes.currentSourceID(input_english)
                        -- print("ESC pressed: Switched to English.")
                    end
                
                -- [CASE 2] If the Ctrl+C key combination was pressed (new logic).
                elseif keycode == 'c' and flags.ctrl then
                    -- Perform this special action only if the current input source is not English.
                    if current_source ~= input_english then
                        -- print("Ctrl+C (in non-English): Intercepted.")
                        -- a. Change the input source to English.
                        hs.keycodes.currentSourceID(input_english)
                        -- b. Simulate an ESC key press.
                        hs.eventtap.keyStroke({}, 'escape')
                        -- print("--> Switched to English and sent ESC.")
                        -- c. Block the original Ctrl+C event from being processed. (Crucial)
                        return true
                    end
                end
            end)
        end
    end

    -- Allow all other key events to pass through.
    return false
end)

-- Start the listener.
escape_keyevent:start()
  1. You’ll notice a part in the middle that gives a 0.03s delay. This is because the app needs time to receive focus when the ESC key is pressed. When I wrote it without this part, at some point, it wouldn’t switch to English no matter how many times I pressed ESC. Upon checking, I found that ESC wasn’t actually working. I couldn’t pinpoint the exact cause, but I assumed this was because the app needs time to receive focus when the ESC key is pressed, and after applying that part, the problem was gone.

  2. In Vim, Ctrl+C serves the same role as ESC, so most people use Ctrl+C instead of pressing ESC. The problem was that when typing in Korean, it would be entered as Ctrl+ㅊ, causing ESC to not work properly. To solve this, I added a separate condition.

I hope this helps anyone who has been experiencing the same inconvenience as me.