2017/04/06

【余談】Hammerspoon に移行

手元の Mac の OSをアップデートしたら Karabiner が利用できなくなってしまった。
どうも  macOS Sierra に非対応のとのこと。Karabiner はカーネルにモジュール(KEXT)を読み込ませ、カーネル内部のキーイベントを操作することでキーバインドの変更を実現している模様だが、そのためカーネルの変更に弱い面がある。

一方の Apple はセキュリティ面やOSの安定稼働の面から、KEXT による拡張を抑制する方向にある。Microsoft も 64bit 移行やOSのアップグレードの機会を通じて電子署名されていないデバイスドライバはロードできないようにしているし、iOS のようなモバイルデバイスのOSでは、もはやカーネルにモジュールを追加することすら不可能になっている。

幸い、Karabiner では Karabiner Elements という新しいキーリマップソフトウェアを開発、そこから従来の Karabiner の機能を追加していく形で刷新するようで、これは期待が持たれる。とはいえ、一からの作り直しなのでしばらくは Karabiner なしの生活に耐えなければならないようだ。

私が Karabiner を使ってる理由は、実のところ「Microsoft Office で Emacs キーバインド(のサブセット)が使いたい」 につきる。Karabiner Elements では現在のところアプリケーションごとのキーマップというのは実装されてないので、これは対応できないことになる。

いい加減 面倒になってきたので色々探して試した結果、Hammerspoon を利用することに落ち着いた。Hammerspoon はキー入力などのイベントを Lua という言語を使ってカスタマイズするもので、単なるキーリマップ以上の操作もできるようだ。
また、Accessibility API を通じてキーイベントを取得、操作を行う。公式のAPI を通しているため、OSのアップデートによる影響も(KEXTを使うのに比べると)低い訳だ。

Hammerspoon は ~/.hammerspoon/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,
["Windows App"] = passthrough,
["Screen Sharing"] = passthrough,
["画面共有"] = 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()
view raw init.lua hosted with ❤ by GitHub

詳細は、この gist のコメントを見てほしい。