Skip to content

Instantly share code, notes, and snippets.

@shiro-t
Last active May 8, 2023 05:22
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shiro-t/09c9524c2f905d1d40dfacc4015960dc to your computer and use it in GitHub Desktop.
Save shiro-t/09c9524c2f905d1d40dfacc4015960dc to your computer and use it in GitHub Desktop.
My init.lua
-- disable all settings
local passthrough = {}
-- default key mappings
local defaultKeyMapper = {
{{"ctrl"}, 'a', {'ctrl'}, 'left' },
{{"ctrl"}, 'b', {}, 'left' },
{{"ctrl"}, 'e', {'ctrl'}, 'right' },
{{"ctrl"}, 'f', {}, 'right' },
{{"ctrl"}, 'h', {}, 'delete' },
{{"ctrl"}, 'n', {}, 'down' },
{{"ctrl"}, 'p', {}, 'up' },
}
-- custom keymapping : Human readable configuration. modal object will be created dynamically from this.
-- Four type of settings.
-- "appname" = {} : default keymappings is applied and addtional keymappings is also applied.
-- "appname*" = {} : only the keymappings is applied. defaults is ignored.
-- "appname" = passthrough : no keymappings is applied. "pass through"
-- "appname" = defaultKeyMapper : only the default keymappings is applied.
--
-- modifier : fn, ctrl, option,alt, command,cmd, shift
appKeyMappers = {
["Finder"] = passthrough,
["Terminal"] = passthrough,
["ターミナル"] = passthrough,
["CotEditor"] = passthrough,
["Code"] = passthrough,
["zoom.us"] = passthrough,
["Slack"] = passthrough,
["Google Chrome"] = passthrough,
["Safari*"] = {
{{"ctrl"}, 'b', {}, 'left' },
{{"ctrl"}, 'f', {}, 'right' },
{{"ctrl"}, 'h', {}, 'delete' },
{{"ctrl"}, 'n', {}, 'down' },
{{"ctrl"}, 'p', {}, 'up' },
{{"cmd"}, 'b', {"cmd","ctrl"},'1' },
},
["VMware Fusion*"] = {
{{"cmd"}, 'w', {}, 'w' },
},
-- ["ATOK お気に入り文書編集ツール"] = defaultKeyMapper,
-- ["Microsoft Word"] = defaultKeyMapper,
["Microsoft OneNote"] = {
{{"ctrl"}, 'k', {"ctrl","shift"}, 'right', {"cmd"}, 'x' },
{{"ctrl"}, 'y', {"cmd"}, 'v' },
},
["Microsoft Excel"] = {
{{"ctrl"}, 'return', {}, 'f2' },
},
-- custom keymapping
["Microsoft PowerPoint*"] = {
{{"ctrl"}, 'a', {'cmd'}, 'left' },
{{"ctrl"}, 'b', {}, 'left' },
{{"ctrl"}, 'e', {'cmd'}, 'right' },
{{"ctrl"}, 'f', {}, 'right' },
{{"ctrl"}, 'h', {}, 'delete' },
{{"ctrl"}, 'n', {}, 'down' },
{{"ctrl"}, 'p', {}, 'up' },
},
["Microsoft Outlook*"] = {
{{"ctrl"}, 'a', {'ctrl'}, 'up' },
{{"ctrl"}, 'b', {}, 'left' },
{{"ctrl"}, 'e', {'ctrl'}, 'down' },
{{"ctrl"}, 'f', {}, 'right' },
{{"ctrl"}, 'h', {}, 'delete' },
{{"ctrl"}, 'n', {}, 'down' },
{{"ctrl"}, 'p', {}, 'up' },
{{"cmd"}, 'return', {}, 'return' },
{{"ctrl"}, 'return', {}, 'return' },
{{"ctrl"}, 'i', {'ctrl'}, 'k' },
{{"ctrl"}, 'o', {'ctrl'}, 'l' },
},
}
delay = hs.eventtap.keyRepeatDelay()
interval = hs.eventtap.keyRepeatInterval()
function bindKeyMappers(keyMappers,modal)
local i,mapper
for i,mapper in ipairs(keyMappers) do
if( #mapper == 4) then
modal:bind(mapper[1], mapper[2],
function()
hs.eventtap.keyStroke(mapper[3],mapper[4], delay )
end,
nil,
function()
hs.eventtap.keyStroke(mapper[3],mapper[4], interval )
end)
elseif(#mapper == 6 ) then
modal:bind(mapper[1], mapper[2],
function()
hs.eventtap.keyStroke(mapper[3],mapper[4])
hs.eventtap.keyStroke(mapper[5],mapper[6])
end,
nil, nil)
end
end
return modal
end
-- globals :
-- appKeyModals contains modal objects for apps. modal objects are used for modify key inputs.
-- modal object will be create one per apps. modal for passthrough and default key mapping will be shared by apps.
--
-- 0 is modal for passthrough
-- 1 is modal default key mapping
-- "appname" is modal for the application
appKeyModals = {
[0] = hs.hotkey.modal.new( passthrough, nil ),
[1] = bindKeyMappers( defaultKeyMapper, hs.hotkey.modal.new({}, nil ) )
}
modal = appKeyModals[1]
modal:enter()
-- Disable if InputMethod is ATOK
-- function imWatcher()
-- ATOKsrc = "com.justsystems.inputmethod.atok31.Japanese"
-- ABCsrc = "com.apple.keylayout.ABC"
--
-- local source_id = hs.keycodes.currentSourceID()
-- print("source_id: " .. source_id .. "\n")
-- if( source_id == ATOKsrc ) then
--
--
--
--
--
-- end
-- hs.keycodes.inputSourceChanged( imWatcher )
-- main function
function applicationWatcher(appName, eventType, appObject)
if (eventType == hs.application.watcher.activated) then
-- print(appName)
modal:exit()
local useDefault = true
local keyMappers = appKeyMappers[appName]
if( keyMappers == nil ) then
keyMappers = appKeyMappers[appName.."*"]
if( keyMappers ~= nil ) then
useDefault = false
-- print( "useDefault = false" )
end
end
if( keyMappers == nil ) then
-- mapper isn't exist, use default modal.
-- print("no definition:default")
modal = appKeyModals[1]
else
modal = appKeyModals[appName]
if( modal == nil ) then
-- mapper is exist, but modal hasn't been created.
if( keyMappers == passthrough ) then
-- print("passthrough")
modal = appKeyModals[0]
elseif( keyMappers == defaultKeyMapper ) then
-- print("default modal")
modal = appKeyModals[1]
else
-- print("create new modal")
modal = hs.hotkey.modal.new({}, nil )
if( useDefault ) then
-- "appname*"
bindKeyMappers(defaultKeyMapper, modal )
end
bindKeyMappers(keyMappers, modal )
end
appKeyModals[appName] = modal
else
-- modal is already exist, use existing one.
-- print("use existing modal")
-- print( appName )
end
end
-- print("set keymap")
modal:enter()
end
end
appWatcher = hs.application.watcher.new(applicationWatcher)
appWatcher:start()
@shiro-t
Copy link
Author

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 を一個作って代入している。

@shiro-t
Copy link
Author

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 全体にあててしまってもまあ実害は無いだろう、という判断をしている。

@shiro-t
Copy link
Author

shiro-t commented Feb 15, 2018

大幅に修正。

キーマップを一切操作しない passthrough と、デフォルトキーバインドだけは操作する defaultKeyMapper を定義。

これまでは
・appKeyMappers に定義があればキーマップを定義する
・定義がなければ何もしない

であったが、ここからは

・appKeyMappers に定義がなくてもすべてに defaultKeyMapper を割り当て
・passthrough が定義されていればキーマップを一切いじらない
・appKeyMappers にアプリ名で追加する事で defaultKeyMapper + 独自定義でのキーマップを定義
・appKeyMappers にアプリ名+末尾に「*」を追加する事で、独自定義のみのキーマップを定義

と少々複雑になった。起因は、ATOK の一部パネル(たとえば「お気に入り」パレット)でキーバインドを効かせたい場合。アプリはかわらずメニューだけが出てくるこの状態では、アプリ名で捉える事が困難であった。このため、何も定義しない状態でも defaultKeyMapper だけは効かせるようにした。その代わりに Finder, Termainal など効いてもらっては困るものは明示的に passthrough を指定するようにした。

また、VMware Fusion では Command-W による仮想マシンを閉じてしまうのだけは防ぎたかった。このために * 付きアプリ名で、defaultKeyMapper 抜きの定義をしている。Safari で本来存在する Ctrl-bfhnp を再定義しているのは confruence がそれらのキーを喰ってしまうための対処。

あとはほぼ以前と同じ。

実際のキーマップは modal クラスで appKeyModals に格納される。
起動時には何もキーマップをイジってない appKeyModals[0] とデフォルトキーマップだけの appKeyModals[1] がある。
現在のキーマップはグローバル変数 moal に入っている。起動時には appKeyModals[1] が入っている。

アプリケーションが切り替わるたびに applicationWatcher 関数が呼ばれる。ここで現在の modal を終了、次の modal を稼働させる。
一つのアプリで生成される modal は一つだけだし、passthough, defaultKeyMapper は1つ作られるのでそれ以降は影響ない。

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