Emacsでプレフィックスキーにもコマンドを割り当てる

Emacsでは, たとえばC-c C-jにコマンドが割り当たっているとすると, C-cまで打った時点では入力待ちになる. 慣れてくるとC-c C-jは素早く打てて, C-cを押して次が何のキーだったか迷うようなことはなくなってくるので, C-cを押してしばらく(0.5秒とか)経った場合は続けて別のキーを入力するつもりはないものとして扱って, C-c単体に別のコマンドを割り当てられるようにしてもよいのではないか.

もっと深刻な話としては, たとえばESC xというキー列はM-xと同等に解釈されてしまうので, ESCそのものにコマンドを割り当てることはできない. もしもM-xはAlt+xでしか打たないというのであれば, M-xのときにはESCとxが同時に入力されるはずなので, ESCの入力があってからほんの少しでも(0.01秒とか)入力待ちになったら, それはESC単体の入力とみなしても問題はなさそうなもの. 実際, ViperVimpulse, Evilなどでは, この発想でESCとM-x等の両方に別々のコマンドを割り当てている. 今回は, これをESCだけでなく一般のキーに対してできるようにしたという話.

作ったものと使い方


典型例
(key-intercept-mode 1) ; あるいはM-x key-intercept-mode

;; C-cにa-commandを定義(全バッファ・全モード共通)
(define-intercept-key (kbd "C-c") 'a-command)

(key-intercept-make-local) ; これ以降の定義はバッファローカルになる

;; hogehoge-mode(マイナーモード)がオンのときだけ有効なキーバインド
(define-modal-intercept-key (kbd "C-x") 'hogehoge-mode 'another-command)

;; コマンドの後の引数は, キー単体の入力だと判断するまでの時間(秒)
(define-intercept-key (kbd "ESC") 'yet-another-command 0.01)
multi-termでESCとC-cを使う設定例
(require 'term)
(require 'multi-term)
(require 'key-intercept nil t)

(defvar term-char-mode t)
(defvar term-line-mode nil)

(defadvice term-char-mode (after activate)
  (setq term-char-mode t)
  (setq term-line-mode nil))

(defadvice term-line-mode (after activate)
  (setq term-char-mode nil)
  (setq term-line-mode t))

(setq term-unbind-key-list '("C-x" "M-x"))
(define-key term-raw-map (kbd "M-RET") 'term-line-mode)
(define-key term-mode-map (kbd "M-RET") 'term-char-mode)

(defun term-send-esc ()
  (interactive)
  (term-send-raw-string "\e"))

(add-hook
 'term-mode-hook
 '(lambda ()
    (when (featurep 'key-intercept)
      (make-local-variable 'emulation-mode-map-alists) ; key-intercept-make-localの代わりにこれでもよい
      (key-intercept-mode 1)
      (define-modal-intercept-key (kbd "ESC")
        'term-char-mode 'term-send-esc 0.01)
      (define-modal-intercept-key (kbd "C-c")
        'term-char-mode 'term-interrupt-subjob)
      )))

制限

  • Viper/Vimpulse/Evilを使っていてemacs-state以外のときは, ESCへの割り当ては動かない(Viper等のものが優先される)かも

がんばりどころ

  • 任意の流さのキー列に別々のキーを割り当てられる
    • たとえばC-cとC-c C-jとC-c C-j xのすべてに別々のキーを割り当てることもできる
    • たとえばC-cとC-c C-j xに別々のキーを割り当てる(C-c C-jはundefined)こともできる
  • 前置引数に対応
  • キーボードマクロに対応
  • remapに対応

しくみ

基本的には, プレフィックスキーを上書きして特別なコマンドを割り当てて, そのコマンドの中で一定時間だけ次の入力を待ってみて, 入力があれば最初のキーと新たに来たキーを再度入力イベントキューに戻し, タイムアウトした場合はdefine-intercept-keyに指定したコマンドを実行するだけ. タイムアウトありの入力待ちはsit-forread-eventでできる. 入力イベントキューの操作はunread-command-eventsをいじることでできる. ただ, これを愚直にやると次のような問題がある.

  1. プレフィックスキーを潰してしまうとそのプレフィックスではじまるすべてのキーバインドの情報が消えてしまう
  2. 入力イベントキューにキーイベントを戻すとまた同じコマンドが実行されてしまって無限ループする
  3. キーボードマクロと相性が悪そう
emulation-mode-map-alists

実は1.と2.を解決するためのうまい方法がある. Emacsのキーマップはかなり複雑に階層化されていて, ある条件を満たすときだけ特定のキーマップを有効化して, 何らかのエミュレーションをやりたい, という場合に使える, emulation-mode-map-alistsという変数がある. この変数にはキーマップの連想配列を複数入れることができ, それぞれの連想配列の要素は, carのシンボルが表す変数の値がnilでないときだけcdrのキーマップが有効になるというもの.

1.に関しては, プレフィックスキーが実際に登録されているローカルマップやグローバルマップは触らずに, emulation-mode-map-alistsに新たにキーを登録することで解決する. 実際にViper/Vimpulse/Evilはこの方法でESCの挙動を実装している.

2.に関しては, 入力イベントキューにキーイベントを戻すときにはエミュレーション層のキーマップを一時的に無効にすればよい. Evilの実装がこの手法を使っている.

コマンドの実行とキーボードマクロ

一見すると, タイムアウトしたときのコマンドの実行処理はそのコマンドをcall-interactivelyで呼べばよいだけのように思える. さらに言うなら, 2.の問題にしても, そもそもキーイベントを入力イベントキューに戻さずに, エミュレーション層以外のキーマップをlookup-keyして, 見つかったコマンドを実行すればよいだけに思える. 実際, Viperの実装はこの手法を使っている.

Evilがこれを採用していないのは, このやり方だとキーボードマクロが使えないため. キーボードマクロの再生中はsit-forタイムアウトしないため, 挙動が変わってしまう. そのためEvilでは, 実際のキー入力では生じないダミーのキーイベント(たとえばESCのキーイベント"\e"の代わりに[escape]というシンボル列)をemulation-mode-map-alistsに登録しておき, 入力イベントキューには本物のキーイベントではなくこのダミーのイベントを戻すようにしている. key-interceptはこれに加え, ダミーのイベントとの中間層のイベントも使っている.

中間層のダミーイベント

以下の場合を考えてみよう.

(define-key (kbd "C-c C-j") 'a-command)
(define-intercept-key (kbd "C-c") 'some-command)
(define-intercept-key (kbd "C-c C-j x") 'some-other-command)

ここまでの議論で, some-commandは実際にはたとえばダミーイベント[intercept-C-c]に割り当て, some-other-commandは実際にはたとえばダミーイベント[intercept-C-c_C-j_x]に割り当てられている. エミュレーション層のキーマップでは(kbd "C-c")には特別な関数が割り当てられていて, この関数はsit-forしてから'intercept-C-cを入力イベントキューに戻すか, さもなければ次のキー(たとえばC-j)とあわせて(kbd "C-c C-j")を入力イベントキューに戻してエミュレーション層のキーマップを次のコマンド実行まで無効にする.

これでC-c C-j xと入力したらどうなるか. a-commandが実行された後にxが入力されてしまう! some-other-commandが実行されるためには, C-c C-jまで入力された時点で, エミュレーション層のC-cに登録されている関数と似たようなもの(sit-forするもの)を実行する必要がある.

これを実現するため, key-interceptでは中間層のダミーイベント(prefixイベントと呼ぶ)を用意した. まずはじめにエミュレーション層のC-cはprefixイベント[prefix-C-c]を入力イベントキューに入れる. このprefixイベントのハンドラはやはりエミュレーション層に登録されていて, 次の入力C-jを受け取った場合はさらなるprefixイベント[prefix-C-c_C-j]を入力イベントキューに入れる. 同様にして[prefix-C-c_C-j]のハンドラによってprefixイベント[prefix-C-c_C-j_x]が発生する. [prefix-C-c_C-j_x]のハンドラは, タイムアウトしたときに, 実際にdefine-intercept-keyで登録されたコマンドの実行を促すイベント(これをinterceptイベントと呼ぶ)[intercept-C-c_C-j_x]を発生させる. つまりsome-other-commandはこのイベントのハンドラとして登録されていればよいことになる.

key-interceptでは, define-intercept-keyされていないC-c C-jのようなキーが入力されてタイムアウトしたときも, 同様にinterceptイベント[intercept-C-c_C-j]を発生させるようにしている. ただし, この場合はdefine-intercept-keyで登録されたinterceptイベントのためのキーマップよりも優先度の低いキーマップがあり, そこに登録されたハンドラは, 受け取ったinterceptイベントを通常のイベントに戻した(kbd "C-c C-j")を発生させ, エミュレーション層を一時無効化するようになっているので, 正しくa-commandが呼ばれることになる.

関連する実装

Viper/Vimpulse/Evil以外に参考にしたものや, 近い発想のもの.

key-combo

入力待ちをして, タイムアウトするか, 次のキーが何かによって挙動を変える機能を提供. ソースコードをすべて読んだわけではないけれど, Viperと同様にキーバインドから呼ぶべきコマンドを見つけてきてcall-interactivelyしている感じ. この日記のEmacsでキーボードイベントを扱う方法まとめ - むしゃくしゃしてやったはよくまとまっていて助かった.

key-chord

キーの同時押しに対して特別なハンドラを登録する機能を提供. 一定時間内にあった入力を同時押しとみなすという方法. ただしinput-method-functionを使っていて, 特定の範囲のキーのみに対してしかハンドラを割り当てられない. キーボードマクロに対応している.