Skip to content

Instantly share code, notes, and snippets.

@ericdodds
Forked from justintanner/emacs_hammerspoon.lua
Last active August 24, 2022 16:29
Show Gist options
  • Save ericdodds/8b5948af353fd63e977c6add67da39c8 to your computer and use it in GitHub Desktop.
Save ericdodds/8b5948af353fd63e977c6add67da39c8 to your computer and use it in GitHub Desktop.
Updated so that Control + Shift + [f/b/n/p] selects one cell (Google Sheets) or line (Google Docs)
-- Emacs Hammerspoon Script
-- Author: Justin Tanner
-- Email: work@jwtanner.com
-- License: MIT
-- What does this script do?
-- Allows you to have *most* Emacs keybindings in other apps.
-- Ctrl-Space space be used to mark and cut text just like Emacs. Also enables Emacs prefix keys such as Ctrl-xs (save).
-- Installation
-- 1) Download and hammerspoon http://www.hammerspoon.org/
-- 3) Start Hammerspoon
-- 2) Copy emacs_hammerspoon.lua to ~/.hammerspoon/init.lua (or import emacs_hammerspoon.lua into your exisiting inti.lua)
-- Customization
-- To customize the keybindings modfiy the global "keys" below.
-- Namespaces
-- This script uses namespaces to send different commands to different apps.
-- "globalOverride" contains keybindings that override all other apps (including Emacs)
-- "globalEmacs" brings Emacs style keybindings to all other apps
-- "Google Chrome" (and other app names) creates app specific keybindings that take precendence over "globalEmacs"
-- Syntax Example
-- ['globalEmacs'] = { ["ctrl"] = { ['f'] = {nil, 'right', true, nil} } }
-- This keybinding states, for all apps other than Emacs map Ctrl+f to the right arrow key. true maintains the current mark (if set).
-- ['globalEmacs'] = { ["ctrl"] = { ['x'] = {nil, nil, false, 'macroStartCtrlX'} } }
-- This keybinding states, for all apps other than Emacs map Ctrl+x to a macro called "macroStartCtrlX" and unset any previous mark.
local keys = {
['globalEmacs'] = {
['ctrl'] = {
['a'] = {'ctrl', 'a', true, nil},
['b'] = {nil, 'left', true, nil},
['d'] = {'ctrl', 'd', false, nil},
['e'] = {'ctrl', 'e', true, nil},
['f'] = {nil, 'right', true, nil},
['g'] = {nil, 'escape', false, nil},
['h'] = {nil, nil, false, nil},
['k'] = {'ctrl', 'k', false, nil},
['n'] = {nil, 'down', true, nil},
['o'] = {nil, 'return', false, nil},
['p'] = {nil, 'up', true, nil},
['r'] = {'cmd', 'f', false, nil},
['s'] = {'cmd', 'f', false, nil},
['v'] = {nil, 'pagedown', true, nil},
['w'] = {'cmd', 'x', false, nil},
['y'] = {'cmd', 'v', false, nil},
['/'] = {'cmd', 'z', false, nil},
['space'] = {nil, nil, true, 'macroCtrlSpace'},
['delete'] = {nil, nil, false, 'macroBackwardsKillWord'},
},
['ctrlXPrefix'] = {
['c'] = {'cmd', 'q', false, nil},
['f'] = {'cmd', 'o', false, nil},
['g'] = {'cmd', 'f', false, nil},
['h'] = {'cmd', 'a', false, nil},
['k'] = {'cmd', 'w', false, nil},
['r'] = {'cmd', 'r', false, nil},
['s'] = {'cmd', 's', false, nil},
['u'] = {'cmd', 'z', false, nil},
['w'] = {{'shift', 'cmd'}, 's', false, nil},
},
['alt'] = {
['f'] = {'alt', 'right', true, nil},
['n'] = {'cmd', 'n', false, nil},
['v'] = {nil, 'pageup', true, nil},
['w'] = {'cmd', 'c', false, nil},
['y'] = {'cmd', 'v', false, nil},
['delete'] = {'cmd', 'z', false, nil},
},
['altShift'] = {
['.'] = {'cmd', 'down', true, nil},
[','] = {'cmd', 'up', true, nil},
},
},
['Safari'] = {
['ctrlXPrefix'] = {
['b'] = {'cmd', 'b', false, nil},
['d'] = {{'cmd', 'alt'}, 'j', false, nil},
['f'] = {'cmd', 'l', false, nil},
['k'] = {'cmd', 'w', false, nil},
[']'] = {'cmd', 'right', false, nil},
['['] = {'cmd', 'left', false, nil},
},
['alt'] = {
['n'] = {'cmd', 't', false, nil},
}
},
['Google Chrome'] = {
['ctrlXPrefix'] = {
['b'] = {'cmd', 'b', false, nil},
['d'] = {{'cmd', 'alt'}, 'j', false, nil},
['f'] = {'cmd', 'l', false, nil},
['k'] = {'cmd', 'w', false, nil},
[']'] = {'cmd', 'right', false, nil},
['['] = {'cmd', 'left', false, nil},
},
['ctrlShift'] = {
['n'] = {{'shift'}, 'down', true, nil},
['p'] = {{'shift'}, 'up', true, nil},
['f'] = {{'shift'}, 'right', true, nil},
['b'] = {{'shift'}, 'left', true, nil},
},
['alt'] = {
['n'] = {'cmd', 't', false, nil},
}
},
['Brave Browser'] = {
['ctrlXPrefix'] = {
['b'] = {'cmd', 'b', false, nil},
['d'] = {{'cmd', 'alt'}, 'j', false, nil},
['f'] = {'cmd', 'l', false, nil},
['k'] = {'cmd', 'w', false, nil},
[']'] = {'cmd', 'right', false, nil},
['['] = {'cmd', 'left', false, nil},
},
['ctrlShift'] = {
['n'] = {{'cmd', 'shift'}, 'down', true, nil},
['p'] = {{'cmd', 'shift'}, 'up', true, nil},
['f'] = {{'cmd', 'shift'}, 'right', true, nil},
['b'] = {{'cmd', 'shift'}, 'left', true, nil},
},
['alt'] = {
['n'] = {'cmd', 't', false, nil},
}
},
['Firefox'] = {
['ctrlXPrefix'] = {
['b'] = {{'cmd', 'shift'}, 'o', false, nil},
['d'] = {{'cmd', 'alt'}, 'k', false, nil},
['f'] = {'cmd', 'l', false, nil},
['k'] = {'cmd', 'w', false, nil},
[']'] = {'cmd', 'right', false, nil},
['['] = {'cmd', 'left', false, nil},
},
['alt'] = {
['n'] = {'cmd', 't', false, nil},
['p'] = {'cmd', '\\', false, nil},
}
},
['Terminal'] = {
['ctrl'] = {
['y'] = {nil, nil, false, 'terminalPasteHack'},
}
},
['Evernote'] = {
['ctrlXPrefix'] = {
['g'] = {{'alt','cmd'}, 'f', false, nil},
}
},
['Anki'] = {
['ctrl'] = {
['a'] = {nil, 'home', false, nil},
['e'] = {nil, 'end', false, nil},
}
},
['globalOverride'] = {
['ctrl'] = {
['x'] = {nil, nil, false, 'macroStartCtrlX'},
},
['ctrlXPrefix'] = {
['j'] = {'cmd', 'space', false, nil},
['t'] = {'alt', 'tab', false, nil},
},
['alt'] = {
['s'] = {{'shift', 'cmd'}, '5', false, nil},
}
}
}
local appsWithNativeEmacsKeybindings = {
'emacs',
'rubymine',
'terminal'
}
local ctrlXActive = false
local ctrlSpaceActive = false
local hotkeyModal = hs.hotkey.modal.new()
--- Entry point for processing keystrokes and taking the appropriate action.
-- @param mods String modifiers such as: ctrl or alt
-- @param key String keys such as: a, b, c, etc
function processKeystrokes(originalMods, originalKey)
return function()
-- My lack of understand of lua and closures probably requires this assignment.
mods = originalMods
key = originalKey
if ctrlXActive and mods == 'ctrl' then
mods = 'ctrlXPrefix'
end
namespace = currentNamespace(mods, key)
if translationNeeded(namespace, mods, key) then
lookupAndTranslate(namespace, mods, key)
else
print('Sending *un-translated* keystrokes: mods: ' .. mods .. ' keys: ' .. key)
tapKey(mods, key)
end
end
end
--- Looks up a keybinding in the global keybindings table and translates that keybinding or runs a macro.
-- @param namespace String the namespace to lookup a key (eg Google Chrome or GlobalEmacs)
-- @param mods String modifiers key such as ctrl or alt.
-- @param key String keys such as: a, b or c
function lookupAndTranslate(namespace, mods, key)
config = keys[namespace][mods][key]
toMods = config[1]
toKey = config[2]
ctrlSpaceSensitive = config[3]
toMacro = config[4]
if toMacro ~= nil then
print('Executing macro: ' .. toMacro)
_G[toMacro]()
else
toMods = addShift(toMods, ctrlSpaceSensitive)
logTranslation(mods, key, toMods, toKey)
tapKey(toMods, toKey)
end
if not ctrlSpaceSensitive then
ctrlSpaceActive = false
end
if toMacro ~= 'macroStartCtrlX' then
ctrlXActive = false
end
end
--- Logs a keybinding translation to the Hammerspoon console
-- @param fromMods String original modifiers
-- @param fromKey String original key
-- @param toMods String or Table destination modifiers
-- @param toKey String destionation key
function logTranslation(fromMods, fromKey, toMods, toKey)
message = 'Translating: ' .. fromMods .. '+' .. fromKey .. ' to '
if type(toMods) == 'string' then
message = message .. toMods
elseif type(toMods) == 'table' then
for index, mods in pairs(toMods) do
message = message .. mods .. '+'
end
end
print(message .. '+' .. (toKey or ''))
end
--- Checks the global keys table for a keybinding
-- @param namespace String namespace of the keybinding (eg globalEmacs, Google Chrome, etc)
-- @param mods String modifier keys such as alt, ctrl, ctrlXPrefix, etc
-- @param key String key such as a, b, c, etc
function keybindingExists(namespace, mods, key)
return (
keys[namespace] ~= nil and
keys[namespace][mods] ~= nil and
keys[namespace][mods][key] ~= nil)
end
--- Determines if a keystroke translation is needed
-- @param namespace String namespace of the keybinding (eg globalEmacs, Google Chrome, etc)
-- @param mods String modifier keys such as alt, ctrl, ctrlXPrefix, etc
-- @param key String key such as a, b, c, etc
function translationNeeded(namespace, mods, key)
return (
keybindingExists('globalOverride', mods, key) or
(keybindingExists(currentApp(), mods, key) and (namespace == currentApp())) or
(not currentAppIsEmacs() and keybindingExists(namespace, mods, key)))
end
--- Checks the global keys table for a keybinding
-- @param namespace String namespace of the keybinding (eg globalEmacs, Google Chrome, etc)
-- @param mods String modifier keys such as alt, ctrl, ctrlXPrefix, etc
-- @param key String key such as a, b, c, etc
function currentNamespace(mods, key)
if keybindingExists('globalOverride', mods, key) then
return 'globalOverride'
elseif currentApp() ~= nil and keybindingExists(currentApp(), mods, key) then
return currentApp()
end
return 'globalEmacs'
end
--- Presses and releases a keystroke with modifiers (faster than hs.eventtap.keystroke)
-- @param mods String modifier keys such as alt, ctrl, shift
-- @param key String key such as a, b, c, etc
function tapKey(mods, key)
if key == nil then return end
hotkeyModal:exit()
hs.eventtap.event.newKeyEvent(mods, key, true):post()
hs.eventtap.event.newKeyEvent(mods, key, false):post()
hotkeyModal:enter()
end
--- Appends a shift modifier key (if needed) to the existing mods.
-- @param mods String modifier keys such as alt, ctrl, ctrlXPrefix, etc
-- @param ctrlSpaceSensitive Boolean current keybinding is amenable to holding shift
function addShift(mods, ctrlSpaceSensitive)
if ctrlSpaceSensitive and ctrlSpaceActive then
if mods == nil then
return 'shift'
elseif type(mods) == 'string' then
return {'shift', mods}
elseif type(mods) == 'table' then
table.insert(mods, 1, 'shift')
return mods
end
end
return mods
end
--- Does the current app already have Emacs keybinings
-- @return Boolean true if Emacs
function currentAppIsEmacs()
for index, value in ipairs(appsWithNativeEmacsKeybindings) do
if value:lower() == currentApp():lower() then
return true
end
end
return false
end
--- Currently running application
function currentApp()
app = hs.application.frontmostApplication()
if app ~= nil then
return app:title()
end
end
--- Assign all keybindings based on the entries in the global keys
function assignKeys()
for namespace, modTable in pairs(keys) do
for mod, keyTable in pairs(modTable) do
for key, keyConfig in pairs(keyTable) do
assignKey(mod, key)
end
end
end
end
--- Assigns a single keystroke to the global modal, which allows this script to intercept keys
-- @param mod String modifier key such as alt, ctrl, shift
-- @param key String key such as a, b, c, etc
function assignKey(mod, key)
if mod == 'altShift' then
hotkeyModal:bind({'alt', 'shift'}, key, processKeystrokes('altShift', key), nil, nil)
elseif mod == 'ctrlShift' then
hotkeyModal:bind({'ctrl', 'shift'}, key, processKeystrokes('ctrlShift', key), nil, nil)
elseif mod:match('alt') then
hotkeyModal:bind('alt', key, processKeystrokes('alt', key), nil, nil)
elseif mod:match('ctrl') then
hotkeyModal:bind('ctrl', key, processKeystrokes('ctrl', key), nil, nil)
end
end
-- Start a selection mark in a non Emacs app
function macroCtrlSpace()
ctrlSpaceActive = not ctrlSpaceActive
tapKey({}, 'shift')
end
-- Activate the Ctrl-x prefix key
function macroStartCtrlX()
ctrlXActive = true
hs.timer.doAfter(1, function() print 'End CtrlX'; ctrlXActive = false end)
if currentAppIsEmacs() then
print('Passingthrough C-x to Emacs')
tapKey('ctrl', 'x')
end
end
function terminalPasteHack()
local focusedWindow = hs.window.focusedWindow()
if focusedWindow:title():find('emacs') then
tapKey('ctrl', 'y')
else
tapKey('cmd', 'v')
end
end
-- Kill a word behind the cursor and put it in the clipboard
function macroBackwardsKillWord()
tapKey({'shift', 'alt'}, 'left')
tapKey('cmd', 'x')
end
function macroLaunchFinder()
hs.application.launchOrFocus('Finder')
end
print('---------------------------------')
print('Starting Emacs Hammerspoon Script')
assignKeys()
hotkeyModal:enter()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment