Create a gist now

Instantly share code, notes, and snippets.

@shiro-t /init.lua
Last active Sep 10, 2017

What would you like to do?
My init.lua
-- key mappings
defaultKeyMapper = {
{{"ctrl"}, 'a', {'cmd'}, 'left' },
{{"ctrl"}, 'b', {}, 'left' },
{{"ctrl"}, 'e', {'cmd'}, 'right' },
{{"ctrl"}, 'f', {}, 'right' },
{{"ctrl"}, 'h', {}, 'delete' },
{{"ctrl"}, 'n', {}, 'down' },
{{"ctrl"}, 'p', {}, 'up' },
}
appKeyMappers = {
["Microsoft PowerPoint"] = { },
["Microsoft Excel"] = { },
["Microsoft Word"] = { },
["Microsoft OneNote"] = { },
["Tweetbot"] = {
{{"cmd"}, 'w', {}, '' },
},
["Microsoft Outlook"] = {
{{"cmd"}, 'return', {}, 'return' },
{{"ctrl"}, 'return', {}, 'return' },
{{"ctrl"}, 'i', {'ctrl'}, 'k' },
{{"ctrl"}, 'o', {'ctrl'}, 'l' },
},
}
-- modal maker
delay = hs.eventtap.keyRepeatDelay()
interval = hs.eventtap.keyRepeatInterval()
function bindKeyMappers(keyMappers,modal)
for i,mapper in ipairs(keyMappers) do
modal:bind(mapper[1], mapper[2], function()
modal.triggered = true
hs.eventtap.keyStroke(mapper[3],mapper[4], delay )
end, nil, function()
hs.eventtap.keyStroke(mapper[3],mapper[4], interval )
end)
end
return modal
end
-- globals
mzero = hs.hotkey.modal.new({}, nil )
modal = mzero
appKeyModals = {
[0] = bindKeyMappers( defaultKeyMapper, hs.hotkey.modal.new({}, nil ) )
}
-- 0 is default only keymap
-- main function
function applicationWatcher(appName, eventType, appObject)
if (eventType == hs.application.watcher.activated) then
--print(appName)
if( modal ~= mzero ) then
modal:exit()
end
keyMappers = appKeyMappers[appName]
if( keyMappers == nil ) then
-- print("passthrough")
modal = mzero
else
modal = appKeyModals[appName]
if( modal == nil ) then
if( #keyMappers == 0 ) then
-- print("default modal")
modal = appKeyModals[0]
else
-- print("create new modal")
modal = hs.hotkey.modal.new({}, nil )
bindKeyMappers(defaultKeyMapper, modal )
bindKeyMappers(keyMappers, modal )
appKeyModals[appName] = modal
end
end
end
if( modal ~= mzero ) then
-- print("set keymap")
modal:enter()
end
end
end
appWatcher = hs.application.watcher.new(applicationWatcher)
appWatcher:start()
Owner

shiro-t commented Apr 5, 2017

defaultKeyMapper はどのアプリケーションでも共通のキーバインドを定義。ここでは Ctrl-a,b,e,f,p といった Emacs Keybinding での基本的なカーソル移動操作と、ctrl-h による削除を定義している。

appKeyMappers はキーマップを変更するアプリケーションの定義と、アプリケーション独自のキーマップを追加するためのテーブル。
私は Microsoft Office で Emacs Keybinding が使いたいため、各アプリケーションをここで定義。
なお、スペースを含む名前をテーブルに入れる場合、[] でくくった上でダブルクオートでくくる事になるので注意。

Outlook については、アプリケーション独自の追加キーマップとして Cmd-Return と Ctrl-Return をただの Return にリマップと、Ctrl-io を Ctrl-kl にリマップしている。前者は Ctrl-Return でメールが送信処理にマップされているのを無効化するためにある。よく誤操作で送信してしまうためだ。
後者は、ATOK のキーバインドで Ctrl-io を文節の伸ばし縮みに使用しているが、Outlook 側で Ctrl-o あたりが使用されているための改変だ。これにあわせて ATOK 側でも Ctrl-kl を Ctrl-io と同じ用途に追加している。

Tweetbot はツイッタークライアントのアプリケーションだが、これについて Cmd-w を無効化している。Tweetbot がフロントのアプリケーションのときについうっかり(Safari がフロントと勘違いして) Cmmand-W を押してしまい、タイムラインを表示したウィンドウを閉じてしまうことが多発してたためだ。Tweetbot ではタイムラインを表示するメインウィンドウと、自身の発言を入力するウィンドウがあるが、後者はESCキーを押すと閉じるために Command-W がなくてもさして困らない。

実際の処理は最後の 94,95行目から始まる。ここで appwatcher という監視オブジェクトを作成、実行している。
監視オブジェクトはアプリケーションの起動終了などのイベントが発生するとコールバック関数 applicationWatcher() を呼ぶ。

関数applicationWatcher() では、まず、アプリケーションのアクティベート(最前面に来てアクティブなアプリケーションとなる)以外のイベントは無視している。

アプリケーションがアクティベートされた場合、まず現在の modal を終了させる。
次にアプリ名で appKeyMappers を検索、リマップ対象かを確認している。

appKeyMapper からの戻りが nil でなければ、リマップ対象と言うことで、今度は appKeyModals を検索、こちらに modal があればそれを利用する。appKeyModals に modal がなければ、keyMappers から modal を作成、appKeyModals に登録の上、これを利用する。

modal とは要するにキーマップのモードである。キーのリマップが一段、たとえば Ctrl-A を Command+← に置き換えるだけなら簡単なのだが、Emacs のファイルの保存のように Ctrl-x の次に Ctrl-w を押すような、多段階のキーマップも考えられる。どうやらこういうときのために、Hammperspoon ではあらかじめ複数のキーマップを定義しておき、切り替えて使うことが可能になっている。例えば通常キーマップで Ctrl-x を押されたら、Ctrl-x 後にだけ有効なキーマップに遷移する、という流れができる。この個々のキーマップを modal というオブジェクトで格納している。
( ウィンドウシステムでプログラムを書いたことのある人なら、Modal Event Loop とか Modal Panel などの特定の状態遷移を指すモードという言葉はなじみがあるかと。 )

ここではその「複数のキーマップを定義して、切り替えることができる」を利用して、アプリケーションごとに modal を生成、切り替えて使うようにしている。起動時に appKeyMappers に含まれる全てのアプリケーションの modal を作るのも無駄に感じられたので、一度作った modal は変数 appKeyModals に格納しておき、二度目以降は再利用している。

また、appKeysModals には最初からデフォルトキーマップだけの modal が登録されている。
デフォルトキーマップ以外に追加していない、言い換えるとアプリケーション名に対応する定義を格納した変数keyMappers の要素数が0の場合、このデフォルトだけのキーマップを利用する。これは53行目の初期化の時点で作られている。デフォルトのキーバインドの変更しか行わない modal が複数生成されないようにして、メモリと処理数を減らしている。(けちくさいと思う、我ながら。)

modal が有効にされるのはリマップ対象のアプリだけである。それ以外の場合は modal は有効にされない。mzero は「modal's zero」の略で、変数 modal の値が mzero と等しい場合は有効も無効もしないようになっている。アプリ切り替え時の判定は若干増えるが、それ以降、キーを叩く度に何の設定もされていない modal を通るか、そもそも modal がないのでスルーされるかだと、後者の方がよいと感じられたため。
nil を使っていないのは、不用意に変数に nil を投入することで GC に目を付けられるのを回避するため。mzero 自体はどの値でも良いのだが、一応型を合わせるために(若干のメモリの無駄だが) modal を一個作って代入している。

Owner

shiro-t commented Sep 10, 2017

なお、職場でも MacBook Pro を使う事になったが、そちらでは Tweetbot の代わりに ["Safari"] = { } という行が入っている。
Safari 自体はほぼ Emacs Keybind で動作するので、通常なら HammerSpooon でキーバインドをいじる必要は無い。
ただ、社内で使っているとある SaaS アプリケーションが Comand-F を先取して検索にあててるという非常に F*cking な仕様でしかもそれらのショートカットを解除できないためである。HammerSpoon でキーバインドをかえる事で、SaaS側には「→」が押されたと認識させている訳である。

HammerSpoon の Lua をうまく使えば、もし Safari の場合はさらにフロントのページのURLを取得してそれに応じてキーバインドを調整するというのも可能ではないかと思われる。ただそこまで細分化しても結局やりたいのは EmacsKeybinding なカーソル移動をしたいだけなので、Safari 全体にあててしまってもまあ実害は無いだろう、という判断をしている。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment