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