Rubyで設定を書けるLinux用キーマッパー 「rumap」をRustで作った

この記事は、 CAMPHOR- アドベントカレンダー 2020の8日目の記事です。

Rubyで設定を書けるLinux用のキーマッパーをRustで実装した話をします。

Rumap

Rumap は Ruby DSLで設定を書けるLinux用のキーマッパーです(正確にはX Window System用)。

github.com

キーマッパーとは何かというと、karabinarみたいなやつです。 つまり、キーボードの入力をなにか別の入力に変換するアプリケーションです。

例えば、以下のような設定ファイルを書いたとします。

remap 'Control-BackSpace', to: 'Delete'

これを rumap に食わせて起動すると、 Control と BackSpace を同時押しすると、代わりにDeleteが入力されるようになります。

また、キー入力を変換するだけでなく、キー入力をトリガーにしてコマンドを実行することもできます。 例えば以下のように書くことで、Alt と Shift と 4を同時押しすると gnome-screenshot -a -d 0 が実行されます。

remap 'Alt-Shift-4', to: execute('gnome-screenshot -a -d 0')

詳しい使い方やインストール方法は、READMEを参照してください。

設定ファイルがRubyスクリプトである

rumapの設定ファイルはRubyスクリプトなので、いろいろと便利なことができます。

例えばこういうことができます。

%w[r z x c v w t f Return].each do |key|
  remap "Alt-#{key}", to: "C-#{key}"
end

この設定ファイルを使うと、Alt と r や z や x...を同時押ししたとき、 Ctrlとの同時押し に変換されます。

このようにプログラミング言語を設定用の言語とすることで、複雑な設定も短く書き下すことができます。

なぜ作ったのか

Linuxで動いて、かつRubyで設定ファイルを書くことができるキーマッパーがなかったので作りました...なら理由としてカッコいいですが、実はそのようなキーマッパーは存在します。

k0kubun.hatenablog.com

DSLシンタックスもほぼ同じです(というかrumapのDSLはxremapのパクリです)。一時期は僕もxremapを使っていました。

ではなぜ作ったのかという話なんですが、xremap には with_modifier オプションが当時存在しなかったからです(今はあるかも)。

with_modifier オプション

with_modifier オプションの説明をするために、with_modifier オプションがない世界線の話をします。

Ctrl+hjkl で矢印キーを入力したい気持ちになることが人類にはよくあると思います*1。 これをxremapのDSLで表現するとどうなるでしょうか

remap 'Control-h', to: 'Left'
remap 'Control-j', to: 'Down'
remap 'Control-k', to: 'Up'
remap 'Control-l', to: 'Right'

こうなります。

これでめでたくCtrl+hjklで矢印キーが入力できるようになったわけですが、ところでテキストを編集するときにマウスを使わずに範囲選択をしたくなることは、人類にはよくあります。 Ctrl+hjkl のことを考えなければ、 Shiftを押しながら矢印キーを押してカーソルを移動する ことで、上記を実現できます。

「Shiftを押しながら」 なんだか怪しい雰囲気がしてきました。

実際、当時のxremapで、 Ctrl+hjkl の設定を入れた状態でShiftを押しながらCtrl+jを入力すると何が起こったかというと、何も起こりませんでした。 上の設定は Ctrl+j の設定であって、 Ctrl+Shift+j の設定ではないからですね。

xremapの動作をもう少し詳しく説明すると、

  • xremapは起動時に、Xのサーバーに対してwatchするキーの組み合わせを設定する
  • Xは、設定された組み合わせのキーが入力されたとき、その旨をxremapに通知する
  • xremapはキー入力の通知を受けると、キーを変換してXに入力する

のように動作するのですが、上の設定だと Ctrl+j の組み合わせしかwatchしないので、Ctrl+Shift+jが入力されてもxremapに通知が来ません。

これを解決するために、rumapで導入したのが with_modifier オプションです。

remap 'Control-h', to: 'Left', with_modifier: 'Shift'
...

このように with_modifier: 'Shift' を指定すると、rumapは立ち上げ時に、Ctrl+h に加えて Ctrl+Shift+hwatchします。そして、 Ctrl+h に変換し、Shiftも押されていた場合は Shift+← に変換します。つまり、 Ctrl+h (+Shift)← (+Shift) に変換します。

これによって、 Ctrl+Shift+hjkl を同時押しすることで、テキストの範囲選択が可能になります。

なんで本家にcontributeせえへんねん

当初は、xremapにpatchを当てることによって上記の問題を解決しようとしており。PRも出すには出しました。

Pass unmatched modifiers by genya0407 · Pull Request #36 · k0kubun/xremap · GitHub

この実装は、上で説明した with_modifier オプションとは違って、 全ての modifier の組み合わせをwatchします。 そして、通知されてきた入力のうち、設定されている部分について変換処理をします。

例えば、 remap 'Control-h', to: 'Left' と設定していたら、hを含む全ての入力(hCtrl+hCtrl+Shift+hCtrl+Alt+h、、、)をwatchし、Ctrl+Alt+j が入力されてきたら、 Alt+↓に変換する、といった感じです。

これは設定が短くて済むという利点はあるのですが、PRを出した直後にバグに気づいたのでPRを取り下げました。

もう記憶があんまりないのですが、たしか Alt+jCtrl+j みたいな設定をしていると、変換後の Ctrl+jj を含んでいるため通知が飛んできて、xremapが無限ループして死ぬ、というようなバグだったと思います。

この問題を解決するには、上で説明したような with_modifier オプションをDSLに導入すればよいのですが*2

  • DSLの文法についての交渉をするのが面倒だった*3
  • 既存のコードをいい感じに修正するのが大変そうだった(たしか)
  • xremapをコードリーディングして実装を概ね把握したことで、勉強も兼ねて自分で一から実装したくなった

などの理由から自分で実装することにしました。

その他、考えたことなど

作ってるときに考えたことなどをつらつらと書いていきます

x11ライブラリ

XのAPIを叩くために、x11というライブラリを使いました。これはXlibの非常に薄いラッパーで、Cの関数を呼ぶのでunsafeな操作ばかりです*4。ただ、薄いラッパーであることによって、Xlibのドキュメントがそのまま使えたのは助かりました。

一番困ったのはXlibのドキュメント(というかチュートリアル)がないことで、仕方がないので xremap の実装を何度も読み返しました。

Linux以外のOSに(理論上)拡張できるようにした

Linux以外のOSに拡張できるように、Linux(X)に依存する部分と、そうでない "コアロジック" を分離するようにしました。

これによって、(理論上は)例えばWindowsmacOSでも使えるように、rumapを拡張できるはずです*5

ただ、コアロジックと環境依存コンポーネントをうまく分離できていないのか、あるいは分離の仕方が悪いからかもしれませんが、コアコンポーネントのコードにジェネリクスが大量に発生してしまい、非常に読みづらく、またジェネリクス関連のコピペが多くなっています。

他環境にも拡張しないとただ単にコードの複雑性を増しただけになってしまうので拡張していきたい。しかし、macにはkarabinarがあるし、Windowsを使うことは基本的にないし、なかなかモチベーションが生まれないというのが正直なところです。

Cargoのworkspaceが便利

上に説明したコンポーネントの分離を実現するために、Cargoのworkspace機能を使ったのですが結構便利でした。

レポジトリを見てもらうと、 linuxmapper という2つのディレクトリが存在し、それぞれが1つのcrateになっています*6*7。 そして、Cargo.tomlに以下のように記述することで、1つのworkspaceとしてまとめています。

[workspace]
members = [
  "mapper",
  "linux",
]

この設定により、それぞれのcrateを、依存関係をもたせながら独立にビルドすることができます。

例えば、 linux/Cargo.toml に以下のように記述することで、linux crateからmapper crateを利用することができます。

[dependencies]
mapper = { path = "../mapper" }

また、以下のようにすることで、linux crate (と、それが依存するmapper crate)のみをビルドすることができます。

$ cd linux
$ cargo build

今回の例では全てのcrateがビルドされてしまうのでありがたみがないですが、例えば macos crate を追加したとき、Linux上では macos crate のビルドが通らないとしても、 linux crate のみをビルドすることが可能です。

共通機能を持ちつつもそれを利用したアプリケーションが複数あるような場合に、とても便利そうだなと思いました。

外部のRubyコマンドに依存するのをやめたい

xremap の「Rubyで設定が書ける」という特徴は革命的に便利なので、rumapでもこの特徴は引き継ぎたいと思っていました。xremapはmrubyで実装しているからrubyのコードを評価するのは簡単ですが、Rustでそれをどう実現すればよいでしょうか。

なんとrumapは、 外部のRubyコマンドを実行する ことで、設定ファイルを評価しています。具体的には、引数に指定されたファイルをinstance_evalして結果をJSON文字列にするRubyスクリプトを書いて、rumap起動時にそのスクリプトを起動して設定ファイルをJSONに変換し、JSONを元にrumapを初期化するようにしています*8

外部のRubyコマンドに依存するのはダサいし、外部要因で動いたり動かなかったりするのはつらいので、どうにかしたいと思っています。 この記事を書いてるときに見つけた以下のcrateを使えば、いい感じにできるはず。

github.com

まとめ

Rubyで設定を書けるLinux用のキーマッパーを作りました。ほとんど xremap のパクりですが、微妙に機能が増えています。

いろいろと実装上の反省点はあるのですが、自分が便利に使えているのでひとまずはいいかなと思っています。

*1:矢印キーまで指を伸ばすのはだるいので

*2:厳密には、Ctrl+h → Ctrl+h みたいな設定をしてると無限ループは回避できないけど、そんな設定をする人類はいないと仮定する

*3:よく考えると、後方互換性を保ったままDSLを拡張できるので割とすんなり行けたんじゃないかって気はする

*4:余談ですが、unsafeな操作は上で説明したコアロジックには存在しません。なぜなら、unsafeな操作をするのはX依存のモジュールだけだからです。

*5:実際には、X固有だと思ってたロジックがコアロジックだったとか、他の環境だとコアロジックが使い物にならん、みたいなのはあるとは思いますが、そのあたりはやってみないとわからない

*6:Cargo.tomlもそれぞれ持っている

*7:linuxがX依存部分、mapperがコアロジック

*8:JSONを作れるならなんでもいいので、例えばPythonDSLを作って組み込むことも可能なはずです。