Emacs 風テキスト・エディタ向けの端末キー入力手順

Emacs 風のコマンド入力を解釈するのに必要な端末入力機能は、 コード・ポイント 1 個分の文字列を get メッセージで得ることができ、 unget でコード・ポイント 1 個分の文字列を読み戻せなければなりません。 さらに、 一連のキー入力の途中でエコー表示をするため、 端末がコード・ポイントを読み取り可能になっているかどうかを調べられるようになっていると良いでしょう。 読み取り可能でないときは、 そこまで読んだ内容をエコー表示して、 新たなキー入力を待つことになるからです。 入力とは別に矢印キー等のエスケープ・シーケンスをキー名に関連付ける辞書も必要です。 これらを作ります。

require 'io/console'

class TTY
  RDBUF_COUNT = 64

  def self.open(path)
    Kernel.open(path, 'r') {|ttyin|
      Kernel.open(path, 'w') {|ttyout| yield new(ttyin, ttyout) }
    }
    nil
  end

  def initialize(ttyin, ttyout)
    @ttyin, @ttyout = ttyin, ttyout 
    @rdbuf = ''.b
    @bytes = ''.b
    @u8bytes = ''.b
    @keystr = ''
    @keypos = 0
  end

  def raw() @ttyin.raw { yield }; self end
  def print(*a) @ttyout.print(*a); self end
  def flush() @ttyout.flush; self end
  def clear_to_eol() print "\e[K" end

#@<get@>
#@<ready?@>
#@<unget@>
#@<keypad@>
#@<splice_utf8@>
end

ありがたいことに、 Ruby では、 キー入力向けの readpartial と read_nonblock の 2 つのメッセージを IO オブジェクトに送ることができます。 元々は Socket 入力のためのものらしいのですが、 Linux ではキー入力にも利用可能です。 readpartial は入力バッファが空のときは空でなくなるまで待ち、 入力バッファへの再補充なしで入っている分だけを読み取ります。 read_nonblock は入力バッファが空のときは即座に終了し、 空でないときは readpartial と同じく再補充なしで入っている分を読み取ります。

get は、 まず readpartial で読み取り、 続いて read_nonblock で読める分を読み続けさせます。 ただし、 ここで一つ注意点があり、 両方の読み取りメッセージともにバイナリ読み取りをします。 キー・タイプした文字だけではなく、 入力メソッドが確定した文字列と端末へカット & ペーストした文字列も読み取り内容であり、 utf-8 のシーケンスの途中で転送打ち切りが生じることがあります。 そのとき単純に入力内容を入力キー文字列へ付け加えようとすると、 不完全な utf-8 シーケンスで終わっているときは、 異常とみなして例外が発生します。 これを避けるには、 入力したバイナリ列から utf-8 の完全なコード・ポイント列を切り出していくしかありません。

#@<get@>=
  def get()
    if @keypos >= @keystr.size
      @keystr.clear
      @keypos = 0
      while @keystr.empty?
        @ttyin.readpartial(RDBUF_COUNT, @rdbuf)
        begin
          @bytes << @rdbuf
        end while String === @ttyin.read_nonblock(RDBUF_COUNT, @rdbuf, exception:false)
        @keystr << splice_utf8(@bytes)
      end
    end
    ch = @keystr[@keypos]
    @keypos += 1
    ch
  end

splice_utf8 メソッドはバイナリ列から utf-8 として切り出せる部分を utf-8 エンコーディングの文字列にして返します。 その際、 バイナリ列から切り出した部分を削除します。 途中で途切れた中途半端な列はバイナリ列に残り、 次回の補充で完全な utf-8 になってくれるだろうと期待しています。

#@<splice_utf8@>=
  def splice_utf8(bytes)
    @u8bytes.clear.force_encoding(Encoding::BINARY)
    n = bytes.size
    i = 0
    while i < n
      u = bytes[i].ord
      len = u < 0x80 ? 1 \
          : u < 0xc0 ? 0 : u < 0xe0 ? 2 : u < 0xf0 ? 3 : u < 0xf8 ? 4 \
          : 0
      if len < 1
        i += 1
      else
        i + len <= n or break
        @u8bytes << bytes[i, len]
        i += len
      end
    end
    bytes[0, i] = '' if i > 0
    @u8bytes.force_encoding(Encoding::UTF_8)
  end

ready? メソッドは、 端末に即座に読み取れる未読文字列があるなら真を返します。 バッファが空でないときは未読ありですし、 バッファが空でも IO オブジェクトで未読があるときは偽を返します。 readpartial を使わず、 read_nonblock を一回だけ使うという点が異なりますが、 やっていることは get に類似しています。

#@<ready?@>=
  def ready?()
    if @keypos >= @keystr.size
      @keystr.clear
      @keypos = 0
      if String === @ttyin.read_nonblock(RDBUF_COUNT, @rdbuf, exception:false)
        @bytes << @rdbuf
        @keystr << splice_utf8(@bytes)
      end
    end
    @keypos < @keystr.size
  end

読み戻しメソッドは、 バッファの読み込んだ箇所へ文字列を書き戻します。 バッファのエンコーディングutf-8 なので、 utf-8 エンコーディングの文字列をそのまま挿入することができます。

#@<unget@>=
  def unget(ch)
    if @keypos <= 0
      @keystr[@keypos, 0] = ch
    else
      @keypos -= 1
      @keystr[@keypos] = ch
    end
    self
  end

矢印キー等のキーコードの関連付ける辞書に代表的なキーを登録しておきます。

#@<keypad@>=
  def keypad()
    @keypad ||= {
      "\e[3~" => :Del,
      "\e[A" => :Up,
      "\e[B" => :Down,
      "\e[C" => :Right,
      "\e[D" => :Left,
      "\e[1;5C" => :ControlRight,
      "\e[1;5D" => :ControlLeft,
      "\e[5~" => :PageUp,
      "\e[6~" => :PageDown,
      "\e[H" => :Home,
      "\eOH" => :Home,
      "\e[F" => :End,
      "\eOF" => :End
    }
  end

コマンド入力解釈と合わせると、 Emacs のエコー領域だけのようにふるまうおもちゃができます。 C-x C-c を入力するまで、 キー入力を解釈しては、 コマンド名を引数付きで表示し続けます。

XTerm.open('/dev/tty') {|tty|
  tty.raw {
    begin
      cmd = Command.match(keymap, tty) {|s|
        tty.ready? or tty.print("\r").clear_to_eol.print(s.to_s).flush
      }
      tty.print("\r").clear_to_eol.print(cmd.to_s).flush
      if :self_insert_command == cmd.name
        tty.print("\r\n(%p %p %p)\r\n" % [cmd.name, cmd.arg, cmd.string]).flush
      else
        tty.print("\r\n(%p %p)\r\n" % [cmd.name, cmd.arg]).flush
      end
    end until cmd.name == :quit
  }
}