ls (1) コマンドのカラム形式出力の真似

カレント・ディレクトリを ls (1) コマンドのカラム形式の真似をして表示します。 だいぶ前に書いたコードがあるのですが、 必要以上に複雑に書きすぎていて反省。 今度は、 必要最小限のシンプルさを心がけてみました。

話を簡単にするため、 ディレクトリ中のエントリ名はすべて 7 ビット ASCII の印字可能文字だと仮定し、 端末上でのカラム数が文字数に一致するとします。 全角・半角が混じるときは、 カラム幅を wcwidth (3) の戻り値にあいまいな文字幅を考慮して求めれば良いでしょう。

def column_width_of_string(s)
  s.grapheme_clusters.inject(0) {|r, c| r + mock_wcwidth(c.ord) }
end

def mock_wcwidth(u) 1 end

カラム形式は、 カラム間に padding 個のスペースを挟み、 カラム幅はそのカラム中の最大幅に揃えたもので、 可変長のタブ・スキップに相当します。 まず左端のカラムに上から下へとエントリ名を並べ、 2 番目以降のカラムに同様に上から下へと並べていきます。 このとき、 画面幅 sizex を越えない、 最も行数が少なくて済む配置を選びます。

今回使ってみるやりかたは、 まず行数を 1 として、 全部横に並べてみます。 画面幅を越えるときは、 行数を 2 にして並べます。 そうやって、 行数を単調に増やしていって最初に見つけた画面幅に入る行数を探しだします。 ただし、 カラム幅が全部 1 であっても画面幅を越える行数より少ない行数で試みるのは無駄ですので、 行数を 1 からではなく、 カラム幅が全部 1 として見積もった行数から探索を始めます。

def layout_column(list, padding, sizex)
  sizes = list.map {|s| column_width_of_string(s) }
  rows_first = sizes.size / [sizex / (padding + 1), sizes.size].min
  (rows_first ... sizes.size).each do |rows|
    cols = (sizes.size + rows - 1) / rows
    tabskip = (0 ... cols).map {|i|
      first = rows * i
      last = [rows * (i + 1), sizes.size].min
      sizes[first ... last].max
    }
    width = tabskip.inject(0) {|r, x| r + x + padding }
    return [rows, tabskip] if width < sizex
  end
  [sizes.size, [sizes.max]]
end

これで行数とカラム幅が求まるので、 それを頼りに表示していきます。 一行中に行数分離れた位置にあるエントリ名を順に並べます。 その際、 エントリ名の間を欄飛ばしの空白で埋めておけばできあがりです。

def list_column(list, padding, sizex, out=STDOUT)
  rows, tabskip = layout_column(list, padding, sizex)
  (0 ... rows).each do |row|
    i = row
    tabskip.each_index do |j|
      if i < list.size
        out << ' ' * (tabskip[j - 1] + padding - list[i - rows].size) if j > 0
        out << list[i]
      end
      i += rows
    end
    out << "\n"
  end
  nil
end

list_column(Dir['*'], 2, 80)

Emacs 風のキー入力部 (2) 単独 ESC 文字の受け入れ

前のキー入力部は、 ESC f のように、 ESC に文字を組み合わせることを前提にしていました。 そのため、 keymap で ESC にシンボルを指定しても、 ESC を押した後にキー入力待ちをしてしまいました。 これでは、 インクリメンタル・サーチのように ESC を単独で使う処理の入力部として使えません。

keymap = {
  :print => isearch_insert_char,
  "\C-s" => isearch_repeat_char,
  "\C-r" => isearch_reverse_char,
  "\x7f" => isearch_undo,
  "\e" => isearch_exit,
  "\C-g" => isearch_cancel,
}

ところで、 上のように ESC に単独で意味を持たせているとき、 インクリメンタル・サーチの最中に矢印キー類を押したらどうふるまうべきでしょう。 矢印キーを押すと ESC [ C のような文字列を送り込んできます。 これの先頭の ESC をインクリメンタル・サーチの終了を示す ESC と認識されてしまうと、 サーチから抜けた時点の現点に [ Cの 2 文字を挿入することでしょう。 これはキー入力した人の意図したふるまいとは思えません。

前のキー入力部では ESC に文字を組み合わせることを前提にしていたので、 専用の状態を追加して、 矢印キー類の文字列をまとめて読み込んでいました。 今度は、 単独 ESC を許す場合も、 矢印キー類の文字列をまとめて読む必要があります。 そこで、 ESC を読んだらすぐに tty.ready? で端末から入力待ちせずに文字を読めることを調べ、 矢印キー類の始まりをすぐに読めるなら、 それを読む状態へ遷移することにします。 そうでないとき、 もしも keymap に ESC が trie 木の子に関連付けされていないなら、 unget で読み戻してから、 ESC に関連付けている名前を返します。 tty.ready? で端末から直ちに読める文字がないとき、 もしも keymap に ESC が trie 木の子に関連付けされていないなら、 即座に関連付けされた名前を返します。 それ以外は、 前と同じで、 数値引数・負引数・trie 木の子の読み込みに遷移します。

start 状態で ESC を読んだら、 key_get せずに escape 状態へ移ります。 他の選択肢は前と同じです。

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

escape 状態で直ちに読み取り可能な文字があるときは、 それから矢印キー類の文字列の始まりかどうかの判定をおこないます。 keymap に ESC が trie 木の子に関連付けされているときは、 先読みした文字で子ノードへ移らせます。 そうでないときは unget します。 escape 状態に入る前に、 既に @name へ ESC が関連付けてある名前が入っているので、 unget したら継続摘要で stop 状態に抜けます。 直ちに読めないとき、 keymap に ESC が trie 木の子へ関連付けされているときは、 key_get で一文字読んでから子ノードへ移らせます。 そうでないときは、 @name に関連付けが終わっているので、 stop 状態に抜けます。

#@<escape@>=
  def escape(keymap, tty, kont)
    if tty.ready?
      @string << tty.get
      if '[' == @string[-1]
        [:key_get, [:ansi_ques, kont]]
      elsif 'O' == @string[-1]
        [:key_get, [:ansi_seq, kont]]
      elsif Hash === @name
        [:esc_map_child, kont]
      else
        [:key_unget, kont]
      end
    elsif Hash === @name
      [:key_get, [:esc_map_child, kont]]
    else
      kont
    end
  end

#@<esc_map_child@>

esc_map_child 状態は、 前の escape 状態から矢印キー類の遷移を除いたものです。

#@<esc_map_child@>=
  def esc_map_child(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]]]
    else
      kont
    end
  end

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
  }
}