端末のスクロール機能を使った簡易版 refresh

xterm は ANSI 画面制御シーケンスに、 行削除 (\e[L) と行挿入 (\e[M) をもっています。 1 行だけでなくパラメータで行数を指定して、 複数の行を一度に削除したり挿入することもできます。 挿入するのは空行です。 さらに、 この 2 つを組み合わせることで、 画面上の行範囲内をスクロール・アップしたり、 スクロール・ダウンすることができます。 この手の制御シーケンスを試すには、 ruby の io/console ライブラリが便利です。 このライブラリが IO オブジェクトに追加する raw メソッドで、 端末ドライバを raw モードにして、 制御シーケンスを画面に送り込みます。

require 'io/console'

class Tty
  attr_reader :ttyout

  def initialize()
    @ttyout = nil
  end

  def open(path='/dev/tty')
    Kernel.open(path, 'w') do |out|
      out.raw do
        begin
          @ttyout = out
          yield self
        ensure
          @ttyout = nil
        end
      end
    end
    self
  end

  # (y0 ... y1) の行範囲内を count 分スクロールします。
  #
  #           scroll_up(0,8,2)    scroll_down(0,8,2)
  #       y0==0 |A|      |C|        |A|      | |
  #           1 |B|      |D|        |B|      | |
  #           2 |C|      |E|        |C|      |A|
  #           3 |D|      |F|        |D|      |B|
  #           4 |E|  =>  |G|        |E|  =>  |C|
  #           5 |F|      |H|        |F|      |D|
  #           6 |G|      | |        |G|      |E|
  #           7 |H|      | |        |H|      |F|
  #       y1==8 |I|      |I|        |I|      |I|
  #
  def scroll_up(y0, y1, count)
    y0 < y1 && count > 0 && y0 < y1 - count or raise ArgumentError
    set_cursor(y0, 0)
    delete_line(count)
    set_cursor(y1 - count, 0)
    insert_line(count)
  end

  def scroll_down(y0, y1, count)
    y0 < y1 && count > 0 && y0 + count < y1 or raise ArgumentError
    set_cursor(y1 - count, 0)
    delete_line(count)
    set_cursor(y0, 0)
    insert_line(count)
  end

  def winsize() @ttyout.winsize end
  def set_cursor(y, x) @ttyout.print "\e[#{y + 1};#{x + 1}H" end
  def clear_to_eol() @ttyout.print "\e[K" end
  def clear_to_eos() @ttyout.print "\e[J" end
  def clear() @ttyout.print "\e[2J" end
  def insert_line(x) @ttyout.print "\e[#{x}L" end
  def delete_line(x) @ttyout.print "\e[#{x}M" end
  def print(*a) @ttyout.print(*a) end
  def flush() @ttyout.flush end
end

端末のスクロール機能を使った、 curses の機能限定版を ruby で書いてみます。 この限定版ではウィンドウ 1 枚だけとし、 画面の左上にウィンドウの右上を固定します。 curses のスクリーンに似たものが一つだけあるとみなせば良いでしょう。 スクリーンの幅と高さは画面内なら自由に選ぶことができます。 ただし、 スクリーンは幅や高さを越えた分をトリミングしないので、 画面が乱れないように lines に書き込む側のは利用する側の役割とします。

require_relative 'ucd/wcwidth'

class Screen
  class Point2d
    attr_accessor :x, :y
  end

  attr_reader :lines, :size, :cursor
  attr_accessor :east_asian_width_ambigious

  def initialize(tty)
    @tty = tty
    @lines = []
    @size = Point2d.new
    @size.y, @size.x = @tty.winsize
    @size.y -= 2
    @cursor = Point2d.new
    @cursor.y = @cursor.x = 0
    @east_asian_width_ambigious = 2

    @previous = []
    @lines_hashval = []
    @previous_hashval = []
  end

#@<全画面を消去して lines 配列を空文字列で埋めます@>
#@<lines の内容を端末画面へ反映します@>

  def refresh_cursor()
    @tty.set_cursor(@cursor.y, @cursor.x)
    @tty.flush
    self
  end
end

通常、 Screen クラスのインスタンスを作った直後に size を設定して clear メッセージをレシーバへ送ります。 これによって、 端末画面全体を消去し、 lines 配列を空文字列で埋めます。

#@<全画面を消去して lines 配列を空文字列で埋めます@>=
  def clear()
    @lines.clear
    @previous.clear
    @previous_hashval.clear
    @lines_hashval.clear
    @size.y.times do |i|
      @previous[i] = ''
      @lines[i] = ''
      @previous_hashval[i] = @previous[i].hash
      @lines_hashval[i] = @lines[i].hash
    end
    @tty.clear
    self
  end

スクリーンの表示内容に、 行の配列 lines を使います。 一つの行は String オブジェクトとし、 この中には空白とフォントで表示可能な印字文字だけが含まれていることを前提にします。 改行文字を含んではいけませんし、 タブは空白に展開済みであるとします。 もちろん、 表示属性を設定する制御シーケンスも含んではいけません。 表示属性の指定は省いてあり、 すべての印字文字は同じ属性で表示するものと簡単化しています。 範囲を越える分のトリミングをしないので、 lines に書き込む段階でトリミングや行の折り返しをおこなっておく必要があります。 そうした、 行のこまごまはすべて lines に書き込む役目をもったオブジェクトがやってくれるであろうとしています。

レイアウトが済んで lines 配列への書き込みが終わったところで、 Screen オブジェクトへ refresh メッセージを送ります。

#@<lines の内容を端末画面へ反映します@>=
  def refresh()
    diff_heckel()
    flip()
    self
  end

#@<更新前と現在にそれぞれ一度のみ出現する同じ行を抽出します@>
#@<Heckel 法@>
#@<端末画面と更新前行配列をブロック移動します@>
#@<行が変化している箇所を書き直します@>
#@<動作確認用手抜き版 wcwidth @>
#@<現在の行をクリアして、 次の画面更新に備えます@>

スクロールを使って画面を更新するには、 直前の更新時点と現在の行の内容を比較して、 同じ内容の行の連なりが、 まとまって上へ移動したり、 下へ移動していることを検出しなければいけません。 こうした行のブロック移動の検出に Heckel 法を利用します。 求め方は、 更新前と現在の両方に含まれている同じ内容の行のうち、 さらに、 更新前と現在のそれぞれに 1 度のみ出現する行を利用します。 ここでは手抜きでテキスト差分を Heckel 法で求めるコードを流用しましたが、 正直これは良いやりかたではありません。 それよりも、 ncurses のように、 行への書き込み API の中で行にリビジョンを打っていき、 ハッシュ値ではなく、 リビジョンでブロック移動している行を求める方がスマートでしょう。

#@<更新前と現在にそれぞれ一度のみ出現する同じ行を抽出します@>=
  def select_unique_pair()
    freq = {}
    r1 = {}
    r2 = {}
    @size.y.times do |y|
      @lines[y] ||= ''
      @previous[y] ||= ''
      @lines_hashval[y] = @lines[y].hash
      @previous_hashval[y] ||= @previous[y].hash
      h1 = @previous_hashval[y]
      h2 = @lines_hashval[y]
      freq[h1] = (freq[h1] || 0) + 2
      freq[h2] = (freq[h2] || 0) + 3
      r1[h1] = r2[h2] = y
    end
    u = [[@size.y, @size.y]]
    freq.each_pair do |h, f|
      if 5 == f && @previous[r1[h]] == @lines[r2[h]]
        u.push [r1[h], r2[h]]
      end
    end
    u.sort! {|a, b|
        (a[0] < b[0] && a[1] < b[1]) ? -1
      : (a[0] == b[0] && a[1] == b[1]) ? 0
      : +1 }
  end

スマートだろうとなんだろうと、 ハッシュだろうとリビジョンだろうと、 とにかく更新前と現在のユニークな行のありかを求めてしまえば、 後は簡単です。 テキスト差分を求めるときには、 ブロック移動分の情報を捨ててますが、 今回はそれこそが欲しいものなので r2move に移動範囲を記録していきます。 もちろん、変化している箇所も必要なのでそちらは r2diff に記録しておきます。

#@<Heckel 法@>=
  def diff_heckel()
    r2diff = []
    r2move = []
    u = select_unique_pair()
    a0 = b0 = 0
    while a0 < @size.y && b0 < @size.y && @previous[a0] == @lines[b0]
      a0 += 1
      b0 += 1
    end
    u.each do |a_uniq, b_uniq|
      next if a_uniq < a0 || b_uniq < b0
      a1, b1 = a_uniq - 1, b_uniq - 1
      while a0 <= a1 && b0 <= b1 && @previous[a1] == @lines[b1]
        a1 -= 1
        b1 -= 1
      end
      a2, b2 = a_uniq + 1, b_uniq + 1
      while a2 < @size.y && b2 < @size.y && @previous[a2] == @lines[b2]
        a2 += 1
        b2 += 1
      end
      r2diff << [a0, a1, b0, b1]
      r2move << [a1 + 1, a2 - 1, b1 + 1, b2 - 1]
      a0, b0 = a2, b2
    end
    block_move(r2move)
    refresh_lines(r2diff)
    nil
  end

端末画面の更新は、 まずブロック移動から始めます。 上書きされたり移動後に必要な行がなくなってしまわないように、 2 段階に分けてスクロールをおこないます。 最初の段階は行が削除されていく分で、 スクロール・アップする方です。 ブロックごとそれぞれの行の移動方向が下から上へなので、 ブロック単位では上から下へと移動を進めます。 続いて、 行を挿入していく分で、 スクロール・ダウンする方です。 こちらは行の移動方向が上から下へなので、 ブロック単位では下から上へ移動を進めます。 スクロールするのは端末画面だけでなく、 更新前を記録している行配列も一緒にスクロールしておきます。 スクロールしていくと、 更新前の行の位置が変化するので、 更新前の行がどこに移動したかを追跡するための補正項 da を使うことにします。 スクロール・アップは行の削除なのでブロック移動分を引き、 スクロール・ダウンは行の挿入なので移動分を足します。

#@<端末画面と更新前行配列をブロック移動します@>=
  def block_move(r2move)
    r2move.each do |a1, a2, b1, b2|
      if a1 > b1
        count = a1 - b1
        tty.scroll_up(b1, a2 + 1, count)
        tty.flush
        (b1 .. b2).each {|i| previous[i] = previous[i + count] }
        (b2 + 1 .. a2).each {|i| previous[i] = '' }
      end
    end
    r2move.reverse_each do |a1, a2, b1, b2|
      if a1 < b1
        count = b1 - a1
        tty.scroll_down(a1, b2 + 1, count)
        tty.flush
        (b1 .. b2).reverse_each {|i| previous[i] = previous[i - count] }
        (a1 .. b1 - 1).each {|i| previous[i] = '' }
      end
    end
    nil
  end

ブロック移動が終わったら、 変化している行の内容を端末に書き出します。 今回は、 文字の挿入・削除制御シーケンスを使わずに、 行頭からの同じ文字範囲はそのままで、 最初の異なる文字から行末までを書き直すやりかたで更新しています。 ユニコードの書記素クラスタを 1 文字とし、 書記素クラスタの先頭のコード・ポイントで端末上で半角か全角かが求まるものと仮定しています。

#@<行が変化している箇所を書き直します@>=
  GRAPHEME = %r/\G\X/

  def refresh_lines(r2diff)
    r2diff.each do |a0, a1, b0, b1|
      (b0 .. b1).each do |y|
        i = 0
        x = 0
        while i < @previous[y].size && i < @lines[y].size
          j_a = GRAPHEME.match(@previous[y], i)&.end(0) || @previous[y].size
          j_b = GRAPHEME.match(@lines[y], i)&.end(0) || @lines[y].size
          @previous[y][i ... j_a] == @lines[y][i ... j_b] or break
          ch = @lines[y][i]
          x += "\t" == ch ? @tabskip - x % @tabskip
             : wcwidth(ch.ord)
          i = j_a
        end
        @tty.set_cursor(y, x)
        @tty.clear_to_eol()
        @tty.print @lines[y][i ... @lines[y].size]
        @tty.flush
      end
    end
    nil
  end

端末上での半角・全角の判定は前回の投稿の Ucd.wcwidth でおこないます。 半角にも全角にもなりうるコード・ポイントの幅を east_asian_width_ambigious 属性で決定します。

#@<端末上での半角・全角を判定します@>=
  def wcwidth(codepoint)
    x = Ucd.wcwidth(codepoint)
    x < 3 ? x : @east_asian_width_ambigious
  end

端末画面の更新が終わったら、 現在の行を直前の行に扱いを変え、 現在の行をクリアして、 次の画面更新に備えます。

#@<現在の行をクリアして、 次の画面更新に備えます@>=
  def flip()
    @previous, @lines = @lines, @previous
    @previous_hashval, @lines_hashval = @lines_hashval, @previous_hashval
    @size.y.times do |i|
      @lines[i] = ''
      @lines_hashval[i] = @lines[i].hash
    end
    nil
  end