Emacs 風のキー入力部

Emacs のキー入力は、 コマンドに結びつけたキー入力文字列の前に数値引数をくっつけることができます。 この前置引数には 2 通りの打ち込み方があります。 私が良く使う汎引数と、 めったに使わない数値引数です。 デフォルトでは、 汎引数 universal-argument は C-u に結合してあり、 数値引数 digit-argument を ESC 2 等へ、 負引数 negative-argument を ESC - に結合してあります。 例えば、 64 個のゼロを打ち込むときは、 次のようにします。 C-u だけを連続して打ち込むと、 4 をかけていくので、 3 つの C-u は 64 になります。 ESC は途中に打っても良いのですが、 C-u では途中に数字だけを並べます。 C-u と ESC のどちらも引数の終わりを指定したいとき、 C-u を打ちます。 それぞれの場合の GNU Emacs がコマンドを呼び出す式も並べておきました。 連打汎引数のときは数値のリストで、 数値引数は数値を実引数にします。

0                           (self-insert-command '())
C-u C-u C-u 0               (self-insert-command '(64))
C-u 6 4 C-u 0               (self-insert-command 64)
ESC 6 4 C-u 0               (self-insert-command 64)
ESC 6 ESC 4 C-u 0           (self-insert-command 64)

負の数を与えることもできます。 頻繁に使うのはマイナス 1 引数の kill-line (C-k) です。 これで前の行の行頭からポイントまでの間を削除してキル・リングに加えます。 マイナス 1 のときは数字の 1 を省略することができます。 これも同じ引数になる打ち込み方が何通りもあります。

C-k                         (kill-line '())
C-u - C-k                   (kill-line -1)
C-u - C-u C-k               (kill-line -1)
C-u - 1 C-k                 (kill-line -1)
C-u - 1 C-u C-k             (kill-line -1)
ESC - C-k                   (kill-line -1)
ESC - C-u C-k               (kill-line -1)
ESC - 1 C-k                 (kill-line -1)
ESC - 1 C-u C-k             (kill-line -1)
ESC - ESC 1 C-k             (kill-line -1)
ESC - ESC 1 C-u C-k         (kill-line -1)

なお、 数値引数でも C-u が終了記号で、 下の ESC f は forward-word コマンドとして解釈します。 これから、 引数の終わりかどうかの判定に ESC のもう一つ先の文字までの先読みが必要なことがわかります。

ESC f                       (forward-word '())
C-u ESC f                   (forward-word '(4))
ESC - ESC f                 (forward-word -1)

recenter (C-l) コマンドは、 汎引数文字 1 個のときと、 引数に 4 を指定したときに、 動作を変えています。 このような場合分けは、 4 のリストのときと、 数値 4 で区別します。

C-l                         (recenter '())
C-u C-l                     (recenter '(4))
C-u 4 C-l                   (recenter 4)

以上のような前置詞引数付きキー入力文字列の解析器を作ります。

キー結合は、 入れ子 Hash によるトライ木 keymap で表します。 簡単にするため、 ごく一部のコマンドだけを keymap に入れておきます。 もう一つ、 単純化のため、 universal-argument には 1 文字のキー・シーケンスに限って結合するとしておきます。 ESC - や ESC 2 等は、 これ以外の結合はありえないので、 なくても良さそうですが、 これらが keymap にあるときに限って引数を打ち込めるようにするために、 表立って記述しておきます。 :print は印字可能文字を表します。 :Left はキーボードの矢印キーを表し、 端末が送ってきた文字列を端末の keypad 辞書を使って :Left を引いたものを使います。

keymap = {
  "\C-f" => :forward_char,
  "\C-n" => :next_line,
  "\C-u" => :universal_argument,
  "\C-x" => {
    "\C-c" => :save_buffers_kill_editor,
  },
  "\e" => {
    'f' => :forward_word,
    '-' => :negative_argument,
    '0' => :digit_argument,
    '1' => :digit_argument,
    '2' => :digit_argument,
    '3' => :digit_argument,
    '4' => :digit_argument,
    '5' => :digit_argument,
    '6' => :digit_argument,
    '7' => :digit_argument,
    '8' => :digit_argument,
    '9' => :digit_argument,
  },
  :print => :self_insert,
  :Left => :forward_char,
}

TTY.open {|tty|
  p tty.keypad["\e[D"] #=> :Left
  begin
    cmd = Command.match(keymap, tty)
    puts '(%p %p)' % [cmd.name, cmd.arg]
  end until cmd.name == :quit
}

keymap にマッチする入力文字列を tty から受け取って、 Command クラスのインスタンスを返します。 このインスタンスは、 コマンド名の @name、 前置引数 @arg、 入力文字列 @string のコンテナになってます。

class Command
  def self.match(keymap, tty, &blk) new.match(keymap, tty, &blk) end

  attr_reader :name, :arg
  def string() @string[@pos .. -1] end

  def initialize()
    @name = @arg = nil
    @pos = 0
    @string = ''
    @digit = 0
    @universal_argument_char = nil
  end

#@<match@>
#@<isprint?@>

private
#@<arg_push@>
#@<key_get, key_unget@>
#@<start@>
#@<keymap_child@>
#@<escape@>
#@<ansi_seq@>
#@<digit_argument@>
#@<universal_argument@>
#@<end_argument@>
end

キーから読み込んだ文字列の最後の文字、 制御 ctrl、 継続 kont の組で状態遷移を繰り返し、 ctrl が stop になったら停止します。 ctrl は Command クラスのプライベート・メソッド名にしてます。 インスタンス変数 @digit は数値引数の値を求めるための作業変数です。

最初のキーを読むとき、 キー入力待ちコールバックを呼ばずに直接 tty.get しています。 これは、 コールバックがエコー領域を書き直すことを想定していることに関係しており、 もしも直前のコマンドによってエコー領域にメッセージ出力があったとき、 最初のキー入力までの間、 そのメッセージを表示したままにしておきたいためです。

#@<match@>=
  def match(keymap, tty, &blk)
    @display_callback = blk
    @name = @arg = nil
    @pos = 0
    @string.clear
    @digit = 0
    @universal_argument_char = keymap.key(:universal_argument)
    @string << tty.get
    ctrl, kont = :start, [:stop, nil]
    while ctrl != :stop
      ctrl, kont = send(ctrl, keymap, tty, kont)
    end
    @display_callback = nil
    self
  end

今回は ASCII 制御文字でない文字を印字可能文字として扱うことにします。

#@<isprint?@>=
def isprint?(u)
  (0x20 <= u && u <= 0x7e) || 0xa0 <= u
end

前置引数値 @arg を、 引数なしのときに nil、 数値引数のときに Integer、 汎引数列だけのときは Array にします。 さらに、 負引数だけなら -1 にします。 この事情から、 数値の算出のために @arg をそのまま使うのでは、 積算変数 @digit を使って計算した結果を @arg に入れていくやりかたにします。

#@<arg_push@>=
  def arg_push(c)
    @arg = @digit = @arg < 0 ? @digit * 10 - c.to_i : @digit * 10 + c.to_i
  end

キー入力は、 @string の末尾へ一文字追加します。 ここでは tty.get をユニコードのコード・ポイントを一つ読み取って返す動作をすると仮定しています。 key_unget は @string の末尾を読み戻しをします。 ここでも、 tty.unget はコード・ポイント単位で読み戻せるものとします。

#@<key_get, key_unget@>=
  def key_get(keymap, tty, kont)
    @display_callback&.call(self)
    @string << tty.get
    kont
  end

  def key_unget(keymap, tty, kont)
    tty.unget(@string[-1])
    @string.chop!
    kont
  end

start はコマンド文字列の先頭の状態です。 この段階で、 キー入力文字 c は、 汎引数文字のとき、 コマンドの先頭文字、 エスケープ文字のいずれかです。 汎引数文字のとき、 arg の暫定値を 4 を入れた配列にします。 この暫定値は数値入力が続くと整数に上書きされます。 そして universal_argument 状態へ遷移し、 その際の戻り先として start 状態を継続に記録します。

印字可能文字のときは、 コマンド名に self-insert-command を選んで、 継続摘要で stop 状態へ遷移します。

Hash オブジェクトのときは、 keymap トライ木の子ノードへと進んでいくため、 状態を keymap_child にします。 ただし、 エスケープ文字だけは特別扱いしたいため、 状態を escape にします。

これ以外のときは、 コマンド名が Symbol オブジェクトで求まっているか、 未登録コマンドとして nil になっているかのどちらかです。 継続摘要で stop 状態へ遷移します。

#@<start@>=
  def start(keymap, tty, kont)
    c = @string[-1]
    @pos = @string.size - 1
    @name = keymap[c]
    if :universal_argument == @name && @arg.nil?
      @arg = [4]
      [:key_get, [:universal_argument, [:start, kont]]]
    elsif @name.nil? && keymap.key?(:print) && isprint?(c.ord)
      @name = keymap[:print]
      kont
    elsif Hash === @name
      ctrl = ("\e" == c) ? :escape : :keymap_child
      [:key_get, [ctrl, kont]]
    else
      kont
    end
  end

keymap_child 状態では、 入力文字が keymap で Hash オブジェクトに結ばれている間は、 トライ木の子ノードへ移っていきます。 トライ木の葉である Symbol オブジェクトまたは nil に到達した時点で、 継続摘要で stop 状態へ遷移します。

#@<keymap_child@>=
  def keymap_child(keymap, tty, kont)
    @name = @name[@string[-1]]
    if Hash === @name
      [:key_get, [:keymap_child, kont]]
    else
      kont
    end
  end

入力文字がエスケープ文字のときは、 負引数 negative-argument と数引数 digit-argument のときに、 戻り先を start 状態にした継続で数引数状態 digit_argument を状態呼び出しします。 負引数の暫定値を -1 にし、 数引数の値は数字から求めます。

端末の矢印キー等を押すと、 ANSI エスケープ・シーケンスが送られてきます。 このエスケープ・シーケンスを一文字と同じ扱いにしておかないと、 keymap に未登録のキーに対して、 エスケープ・シーケンスの中身を self-insert-command で挿入しようとしてしまいます。 そのため、 エスケープ・シーケンスの終わりまで読み取ってから、 keymap を引くようにします。

#@<escape@>=
  def escape(keymap, tty, kont)
    c = @string[-1]
    @name = @name[c]
    if Hash === @name
      [:key_get, [:keymap_child, kont]]
    elsif @name == :negative_argument && @arg.nil?
      @arg = -1
      [:key_get, [:digit_argument, [:start, kont]]]
    elsif @name == :digit_argument && @arg.nil?
      @arg = @digit = c.to_i
      [:key_get, [:digit_argument, [:start, kont]]]
    elsif '[' == c
      [:key_get, [:ansi_ques, kont]]
    elsif 'O' == c
      [:key_get, [:ansi_seq, kont]]
    else
      kont
    end
  end

端末から送られてくる ANSI エスケープ・シーケンスのうち、 矢印キー用のものは、 SSI か SS3 のどちらかです。 これのパターンに沿って入力文字に対する状態遷移をおこなってから、 キー・コードを引いて、 さらにそれを keymap から引いてコマンド名を求めます。

#@<ansi_seq@>=
  # %r/\e(?:\[[?]?|O)[0-9;]*./
  def ansi_ques(keymap, tty, kont)
    if '?' == @string[-1]
      [:key_get, [:ansi_seq, kont]]
    else
      [:ansi_seq, kont]
    end
  end

  def ansi_seq(keymap, tty, kont)
    c = @string[-1]
    if ('0' .. '9').include?(c) || ';' == c
      [:key_get, [:ansi_seq, kont]]
    else
      @name = keymap[tty.keypad[@string[@pos .. -1]]]
      kont
    end
  end

数値引数と負引数では、 間に挟まって良いエスケープ記号を読み飛ばしつつ、 最後の省略可能な C-u も読み飛ばして、 それが終わると継続摘要で状態 start へ戻ります。 エスケープ文字の次まで先読みしているときは、 unget で読み戻しをしておきます。

#@<digit_argument@>=
  def digit_argument(keymap, tty, kont)
    c = @string[-1]
    if ('0' .. '9').include?(c)
      arg_push(c)
      [:key_get, [:digit_argument, kont]]
    elsif "\e" == c
      [:key_get, [:digit_esc_argument, kont]]
    elsif @universal_argument_char == c
      [:key_get, [:end_argument, kont]]
    else
      kont
    end
  end

  def digit_esc_argument(keymap, tty, kont)
    c = @string[-1]
    if ('0' .. '9').include?(c)
      arg_push(c)
      [:key_get, [:digit_argument, kont]]
    elsif @universal_argument_char == c
      [:key_get, [:end_argument, kont]]
    else
      [:key_unget, kont]
    end
  end

汎引数では、 マイナス記号に対して暫定値を -1 にする他は、 数字を読み取っていくだけです。 汎引数文字を見つけると状態 end_argument へ遷移します。

#@<universal_argument@>=
  def universal_argument(keymap, tty, kont)
    c = @string[-1]
    if '-' == c
      @arg = -1
      [:key_get, [:univ_digit_argument, kont]]
    elsif ('0' .. '9').include?(c)
      @arg = @digit = c.to_i
      [:key_get, [:univ_digit_argument, kont]]
    elsif @universal_argument_char == c
      @arg = [16]
      [:key_get, [:end_argument, kont]]
    else
      kont
    end
  end

  def univ_digit_argument(keymap, tty, kont)
    c = @string[-1]
    if ('0' .. '9').include?(c)
      arg_push(c)
      [:key_get, [:univ_digit_argument, kont]]
    elsif @universal_argument_char == c
      [:key_get, [:end_argument, kont]]
    else
      kont
    end
  end

ここでの独自設定として、 引数の終わりに汎引数文字が連続して複数並んでいてもかまわないことにしています。 先頭から汎引数だけで数値の入力がないとき、 引数を数の配列にしてます。 それを 4 倍していきます。

#@<end_argument@>=
  def end_argument(keymap, tty, kont)
    if @universal_argument_char == @string[-1]
      @arg[0] *= 4 if Array === @arg
      [:key_get, [:end_argument, kont]]
    else
      kont
    end
  end