行の折り返し表示 その 1 - Layout クラス

C. Finseth The Craft of Text Editing の基本エディタ部は、 画面表示部から独立しています。 そのため、 どこが画面に表示されているかに依存せず、 現点を自在に移動することができます。 画面表示部では、 現点を画面に表示できるように表示開始点を求め、 そこから必要な行数分を再表示します。 この表示開始点はマークであり、 次回の再表示まで位置を保存しています。 画面の外に現点が出ない限り同じ表示開始位置に留まるようになっています。 Finseth のコード例では、 現点から表示開始点を求めるのが framer で、 表示開始点から画面へ行を送り出すのが redisplay です。

Finseth のコード例は、 表示幅に行がすべて収まる単純な場合にしか使えないので、 表示幅を越える行を折り返し表示できるようにしてみます。 行は半角・全角文字、 タブ、 制御文字が並んでいるものとします。 半角・全角文字にはユニコードの合成文字を許し、 書記素クラスタを 1 文字として扱います。 タブは一定間隔への欄飛ばしとします。 制御文字はキャレット表記の半角 2 桁で表示しましょう。 このような行を表示幅に収まるように折り返して、 表示します。

折り返しは、 単純なライン・ラップ方式とします。 タブ・空白を含めてすべての文字を特別扱いせず、 表示幅を越えないように文字を表示行に詰め込んでいきます。 越えた文字を次の表示行の先頭に配置し、 その表示行にさらに文字を詰め込んでいきます。

将来、 端末画面を複数の表示領域に上下分割できるようにしておきたので、 再表示を表示領域ごとにおこなうことにします。 それらの個々の表示領域を GNU Emacs にあわせて Window と呼ぶことにします。 なお、 この Window は、 GUI で Pane ウィジェットと呼ばれるものに相当します。 ただし、 今の段階では、 表示できる Window を 1 つだけに限定しています。

Window は、 バッファ、 端末画面、 表示開始画面座標、 表示幅、 表示高をもちます。 最下行をモード表示に使うため、 バッファの内容を表示する行数は表示高からモード行の行数を引いたものになります。 長い行の表示のためのレイアウトは折り返し以外の方式と入れ替えることができるようにするため、 レイアウト・オブジェクトとして Window の外に切り分けてあります。 折り返し表示に関係するものだけを Window に残して他を削除すると次のようになります。 なお、 miniwindow は、 端末画面最下に配置するエコー領域とミニバッファが使う表示行です。 これの size.y は通常 1 です。

class Window
  attr_reader :screen, :buffer, :topleft, :size, :cursor
  attr_reader :dot, :start, :mark
  attr_reader :layout

  def initialize(screen, buffer)
    @screen = screen
    @buffer = buffer
    @topleft = Point2d.new(0, 0)
    @size = Point2d.new(screen.size.y - screen.miniwindow.size.y, screen.size.x)
    @cursor = Point2d.new(0, 0)
    @dot = Mark.new(0, 0)
    @start = Mark.new(0, 0)
    @mark = [@dot, @start]
    @buffer.window << self
    @layout = Layout.new(self)
    # 略
  end

  def modeline_height() 1 end

  def redisplay()
    screen.miniwindow.redisplay()
    size.y = screen.size.y - screen.miniwindow.size.y
    layout.framer()
    bottom_flag = layout.frame_end_of_buffer()
    layout.display_page()
    display_mode_line(bottom_flag)
  end
end

Window の redisplay は、 layout の framer で表示開始マーク start を求めます。 続いて、 layout の frame_end_of_buffer で、 バッファ末尾が表示範囲に含まれている場合に start を適切な値に調整しなおします。 layout の display_page で start から表示高さ分を端末画面に表示する準備をします。 最後にモードラインを表示する準備をします。 実際の再表示は端末画面との同期をおこなう screen オブジェクトが担当しています。

layout は Layout オブジェクトです。 このオブジェクトは、 バッファ内の着目している 1 行分を折り返し表示するための配置情報を grid に格納しています。 配置情報の算出を繰り返すと体感できるほどに処理が遅くなるので、 キャッシュしています。 配置情報は、 行の先頭座標 first と、 次の行の先頭座標 last でバッファのどこを配置しているかを表します。 Grid の v 欄は表示行の配列です。 表示行は HBox の配列です。 HBox の d 欄は Grid.first からの相対ユーザ座標、 len はバッファ内の長さ、 span は表示桁幅を表します。 Line はバッファが管理している行情報で、 書記素クラスタの開始ユーザ座標が並んでいます。 並びの最後に次の行のユーザ座標の相対値が入ってところが Grid との違いです。 Grid では、 下の例のように、 全角の 2 桁めに穴埋めの HBox を入れてあります。 穴埋めは、 全角だけでなく、 タブの欄飛ばし、 制御文字のキャレット表記にも入っています。

# Line[first=200, last=211, h=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11]]
#                              |H |e |l |l |o |こ|ん|に|ち|わ|\n|
Line = Struct.new(:first, :last, :h)

# Grid[first=200, last=211,
#      v=[[HBox[ 0, 1,  1],     # window.buffer[200, 1]=="H"
#          HBox[ 1, 1,  1],     # window.buffer[201, 1]=="e"
#          HBox[ 2, 1,  1],     # window.buffer[202, 1]=="l"
#          HBox[ 3, 1,  1],     # window.buffer[203, 1]=="l"
#          HBox[ 4, 1,  1],     # window.buffer[204, 1]=="o"
#          HBox[ 5, 1,  2],     # window.buffer[205, 1]=="こ"
#          HBox[ 5, 0, -1],
#          HBox[ 6, 1,  2],     # window.buffer[206, 1]=="ん"
#          HBox[ 6, 0, -1],
#          HBox[ 7, 1,  2],     # window.buffer[207, 1]=="に"
#          HBox[ 7, 0, -1],
#          HBox[ 8, 1,  2],     # window.buffer[208, 1]=="ち"
#          HBox[ 8, 0, -1],
#          HBox[ 9, 1,  2],     # window.buffer[209, 1]=="わ"
#          HBox[ 9, 0, -1],
#          HBox[10, 1,  0]]]],  # window.buffer[210, 1]=="\n"
Grid = Struct.new(:first, :last, :v)
HBox = Struct.new(:d, :len, :span)

class Layout
  END_BUFFER = ''

  attr_accessor :grid, :window
  attr_reader :cache

  def initialize(window)
    @grid = Grid.new(0, 0, [[]])
    @window = window
    @cache = RangeLocationCache.new(window.size.y * 2)
  end

  def sizey() @grid.v.size end
  def sizex(y) @grid.v[y].size end
  def eos?() @grid.v[-1].last.d + @grid.first >= @grid.last end

#@<framer@>
#@<frame_end_of_buffer@>
#@<display_page@>
#@<move_line@>
#@<get_column@>
#@<set_column@>
#@<layout@>
end

現点マーク dot から、 表示開始マーク start を求める framer は、 折り返された結果の表示行を前へ辿っていく点を除いて、 Finseth の framer の基本原理をそのまま受け継いでいます。 get_column と layout が指定したユーザ座標を含む行を折り返して、 表示行の配列を作成して grid に格納します。 get_column はさらに続いてユーザ座標から通算桁を求めます。 通算桁は先頭がゼロ桁で折り返された次が表示幅の倍数になります。 最初に求めている y は、 通算桁を表示幅で割っているので、 grid 内での現点を含む表示行の指標になっています。

#@<framer@>=
  def framer()
    y = get_column(window.dot.point) / window.size.x
    loc = @grid.v[y][0].d + @grid.first
    height = window.size.y - window.modeline_height
    half = window.start.point
    count = 0
    while count < height
      if window.start.revision == 0 && window.start.point == loc
        break
      end
      if 0 >= loc
        window.start.point = 0
        break
      end
      if count == height / 2
        half = loc
      end
      if y < 1
        layout(@grid.first - 1)
        y = @grid.v.size
      end
      y -= 1
      loc = @grid.v[y][0].d + @grid.first
      count += 1
    end
    if count >= height
      window.start.point = half
    end
    window.start.revision = 0
  end

framer は現点からバッファの前側しか調べないため、 後側を調べて表示範囲にバッファ末尾が入っている場合に start を書き換える処理が必要になります。 framer で求めた開始点から表示高さの分下の表示行の先頭表示桁に対応するユーザ座標を loc1 に求めます。 move_line は、 配置情報を元に、 表示行単位で行移動をする計算するメソッドです。 set_column は grid 内の通算桁に位置する書記素クラスタのユーザ座標を求めるメソッドです。 バッファ末尾が含まれる表示行の先頭表示桁に対応するユーザ座標を同様に loc2 に求めます。 loc1 と loc2 が等しいとき、 表示範囲にバッファ末尾が入っています。 そのときは、 バッファ末尾を画面の下側に配置するように、 表示開始点を再設定します。 next_screen_context_lines は scroll-up コマンドのためのページ間移動でオーバーラップさせる行数を指定する変数で、 それを流用しています。

#@<frame_end_of_buffer@>=
  def frame_end_of_buffer()
    height, width = window.size.y - 1, window.size.x
    start = window.start
    x1 = move_line(start.point, height - 1)
    loc1 = set_column(x1 / width * width, false)
    x2 = get_column(window.buffer.size)
    loc2 = set_column(x2 / width * width, false)
    if start.point > 0 && loc2 <= loc1
      paddings = window.buffer.mode.next_screen_context_lines
      x = move_line(loc1, -[height - paddings, height / 2].max + 1)
      start.point = set_column(x / width * width, false)
      start.revision = 0
    end
    loc2 <= loc1
  end

表示開始点が start に求まったら、 レイアウト情報を使って端末画面へ行を表示する準備をします。 screen の lines に書き込んでおくと、 screen が表示済み内容との差分をとって端末画面の書き直しをしてくれるはずなので、 lines に文字や欄飛ばしのための空白等をどんどん追加していきます。 表示行を終端するのは、 改行かバッファ末尾です。 いずれかに出会った時点で次の行の書き出しに移ります。 なお、 この screen は SGR (Select Graphic Rendition) 未対応により単なる lines の配列ですが、 将来変更して、 書記素クラスタごとに SGR を設定できるようにする予定です。

#@<display_page@>=
  def display_page()
    top = window.topleft.y
    loc = window.start.point
    j = get_column(loc) / window.size.x
    y, height = 0, window.size.y - window.modeline_height
    while y < height
      window.screen.clear_line(y + top)
      x = 0
      while x < @grid.v[j].size
        hbox = @grid.v[j][x]
        loc = hbox.d + @grid.first
        ch = loc < window.buffer.size ? window.buffer[loc, hbox.len] : END_BUFFER
        if loc == window.dot.point
          window.cursor.y, window.cursor.x = y, x
        end
        loc += hbox.len
        break if END_BUFFER == ch || "\n" == ch
        if "\t" == ch || ' ' == ch
          window.screen.lines[y + top] << ' ' * hbox.span
        elsif ch.ord < 0x20
          window.screen.lines[y + top] << ('^' + (ch.ord + '@'.ord).chr)
        else
          window.screen.lines[y + top] << ch
        end
        x += hbox.span
      end
      y += 1
      break if END_BUFFER == ch
      j += 1
      if j >= @grid.v.size
        layout(loc)
        j = 0
      end
    end
    (y ... height).each {|j| window.screen.clear_line(j + top) }
    nil
  end

move_line は、 バッファのユーザ座標 loc に相当する表示行から、 表示行単位で count 行下の同じ桁の通算桁を求めます。 count が負のときは、 count の絶対値の行数分、 上の同じ桁の通算桁を求めます。 count がゼロのときは get_column と同じで、 loc に相当する通算桁を求めます。 移動は配置情報 grid ごとに進めていきます。 最初の get_column で loc を含む行の配置情報が grid に入ります。 get_column が返す通算桁を表示幅で割った y が grid 内での loc の表示行の指標になっています。 sizey は現在の grid の高さです。 count が正、 すなわち、 下へ移動するときは、 y に count を足した移動先が grid の高さを越えている間、 直下の配置情報を layout で求めて、 表示行を下へと進んでいきます。 grid.last は、 次の行の grid.first と同じユーザ座標なので、 直下の grid を求めるには grid.last を含む行の配置情報を求めれば良いわけです。 ところで、 count が大きすぎてバッファ末尾を越えてしまうときは eos? が真になりますので、 そこで移動を停止します。 count が負のときは、 同じように直上の配置情報を layout で求めつつ、 上の表示行へ進んでいきます。

#@<move_line@>=
  def move_line(loc, count)
    y, x = get_column(loc).divmod(window.size.x)
    if count > 0
      while y + count >= sizey() && ! eos?
        count, y = count - sizey() + y, 0
        layout(@grid.last)
      end
      y = [y + count, sizey() - 1].min
    elsif count < 0
      count = -count
      while y < count && 0 < @grid.first
        layout(@grid.first - 1)
        count, y = count - y - 1, @grid.v.size - 1
      end
      y = [y - count, 0].max
    end
    y * window.size.x + x
  end

ユーザ座標の通算桁を求めるには、 まずそのユーザ座標 loc の含まれる行の配置情報 grid を layout で求めます。 続いて、 grid の中での表示行の指標 y を探します。 表示行の右端の書記素クラスタの開始ユーザ座標が loc よりも小さいときは、 loc を含む表示行はそこよりも下にあります。 そのときは y を進めます。 表示行が見つかったら、 続いて、 その中から桁 x を探します。 最後に、 通算桁を計算して返します。

#@<get_column@>=
  def get_column(loc)
    layout(loc)
    y = 0
    while y + 1 < @grid.v.size && @grid.v[y].last.d + @grid.first < loc
      y += 1
    end
    row = @grid.v[y] || []
    x = 0
    if row.size > 0
      while x < row.size && row[x].d + @grid.first < loc
        x += row[x].span
      end
    end
    y * window.size.x + x
  end

逆に通算桁から配置情報の hbox の値を使うことで、 ユーザ座標を求めることができます。 round_flag によって、 全角の右桁に対して、 真のときはその全角の開始桁のユーザ座標を、 偽のときは右隣の文字のユーザ座標を返すように挙動を変更します。

#@<set_column@>=
  def set_column(x, round_flag)
    y, x = x.divmod(window.size.x)
    row = @grid.v[y]
    right = row.size - 1
    if right > 0 && row[right].span < 0
      right += row[right].span
    end
    if right <= x
      row[right].d + @grid.first
    else
      if x < right && row[x].span < 0
        x += row[x].span
        x += row[x].span if ! round_flag
      end
      row[x].d + @grid.first
    end
  end

長い行を折り返して配置情報を grid に作る心臓部が layout メソッドです。 このメソッドは、 まず、 キャッシュから既に求めてある配置情報を探し、 見つかったらそれを grid にします。 なお、 grid を使いたいときにキャッシュからその都度探すようにコーディングしていく必要があります。 なぜなら、 その 3 で説明するように、 バッファへ挿入・削除すると、 キャッシュを更新するためです。 キャッシュになかったときは、 バッファから行の書記素クラスタの列 line を求め、 それを配置して grid を作ります。 そして、 作った grid をキャッシュに登録して返します。 特別な配置情報として、 grid の v 欄に空行を一ついれる場合が生じます。 バッファが空のときと、 バッファが改行で終わっているときの、 バッファ末尾に対して、 空行を生成します。 他の場合、 改行は HBox を作るため、 空行になりません。 下で使っている変数 x は通算桁で、 表示行の桁にするときは表示幅で割ります。

#@<layout@>=
  def layout(loc)
    tty = window.screen.tty
    buffer = window.buffer
    @grid = @cache.lookup(loc, buffer.size)
    if @grid.nil?
      tab_width = buffer.mode.tab_width
      width = window.size.x
      line = buffer.grapheme_clusters_of_line(loc)
      @grid = Grid.new(line.first, line.last, [[]])
      x = 0
      line.h.each_index do |j|
        loc = line.h[j] + line.first
        len = (line.h[j + 1] || line.h[j]) - line.h[j]
        ch = loc < buffer.size ? buffer[loc] : END_BUFFER
        span = END_BUFFER == ch ? 0 \
             : "\n" == ch ? 0 \
             : "\t" == ch ? tab_width - (x % width) % tab_width
             : ch.ord < 0x20 ? 2
             : tty.wcwidth(ch.ord)
        if x % width + span > width
          x += width - x % width
          @grid.v << []
        end
        @grid.v[-1] << HBox.new(line.h[j], len, span)
        if span > 1
          (span - 1).times {|d| @grid.v[-1] << HBox.new(line.h[j], 0, -(d + 1)) }
        end
        break if END_BUFFER == ch || "\n" == ch
        if x % width + span >= width
          @grid.v << []
        end
        x += span
      end
      @cache.unshift @grid
    end
    self
  end

その 2 - Line を求めるに続く。