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

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

Point2d = Struct.new(:y, :x)

class ColumnLayout
  attr_reader :size, :tabskip, :string

  def initialize(width)
    @size = Point2d.new(0, width)
    @tabskip = []
    @string = ''
  end

#@<render@>

private

#@<layout>
#@<column_width_of_string>
end

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

#@<column_width_of_string>=
  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 番目以降のカラムに同様に上から下へと並べていきます。 このとき、 画面幅 size.x を越えない、 最も行数が少なくて済む配置を選びます。

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

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

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

#@<render@>=
  def render(list, padding=2)
    layout(list, padding)
    @string = ''
    (0 ... size.y).each do |row|
      i = row
      @string << "\n" if row > 0
      @tabskip.each_index do |j|
        if i < list.size
          if j > 0
            @string << ' ' * (@tabskip[j - 1] + padding - list[i - size.y].size)
          end
          @string << list[i]
        end
        i += size.y
      end
    end
    self
  end

画面が 80 桁の幅として、 カレント・ディレクトリの一覧をカラム形式で表示するには次のようにします。

table = ColumnLayout.new(80).render(Dir["*"], 2)
puts table.string