MacbookProを購入した。
…と思ったら即日メインのWindowsPCがぶっ壊れる始末。
何なの…
概要
Windowsでは、AHKを使って無変換と変換キーをオレオレ装飾キーとして扱い、ダイヤモンドカーソル風の移動などを実現していた。例えば、無変換 + J
で←
、無変換 + K
で↓
といった具合である。
Macオンリーな生活を余儀なくされたので、Macでも同様のオレオレ装飾キーが使えないか再現を試みた。満足のいく結果が得られたので、調査した内容や実装をまとめる。
要点
SierraではKarabiner(旧KeyRemap4MacBook)が使えない。KeyRemap4MacBook入れれば終いや!と久しぶりMacな自分は高を括ってたのだが…(KarabinerのSierra版はまだ開発途中、キーリマップだけならKarabiner-Elementsというアプリがリリースされている)
いろいろ調べたり試したりした結果、Hammerspoonというアプリを使えばLuaでかなり柔軟にキー入力の処理などを記述できそうである。キーリマップだけなら、前述のKarabiner-Elementsや英かなでも可能なのだが、今回の要件は満たせない。他にも、keyhacというアプリもあるようだが、不具合があるという記述や、調べた限り同様の目的での採用数が少なそうだったので見送った。
最終的には、Karabiner-Elementsで既存のキーを効果のないキーにリマップし、Hammerspoonを使って処理を実装するという形になった。
実装
- なるべく簡単にキーリマップを設定・変更出来るように、汎用的な処理にリマップデータを流し込む形にした
- 装飾キーは、Karabiner-Elementsを使って
英数
とかな
をF19
にして使用している(厳密には特にしなくても良い設定だが) -
hs.hotkey.bind()
だと、非装飾キー(ctrl
などでないキー)を装飾キーのように扱えなかったので、キーダウンとキーアップイベントを乗っ取る形で実現 - 装飾キーを単体押下したときの処理も設定できるようにした
- キー入力のリマップ以外にも、押下時に独自の処理を行うなどにも対応(コード中のextendKeyListで、outKeyを空白にしfunction()を設定しておけばキー入力は行われずfunction()のみが呼ばれる)
グローバル変数部分
----------------------------------------------------------------
-- Hammerspoon 設定
----------------------------------------------------------------
-- キーコード
local eisuu = 0x66
local kana = 0x68
local minus = '-'
local caret = '='
local extendKey = 'f19'
-- グローバル
local normalizedKeyMap = {}
イベントハンドラ部分
---------------------------------------------------------
-- キー入力監視
---------------------------------------------------------
-- 押下している独自修飾キー
local downedKeyMap = {}
-- 他のキーと組み合わせ済みの独自修飾キー(単独押し判定用)
local combinedKeyMap = {}
-- キーリマップリストの組み変え(アクセスし易いように)
-- {
-- "modKey": {
-- "single": "function(mods)",
-- "keys": {
-- "inKey": {"mods": ["outMods", ..], "key": "outKey", "func": "function(mods, events, self)"},
-- ..
-- }
-- },
-- ..
-- }
local function normalizeKeylist(list)
local out = {}
for i, val in pairs(list) do
if out[val['mod']] == nil then out[val['mod']] = {} end
local map = out[val['mod']]
map['single'] = val['single']
if map['keys'] == nil then map['keys'] = {} end
for j, key in pairs(val['keys']) do
map['keys'][key[1]] = {
mods = key[2],
key = key[3],
func = key[4]
}
end
end
return out
end
-- キー入力ハンドラー
local keyHandler = function(ev)
local keyCode = ev:getKeyCode()
local eventType = ev:getType()
local key = hs.keycodes.map[keyCode]
-- キーダウン時
if eventType == hs.eventtap.event.types.keyDown then
-- 独自修飾キーとの組み合わせ処理(独自修飾キーが押下済みで独自修飾キー以外のダウンイベント)
for downedKey, v in pairs(downedKeyMap) do
if downedKey ~= key and normalizedKeyMap[downedKey] ~= nil then
if combinedKeyMap[downedKey] == nil then combinedKeyMap[downedKey] = {} end
if normalizedKeyMap[downedKey]['keys'][key] ~= nil then
local mods = {}
for k, v in pairs(ev:getFlags()) do
table.insert(mods, k)
end
-- 代替イベント(キーダウン)
local events = {}
local replacement = normalizedKeyMap[downedKey]['keys'][key]
if replacement['func'] ~= nil then replacement['func'](mods, events, replacement) end
if replacement['key'] ~= '' then
for i, mod in pairs(replacement['mods']) do table.insert(mods, mod) end
table.insert(events, hs.eventtap.event.newKeyEvent(mods, replacement['key'], true))
end
-- 組み合わせたキーと内容を保持しておく
combinedKeyMap[downedKey][key] = replacement
return true, events
end
end
end
-- 独自修飾キーの処理(独自修飾キーのダウンイベント)
if normalizedKeyMap[key] ~= nil then
if downedKeyMap[key] == nil then
downedKeyMap[key] = true
end
-- キー自体のイベントは無効化(キーリピートも含まれるため)
return true
end
-- キーアップ時
elseif eventType == hs.eventtap.event.types.keyUp then
-- 独自修飾キーの処理(独自修飾キーのアップイベント)
if normalizedKeyMap[key] ~= nil then
downedKeyMap[key] = nil
-- 単体押し
if combinedKeyMap[key] == nil then
local mods = {}
for k, v in pairs(ev:getFlags()) do
table.insert(mods, k)
end
-- 関数を呼ぶ
if normalizedKeyMap[key]['single'] ~= nil then normalizedKeyMap[key]['single'](mods) end
return true
-- 組み合わせ済みの場合(独自修飾キーよりも先に組み合わせキーが離された場合)
else
-- 強制的に代替イベントのキーアップを呼ぶ
local events = {}
for k, val in pairs(combinedKeyMap[key]) do
table.insert(events, hs.eventtap.event.newKeyEvent(val['mods'], val['key'], false))
end
combinedKeyMap[key] = nil
return true, events
end
end
-- 独自修飾キーとの組み合わせ処理(独自修飾キーが押下済みで独自修飾キー以外のアップイベント)
for downedKey, v in pairs(downedKeyMap) do
if downedKey ~= key and normalizedKeyMap[downedKey] ~= nil then
if normalizedKeyMap[downedKey]['keys'][key] ~= nil then
-- 代替イベント(キーアップ)
local events = {}
local replacement = normalizedKeyMap[downedKey]['keys'][key]
if replacement['key'] ~= '' then
table.insert(events, hs.eventtap.event.newKeyEvent(replacement['mods'], replacement['key'], false))
end
-- 保持している処理を削除
if combinedKeyMap[downedKey] ~= nil then combinedKeyMap[downedKey][key] = nil end
return true, events
end
end
end
end
end
eventtap = hs.eventtap.new({hs.eventtap.event.types.keyDown, hs.eventtap.event.types.keyUp}, keyHandler)
eventtap:start()
キーリマップ設定部分
---------------------------------------------------------
-- キーカスタマイズ設定
---------------------------------------------------------
-- {
-- "mod": "modKey",
-- "single": "function(mods)",
-- "keys": [
-- ["inKey", ["outMods", ..], "outKey", "function(mods, events, self)"],
-- ..
-- ]
-- }
local extendKeyList = {
{
mod = extendKey,
single = function(mods)
-- ターミナルにフォーカス
hs.application.launchOrFocus('Terminal')
end,
keys = {
-- IME切り替え
-- ctrl + space だと意図通りに動かない…
{'space', {}, eisuu, function(mods, events, self)
-- 自身の設定を組み替える
if self['key'] == eisuu then
self['key'] = kana
else
self['key'] = eisuu
end
end},
-- ディスプレイ切り替え
{'left', {'ctrl'}, 'left'},
{'down', {'ctrl'}, 'down'},
{'up', {'ctrl'}, 'up'},
{'right', {'ctrl'}, 'right'},
-- カーソル
{'j', {}, 'left'},
{'k', {}, 'down'},
{'l', {}, 'up'},
{';', {}, 'right'},
{'u', {}, 'home'},
{'i', {}, 'pagedown'},
{'o', {}, 'pageup'},
{'p', {}, 'end'},
-- ファンクションキー
{'1', {}, 'f1'},
{'2', {}, 'f2'},
{'3', {}, 'f3'},
{'4', {}, 'f4'},
{'5', {}, 'f5'},
{'6', {}, 'f6'},
{'7', {}, 'f7'},
{'8', {}, 'f8'},
{'9', {}, 'f9'},
{'0', {}, 'f10'},
{minus, {}, 'f11'},
{caret, {}, 'f12'},
-- その他
{'delete', {'ctrl'}, 'd'}
}
}
}
normalizedKeyMap = normalizeKeylist(extendKeyList)
hs.alert.show("Config Loaded!!")
まとめ
概ね満足のいく結果を得られた。Hammerspoonを使えば、かなりいろいろできそうである。
思わぬところでLuaに再入門することになった。とはいえ、Tableオブジェクトの扱いに詰まった程度で、これくらいのコードなら書くことが出来た。最初はアプリごとに有効無効も設定できるようにと考えていたが、めんどくさくなって断念、使わないしいいや。
補足
そもそもなぜKarabinerが動かなくなったのかという理由は以下のサイトに書いてあった。
KEXTというカーネル拡張モジュールを読み込ませて動作していたが、OSのアップデートによってKEXTの動作に制限が掛かったのが原因とのこと。KEXT自体は、デバイスドライバなどをカーネルの再コンパイルなしに 有効化したり無効化するための仕組みであるらしい。
今後もカーネルへの操作をMacが規制してくるなら、同じ仕組みである限りKarabinerがフル動作するのを期待するのは難しいのかも。