Using control-tab to switch between Emacs buffers on macOS
I use VSCode as my main code editor and emacs for everything else. When in VSCode, I use control-tab to switch between recent buffers. In Emacs, this key binding doesn't work (by default, it toggles visibility of the tab bar), causing me endless minor frustrations. This post documents how I brought the VSCode control-tab behavior to Emacs.
In VSCode, the control-tab works as follows: when I hit control-tab it brings up the list of "recent buffers" and selects the most recent previous buffer. While continuing to hold control, I can then repeatedly hit tab to select other buffers, in order of decreasing recency. Releasing the control key will then take me to the selected buffer. (This is similar to how command-tab and alt-tab work for switching between applications in macOS and Windows respectively.)
The obvious approach to getting this working under Emacs involves watching for the control key release from within Emacs itself. Unfortunately, this approach cannot work because Emacs cannot detect key releases.
My solution is to watch for the control key release at the macOS level, and then send a synthetic "return" key stroke. This uses Hammerspoon, which is "a bridge between the operating system and a Lua scripting engine". The downside to this approach is that it requires giving Hammerspoon permissions to the macOS accessibility stack, which comes with security risks.
(Aside: I initially tried using Karabiner-Elements. Ultimately, I couldn't get this to work because there is no way to evaluate conditions on key up. Also, unlike Hammerspoon, Karabiner-Elements installs its own kernel extension. I also much prefer Hammerspoon's approach of writing scripts in Lua over Karabiner-Element's json-embedded syntax.)
Here's my .hammerspoon/init.lua
script, which is the first Lua program that
I've ever written:
local emacsKeyboardEventHandler
local controlTabSeen
-- Watches for the release of the control key after one or more control-tab
-- keystrokes, and sends a synthetic 'return' keystroke.
local function keyboardEventHandler(event)
local flags = event:getFlags()
local eventType = event:getType()
local keyCode = event:getKeyCode()
if flags.ctrl and eventType == hs.eventtap.event.types.keyDown and keyCode == 48 then
controlTabSeen = true
elseif not flags.ctrl and controlTabSeen then
controlTabSeen = false
hs.eventtap.keyStroke({}, "return")
end
return false
end
-- Runs the keyboardEventHandler only when Emacs is activated.
local function appEventHandler(appName, eventType)
if appName ~= "Emacs" then return end
if eventType == hs.application.watcher.activated and not emacsKeyboardEventHandler then
controlTabSeen = false
emacsKeyboardEventHandler = hs.eventtap.new({
hs.eventtap.event.types.keyDown, hs.eventtap.event.types.flagsChanged
}, keyboardEventHandler)
emacsKeyboardEventHandler:start()
elseif eventType == hs.application.watcher.deactivated and emacsKeyboardEventHandler then
emacsKeyboardEventHandler:stop()
emacsKeyboardEventHandler = nil
end
end
appWatcher = hs.application.watcher.new(appEventHandler):start()
For completeness, here's the relevant section of my Emacs config.el
:
(after!
(:and helm helm-icons)
(define-key helm-map (kbd "<C-tab>") #'helm-next-line)
(define-key helm-map (kbd "<C-S-tab>") #'helm-previous-line)
)
(global-set-key (kbd "<C-tab>") #'helm-multi-files)