macOS Sierraでオレオレ装飾キーを使う

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()のみが呼ばれる）

グローバル変数部分

```lua:init.lua
----------------------------------------------------------------
-- Hammerspoon 設定
----------------------------------------------------------------

-- キーコード
local eisuu = 0x66
local kana = 0x68
local minus = '-'
local caret = '='
local extendKey = 'f19'

-- グローバル
local normalizedKeyMap = {}
```

イベントハンドラ部分

```lua:init.lua
---------------------------------------------------------
-- キー入力監視
---------------------------------------------------------

-- 押下している独自修飾キー
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()
```

キーリマップ設定部分

```lua:init.lua
---------------------------------------------------------
-- キーカスタマイズ設定
---------------------------------------------------------
-- {
--     "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が動かなくなったのかという理由は以下のサイトに書いてあった。

[仮想化雑記帳: 【余談】Hammerspoon に移行](http://virtnote.blogspot.jp/2017/04/hammerspoon.html)

KEXTというカーネル拡張モジュールを読み込ませて動作していたが、OSのアップデートによってKEXTの動作に制限が掛かったのが原因とのこと。KEXT自体は、デバイスドライバなどをカーネルの再コンパイルなしに 有効化したり無効化するための仕組みであるらしい。

今後もカーネルへの操作をMacが規制してくるなら、同じ仕組みである限りKarabinerがフル動作するのを期待するのは難しいのかも。