xkeysnail - もうひとつの Linux 向けキーリマッパ

# TL;DR

- Linux (X11) 向けのキーリマップツールをつくった
- Python 3 で書かれており `sudo pip install xkeysnail` で入れることができる
- `uinput` という Linux の低レイヤ API を利用し動作するため「多くのキーリマップツールではうまくリマッピングが動かなかった場所」でも動作する
- アプリケーションごとにキーバインドを変えたり、複数ストロークのキーにコマンドを割り当てたり Python の関数をキーに割り当てたりすることもできる
- 詳しくは https://github.com/mooz/xkeysnail で

![xkeysnail_screenshot.png](https://qiita-image-store.s3.amazonaws.com/0/85/7258ce37-3573-aa8a-f4f9-b8f7ad999a56.png)

# 背景

Firefox が Quantum になり、旧来の XUL ベースのアドオンは動作しなくなった。新たな拡張機能の機構である WebExtensions では[キーボードショートカットに関する API が大幅に制限され](https://bugzilla.mozilla.org/show_bug.cgi?id=1215061)、ぼくは拙作のアドオン [KeySnail](https://github.com/mooz/keysnail/) のWebExtensions 化を諦めた。

それからというもの、Firefox でのブラウジングが少し苦痛なものとなった。Emacs キーバインドを無意識のうちに使うたび、新しいウィンドウや印刷ダイアログが無数に開き、虐げられる気分で日々を過ごしていた。

そうこうしているうちに限界になったので、ブラウザの外で何とかしようと思い Linux 向けのキーリマップツールを探すことにした。

# なぜまた別のキーリマップツールを作ったのか

現在、世の中にあるもので自分のユースケースに合うものが残念ながら見つからなかったと、いうことが主なモチベーションとなった。

はじめ id:k0kubun さんの [`xremap`](http://k0kubun.hatenablog.com/entry/xkremap)を試し「Ruby で気持ちよくキーリマップができて最高なのではないか？」と思ったのだが、いくらか使っていると残念ながら Firefox ではリマップが動作しなかったり、動作が重いということがしばしばあった。これは原因を調べてみると `xremap` が X Window System の高めのレイヤでキーフック/キーエミュレーションをやるためのようで、[アプリケーションによってはそうして高いレイヤでエミュレートされたイベントを無視しうる](https://github.com/DreaminginCodeZH/xkeymacs/issues/1)ため、結果的にリマップが動作しない場面が出てくるということのようだった。

そもそも `xremap` が高めのレイヤでキーフック/エミュレーションをしているのは[「管理者権限 (sudo) を必要とさせたくない」](https://github.com/k0kubun/xremap/issues/6) という design decision のよう。管理者権限をキーリマッパが要求するのはユーザインタフェース的にどうなんだ、というところは、とても良く理解ができる。

ただ、ぼくは管理者権限を使ってでも、とにかく色んな場所でアプリケーションごとのリマップ機能が欲しかった。そこで、新しく `xkeysnail` というキーリマップツールを作ることにした。

# xkeysnail の特徴

https://github.com/mooz/xkeysnail

今回作った `xkeysnail` は、以下のような特徴を持つ。

- **利点**
    - フレキシブルなキーリマップ機能
        - **アプリケーションごとのキーバインド設定**
        - **複数ストロークのキーバインド設定** (例: `Ctrl+x Ctrl+c` to `Ctrl+q`)
        - **キーのリマップだけでなく Python を通じた任意のコマンドの実行**
    - 高い信頼性のキーリマップ (`uinput` を利用し低レイヤで動作するため、ほとんどの場所でリマップが動作する)
- **欠点**
    - `uinput` を利用するため管理者権限での実行が必要となる

`xkeysnail` のキーリマップ機構は [`pykeymacs`](https://github.com/DreaminginCodeZH/pykeymacs) をベースにしている。実際は `pykeymacs` がアプリケーションごとのキーマップの定義や、カスタマイズの仕組みを持たなかったりしたのでゴリゴリ機能を追加していたら、新しいキーリマップツールができていたという流れになる。

## インストール

管理者権限と Python 3 が必要。

### Ubuntu

    sudo apt install python3-pip
    sudo pip3 install xkeysnail

### ソースコードからのインストール

    git clone --depth 1 https://github.com/mooz/xkeysnail.git
    cd xkeysnail
    sudo pip install --upgrade .

## 使い方

    sudo xkeysnail config.py

とすると勝手にキーボードデバイスをスキャンして、それらをリマップ対象とする。特殊なキーボードを使っている場合は間違う可能性もあるため、

    sudo xkeysnail config.py --devices /dev/input/event1

のように `--devices` オプションでキーボードデバイスを明示的に指定することも可能 (v0.0.5 より)。

## `config.py` の書き方

(Emacs ライクなキーバインドが必要なだけであれば [`example/config.py`](https://github.com/mooz/xkeysnail/blob/master/example/config.py) をそのまま利用することをお薦めする)

xkeysnail の設定ファイル `config.py` には「各アプリケーション」で「どんなキーバインドを定義するか」を書く。これをキーマップと呼ぶ。

キーマップの定義には以下で説明する `define_keymap(condition, mappings, name)` という関数を使う。

その前にイメージをつかんでもらうため [`example/config.py`](https://github.com/mooz/xkeysnail/blob/master/example/config.py) の中身を少し抜粋する。

```python:config.py
import re
from xkeysnail.transform import *

define_keymap(re.compile("Firefox|Google-chrome"), {
    # Ctrl+Alt+j/k to switch next/previous tab
    K("C-M-j"): K("C-TAB"),
    K("C-M-k"): K("C-Shift-TAB"),
}, "Firefox and Chrome")

define_keymap(re.compile("Zeal"), {
    # Ctrl+s to focus search area
    K("C-s"): K("C-k"),
}, "Zeal")

define_keymap(lambda wm_class: wm_class not in ("Emacs", "URxvt"), {
    # Cancel
    K("C-g"): [K("esc"), set_mark(False)],
    # Escape
    K("C-q"): escape_next_key,
    # C-x YYY
    K("C-x"): {
        # C-x h (select all)
        K("h"): [K("C-home"), K("C-a"), set_mark(True)],
        # C-x C-f (open)
        K("C-f"): K("C-o"),
        # C-x C-s (save)
        K("C-s"): K("C-s"),
        # C-x k (kill tab)
        K("k"): K("C-f4"),
        # C-x C-c (exit)
        K("C-c"): K("M-f4"),
        # cancel
        K("C-g"): pass_through_key,
        # C-x u (undo)
        K("u"): [K("C-z"), set_mark(False)],
    }
}, "Emacs-like keys")
```

ここで使っている `define_keymap(condition, mappings, name)` は以下のような仕様となっている。

### `define_keymap(condition, mappings, name)`

`condition` にはキーバインド群 `mappings` を「どのアプリケーションで有効にするか」という条件を指定する。条件は以下のどれか。

- 正規表現 (e.g., `re.compile("YYY")`)
  - パターン `YYY` がアプリケーションの `WM_CLASS` にマッチした場合にキーマップを有効にする。
- `lambda wm_class: some_condition(wm_class)`
  - `WM_CLASS` を受けとり True/False を返す関数でキーマップの有効/無効を指定。正規表現では表現しにくい複雑な条件を書きたいときに。
- `None`: 全てのアプリケーションで有効な「グローバルキーマップ」をあらわす。

`mappings` には `{key: command, key2: command2, ...}` という形式の辞書を指定する。`key` と `command` は以下の形式。

- `key`: コマンドを割り当てたいキーを `K("YYY")` の形式で書く。
      - キーのシンタックスは後述の [key specification section](#key-specification)
    にて説明。
- `command`: キーに割り当てるコマンド。以下のどれかが指定可能。
    - `K("YYY")`
        - キーイベントをそのアプリケーションに送る。
    - `[command1, command2, ...]`
        - 配列を指定すると、コマンドを連続して送る。複数キーイベントを送りたい場合などに。
    - `{ ... }`
        - 辞書を指定すると、サブのキーマップとなる。複数ストロークのキーバインドを定義するときに利用する。詳しくは後述の [multiple stroke keys](#multiple-stroke-keys) にて。
    - `pass_through_key`
        - そのキーをそのままアプリケーションに送る。グローバルなキーマップで定義したコマンドを、特定のアプリケーションでは無効にしたいときに利用する。
    - `escape_next_key`
        - 次の１ストロークはアプリケーションにキーイベントをそのまま送る。
    - 関数
        - 関数が指定されると、コマンド実行にその関数が実行される。返り値はさらにコマンドとして解釈される。基本的には None を返しておけばよい (return を省略)。
        - UNIX コマンドをキーに割り当てたい場合に有用。

`name` にはキーマップの名前を書く。オプショナルな引数。

#### Key Specification

キー記法は `K("(<Modifier>-)*<Key>")` という構成となっている。Emacs のような記法が使える。

`<Modifier>` には修飾キー（モディファイア）を書く。これは

- `C` or `Ctrl` -> Control key
- `M` or `Alt` -> Alt key
- `Shift` -> Shift key
- `Super` or `Win` -> Super/Windows key

という記法となっている。

`<Key>` にキーは修飾キーでない、キーを書く。各キーの書き方は [`key.py`](https://github.com/mooz/xkeysnail/blob/master/xkeysnail/key.py) を参照。基本的には [Linux のインプットデバイスのヘッダファイルの変数名](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h) を踏襲している。

キー記法の例を以下に。

- `K("C-M-j")`: `Ctrl` + `Alt` + `j`
- `K("Ctrl-m")`: `Ctrl` + `m`
- `K("Win-o")`: `Super/Windows` + `o`
- `K("M-Shift-comma")`: `Alt` + `Shift` + `comma` (= `Alt` + `>`)

なお `xkeysnail` を起動してからキーを入力すると、該当するキーが `xkeysnail` 表記でコマンドラインに出力されるので、これを参考に `config.py` にキーマップを書くのがお薦め。

#### Multiple stroke keys

複数ストロークのキーバインドを指定したい場合は、キーマップをネストさせる。例えば以下の例では `C-x C-c` に `C-q` を割り当てている。

```python:config.py
define_keymap(None, {
    K("C-x"): {
      K("C-c"): K("C-q"),
      K("C-f"): K("C-q"),
    }
})
```

#### アプリケーションの `WM_CLASS` を `xprop` で調べる

各アプリケーション毎にキーマップを定義する場合、そのアプリケーションが何と言う `WM_CLASS` を持っているかを調べる必要がある。これには `xprop` というコマンドを使えば良い。

コマンドラインで

    xprop WM_CLASS

とするとカーソルの形が変わるので、あとは対象のアプリケーションをクリックすると、コマンドラインに

    WM_CLASS(STRING) = "Navigator", "Firefox"

のような出力がなされる。この２つめ (この例なら `Firefox`) が `xkeysnail` における `WM_CLASS` の値になるので、これを `config.py` 内で利用する。

例えば

```python
define_keymap(re.compile("Firefox"), {
    K("C-x"): {
      K("C-c"): K("C-q"),
      K("C-f"): K("C-q"),
    }
})
```

というぐあい。

# まとめ

`xkeysnail` という Linux 向けのキーリマッパを作った。個人的には、かなりライフチェンジングなツールになった。バグレポートや要望などあれば [GitHub](https://github.com/mooz/xkeysnail) まで頂けると助かります。