テキスト・エディタの上下タイリング・ウィンドウ

テキスト・エディタが端末画面に複数のウィンドウを表示するとき、 画面を分割して隙間なくウィンドウをタイルのように並べる方式が定番になっています。 ここで作ろうとしているエディタも複数のウィンドウをタイリング方式で表示できるようにしていきます。 ただし、 画面の分割を上下に制限します。

縦に並ぶウィンドウは 2 種類に分かれます。 ファイルの編集等に利用する Window と、 エコー領域とミニバッファの表示領域に利用する MiniWindow です。 これらは WindowBase クラスの派生クラスにしています。 WindowBase の役目は、 画面上にウィンドウを配置する長方形領域を topleft 属性と size 属性で指定すること、 画面オブジェクトである Screen や Buffer オブジェクトを結びつけることです。 さらに、 幅を越える行の表示方式を提供する Layout オブジェクトも結びつけます。 端末座標でのカーソル位置、 バッファ座標での現点と表示開始位置も保持します。

class WindowBase
  attr_reader :screen, :buffer, :topleft, :size, :cursor
  attr_reader :dot, :start, :mark_list
  attr_reader :layout

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

  def minibuffer?() false end
  def tail() self end
  def redisplay_setup() end
  def redisplay_teardown() end
end

minibuffer? 以下のメソッドは、 Editor と Screen がミニバッファを扱うための仕組みに関係しています。 このエディタのミニバッファは、 WindowBase の派生ウィンドウ・クラスのインスタンスを一時的に修飾するラッパー・オブジェクトにしています。 tail 属性は修飾されるウィンドウ・オブジェクトであり、 WindowBase の派生クラスでは常にレシーバーを返します。

Screen は、 端末 tty 属性を使って端末表示の同期をおこなう ScreenBase の派生クラスで、 ウィンドウのタイリング tile と、 キーボード入力をつなげる activated_window を管理します。 Screen の初期化に、 tty オブジェクト、 最初に表示する Window と MiniWindow のそれぞれのバッファが必要です。 tile の要素は Window です。 下記は、 以前の Screen を補ったものです。

class Screen < ScreenBase
  attr_reader :activated_window, :tile, :miniwindow
  attr_accessor :window_min_height

  def initialize(tty, buffer, miniwindow_buffer)
    super(tty)
    @tile = [Window.new(self, buffer)]
    @miniwindow = MiniWindow.new(self, miniwindow_buffer)
    @activated_window = @tile[0]
    @activated_window.size.y = size.y - 1
    @window_min_height = 4
  end

#@<redisplay@>
#@<選択されているウィンドウ@>
#@<balance_windows@>
#@<find_index_window@>
#@<split_window_below@>
#@<next_window@>
#@<enlarge_window@>
#@<delete_window@>
end

ウィンドウが 1 つだけ存在するときは、 ウィンドウの大きさ変更や削除ができない特別な場合なので、 one_window? でチェックできるようにしています。

  def one_window?()
    tile.size <= 1
  end

タイルの再描画自体は順に WindowBase オブジェクトに redisplay メッセージを送っていくだけで簡単なのですけど、 高さの再調整が必要になることがあります。 まず、 miniwindow 以外のウィンドウの高さを足し合わせてみて、 画面の高さより 1 少ない値に一致しているときは、 高さ調整が不要です。 そうでないときは、 均一な高さになるように設定します。 続いて、 miniwindow を再描画します。 その結果、 miniwindow の高さが 1 より大きくなることがあります。 ウィンドウの高さは miniwindow の高さが 1 のときに合わせて調整済みなので、 1 より大きなときは、 下側のウィンドウの高さを一時的に狭めなければいけません。 一番下のウィンドウの高さでは不十分なとき、 そのウィンドウの表示を止めて miniwindow の表示を優先します。 一時的にタイルからウィンドウを削ったり、 ウィンドウの高さを変更するため、 再描画用のタイルの複製を presents に作っておき、 調整済みの元のウィンドウの高さを saved_size_y に記録しておきます。 再描画が終わると、 ウィンドウの高さを元に戻します。

#@<redisplay@>=
  def redisplay()
    if tile.inject(0) {|r, win| r + win.size.y } != size.y - 1
      balance_windows_internal()
    end
    activated_window.redisplay_setup()
    miniwindow.redisplay()
    saved_size_y = tile.map {|win| win.size.y }
    presents = tile.dup
    if miniwindow.size.y > 1
      while presents[-1].size.y - miniwindow.size.y + 1 < window_min_height
        presents[-2].size.y += presents[-1].size.y
        presents.pop
      end
      presents[-1].size.y -= miniwindow.size.y - 1
    end
    presents.each {|win| win.redisplay() }
    saved_size_y.each_with_index {|y, i| tile[i].size.y = y }
    activated_window.redisplay_teardown()
    self
  end

activated_window はキーボード入力がつながっている WindowBase または MiniBufferBase のことです。 一方、 現在選択中のウィンドウを示す selected_window は activated_window の tail で、 これは WindowBase です。

#@<選択されているウィンドウ@>=
  def activated_window_push(win)
    @activated_window = win
    if not @activated_window.minibuffer?
      @activated_window.buffer.window_raise(@activated_window)
    end
    self
  end

  def activated_window_pop()
    win = @activated_window
    @activated_window = @activated_window.tail
    win
  end

  alias select_window activated_window_push

  def selected_window()
    activated_window.tail
  end
end

あるバッファを複数のウィンドウで表示しているとき、 そのバッファを最後に選択したウィンドウを知りたいことがあります。 バッファは window 属性に表示中のウィンドウ・オブジェクトを並べて覚えているので、 ウィンドウを選択したとき、 window 属性の先頭へ移動するようにしておきます。 それを Buffer クラスの window_raise メソッドにやってもらいます。

class Buffer
  def window_raise(win)
    i = window.find_index {|x| x.equal?(win) } or return self
    i > 0 or return self
    window.delete_at(i)
    window.unshift win
    self
  end
end

高さを均一にするコマンドは balance-windows です。

class BalanceWindows < StickyxInteractive
  def self.name() :balance_windows end

  def edit(arg)
    screen.balance_windows()
  end
end

miniwindow を除く、 ウィンドウの高さを均一にします。 balance_windows は、 redisplay 時にウィンドウの高さを均一にする処理が働くように最初のウィンドウの高さを壊します。 redisplay はウィンドウの高さが画面高さに合ってないと、 ウィンドウの再配置をおこなって高さを均一に揃えます。

#@<balance_windows@>=
  def balance_windows()
    not one_window?() or return
    tile[0].size.y = size.y
  end

  def balance_windows_internal()
    y = 0
    height2 = size.y - 1
    height1 = height2 / tile.size
    (0 ... tile.size - 1).each {|i|
      tile[i].topleft.y = y
      tile[i].size.y = height1
      y += height1
      height2 -= height1
    }
    tile[-1].topleft.y = y
    tile[-1].size.y = height2
  end

Screen を初期化した時点で、 ウィンドウ 1 つでテキスト編集できるようになっています。 そこから、 ウィンドウを増やすには split-window-below コマンド (C-x 2) を使います。 これは選択中のウィンドウを上下に分割します。 分割後に選択されるのは上のウィンドウです。 分割後の上のウィンドウの高さを数引数で指定します。 この高さにはモードラインも含みます。 高さ指定を省略すると半分に分けます。 分割後、 上下とも同じバッファを使い、 下の現点は上のユーザ座標を受け継ぎます。

class SplitWindowBelow < StickyxInteractive
  def self.name() :split_window_below end

  def edit(arg=nil)
    count = digit_argument(arg)
    win = screen.split_window_below(screen.selected_window, size: count)
    if ! win
      screen.miniwindow.print('', '(too small height?)')
    end
    win
  end
end

split-window-below コマンドの本体は Screen クラスのメソッドです。 ウィンドウ・オブジェクト window を指定しているときはタイルに登録してあるかどうかを調べます。 window が nil のときは選択中のウィンドウのタイル中の指標を探します。 ウィンドウが見つかったら、 分割後の上と下のそれぞれの高さを size と other_size に求めます。 両方の高さが制限値以上のときに分割をします。 window を複製して下に置きます。 高さを設定し、 下のウィンドウの上辺の位置も設定します。 分割が終わると、 新しく作ったウィンドウを返します。

#@<split_window_below@>=
  def split_window_below(window=nil, size: nil)
    i = find_index_window(window) or return nil
    window = tile[i]
    size ||= window.size.y / 2
    other_size = window.size.y - size
    size >= window_min_height or return nil
    other_size >= window_min_height or return nil
    tile.insert(i + 1, window.copy)
    window.size.y = size
    tile[i + 1].topleft.y = window.topleft.y + size
    tile[i + 1].size.y = other_size
    tile[i + 1]
  end

find_index_window は、 tile から window の入っている場所の指標を返します。 window に nil を指定したときは選択中の window を探します。

#@<find_index_window@>=
  def find_index_window(window=nil)
    window ||= selected_window()
    tile.find_index {|item| item.equal?(window) }
  end

なお、 ウィンドウを複製すると、 複製元と同じバッファ・オブジェクトを共用し、 現点と表示開始点を同じユーザ座標に設定したウィンドウ・オブジェクトを新しく作ります。

class Window < WindowBase
  def copy()
    win = Window.new(screen, buffer)
    win.dot.point = dot.point
    win.start.point = start.point
    win
  end
end

他のウィンドウを使用するコマンドは other-window (C-x o) です。 他のウィンドウの相対位置を数引数で与え、 そこにあるウィンドウを選択します。 数引数を省略すると相対位置は +1 になります。

class OtherWindow < StickyxInteractive
  def self.name() :other_window end

  def edit(arg=nil)
    count = digit_argument(arg) || 1
    win = screen.next_window(count) or return
    screen.select_window win
  end
end

相対位置の符号が正のときは下向き、 負のときは上向きの意味になります。 今のところ、 画面の一番下のウィンドウの次は MiniWindow ではなく、 画面の一番上のウィンドウにしています。 tile をリング・バッファとして計算しており、 ruby では除数が正のとき、 i + count が負になっても、 正の剰余が求まるので式が簡単で済みます。 C 言語に移植するとき、 i + count が負のときの補正が必要になります。

#@<next_window@>=
  def next_window(count=1)
    if one_window?
      nil
    else
      i = find_index_window or return nil
      tile[(i + count) % tile.size]
    end
  end

ウィンドウの高さを変更するコマンドは enlarge-window (C-x ^) です。 高さの増減値を数引数で指定します。 負で縮み、 正で伸びます。

class EnlargeWindow < StickyxInteractive
  def self.name() :enlarge_window end

  def edit(arg=nil)
    not screen.one_window? or return
    count = digit_argument(arg) || 1
    win = screen.selected_window
    screen.enlarge_window(win, count)
  end
end

縮むときは下に隣接するウィンドウを伸ばします。 一番下のウィンドウを縮めるときは、 伸ばすのは上に隣接するウィンドウを伸ばします。 伸ばすときは下に隣接するウィンドウを縮め、 それで足りないときは上に隣接するウィンドウも縮めます。 縮めるとき、 ウィンドウの高さ制限より小さくしないようにします。 高さ制限にひっかかるときは、 可能なだけ縮めるか伸ばすかします。 なお、 汎引数で C-u C-x ^ とタイプするときに制限にひっかかることがあるので、 GNU Emacs のように例外を生じさせるのは避けました。

#@<enlarge_window@>=
  def enlarge_window(window, count=1)
    not one_window? or return
    i = find_index_window(window) or return
    window = tile[i]
    if count < 0 && window.size.y > window_min_height
      count = [-count, window.size.y - window_min_height].min
      if i == tile.size - 1
        enlarge_window_internal(tile[i - 1], window, count)
      else
        enlarge_window_internal(window, tile[i + 1], -count)
      end
    elsif count > 0
      if i < tile.size - 1
        dy = tile[i + 1].size.y - [tile[i + 1].size.y - count, window_min_height].max
        if dy > 0
          enlarge_window_internal(window, tile[i + 1], dy)
          count -= dy
        end
      end
      if count > 0 && i > 0
        dy = tile[i - 1].size.y - [tile[i - 1].size.y - count, window_min_height].max
        if dy > 0
          enlarge_window_internal(tile[i - 1], window, -dy)
        end
      end
    end
    self
  end

  def enlarge_window_internal(window, below, count)
    window.size.y += count
    below.topleft.y += count
    below.size.y -= count
  end

delete-window (C-x 0) コマンドで選択中のウィンドウを削除します。

class DeleteWindow < Interactive
  def self.name() :delete_window end

  def edit(arg=nil)
    not screen.one_window? or return
    win = screen.selected_window
    i = screen.find_index_window(win) or return
    screen.delete_window(win)
  end
end

逆に delete-other-windows (C-x 1) コマンドは、 選択中のウィンドウを残して、 他のウィンドウをすべて削除します。

class DeleteOtherWindows < StickyxInteractive
  def self.name() :delete_other_windows end

  def edit(arg=nil)
    not screen.one_window? or return
    not screen.activated_window.minibuffer? or return
    win = screen.selected_window
    i = screen.find_index_window(win) or return
    (0 ... screen.tile.size).reverse_each do |j|
      if i != j
        screen.delete_window(screen.tile[j])
      end
    end
  end
end

どちらのコマンドも、 screen の delete_window で、 指定した window を tile から削除します。 window を nil に指定すると、 選択中のウィンドウを削除します。 一番下のウィンドウを削除するときは、 上に隣接するウィンドウを伸ばします。 他のときは、 下に隣接するウィンドウを伸ばします。

#@<delete_window@>=
  def delete_window(window=nil)
    not one_window?() or return
    i = find_index_window(window) or return
    window = tile[i].buffer_release
    if selected_window.equal?(window)
      select_window(next_window)
    end
    if i == tile.size - 1
      tile[i - 1].size.y += window.size.y
    else
      tile[i + 1].topleft.y = window.topleft.y
      tile[i + 1].size.y += window.size.y
    end
    tile.delete_at(i)
    self
  end

Window を削除するとき、 buffer_release メソッドで Buffer との結びつきを切っておきます。 Window が保持している現点と表示開始点、 Window の layout が保持している Grid のキャッシュを Buffer が挿入・削除の際に逐次更新するため、 Buffer の window 配列に Window オブジェクトを入れてあります。 それを取り除きます。 それによりバッファの window 配列が空になると、 そのバッファは表示されなくなったことになります。 ウィンドウが保持している現点と表示開始点をバッファに記録し、 後に表示されるときのために備えます。

class Window < WindowBase
  def buffer_release()
    layout.cache.clear
    buffer.window.delete_if {|item| item.equal?(self) }
    if buffer.window.empty?
      buffer.dot.point = dot.point
      buffer.start.point = start.point
    end
    @buffer = nil
    self
  end
end