Python
Emacs
Linux
firefox
X

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

TL;DR

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

xkeysnail_screenshot.png

背景

Firefox が Quantum になり、旧来の XUL ベースのアドオンは動作しなくなった。新たな拡張機能の機構である WebExtensions ではキーボードショートカットに関する API が大幅に制限され、ぼくは拙作のアドオン KeySnail のWebExtensions 化を諦めた。

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

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

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

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

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

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

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

xkeysnail の特徴

https://github.com/mooz/xkeysnail

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

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

xkeysnail のキーリマップ機構は 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 をそのまま利用することをお薦めする)

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

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

その前にイメージをつかんでもらうため example/config.py の中身を少し抜粋する。

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, ...} という形式の辞書を指定する。keycommand は以下の形式。

  • key: コマンドを割り当てたいキーを K("YYY") の形式で書く。
  • command: キーに割り当てるコマンド。以下のどれかが指定可能。
    • K("YYY")
      • キーイベントをそのアプリケーションに送る。
    • [command1, command2, ...]
      • 配列を指定すると、コマンドを連続して送る。複数キーイベントを送りたい場合などに。
    • { ... }
      • 辞書を指定すると、サブのキーマップとなる。複数ストロークのキーバインドを定義するときに利用する。詳しくは後述の multiple stroke keys にて。
    • pass_through_key
      • そのキーをそのままアプリケーションに送る。グローバルなキーマップで定義したコマンドを、特定のアプリケーションでは無効にしたいときに利用する。
    • escape_next_key
      • 次の1ストロークはアプリケーションにキーイベントをそのまま送る。
    • 関数
      • 関数が指定されると、コマンド実行にその関数が実行される。返り値はさらにコマンドとして解釈される。基本的には 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 を参照。基本的には Linux のインプットデバイスのヘッダファイルの変数名 を踏襲している。

キー記法の例を以下に。

  • 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-cC-q を割り当てている。

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

アプリケーションの WM_CLASSxprop で調べる

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

コマンドラインで

xprop WM_CLASS

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

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

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

例えば

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

というぐあい。

まとめ

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