テキスト・エディタのマルチ・バッファ

ここまでで、 マルチ・バッファにする下地は整っているので、 まずは、 落穂拾いから始めます。

テキスト挿入もしくは削除に伴い、 マークと行折り返しレイアウト・キャッシュを更新する目的で、 Window に buffer 属性を、 Buffer に window 属性を設けて相互リンクしていました。 Window に buffer 属性があるのは当然としても、 Buffer に window 属性が必要な理由は、 Buffer が更新するマークとキャッシュに Window に所属するものがあるためです。

ところが、 この方式では Window と Buffer の関連付けが変わるごとに、 その都度、 相互リンクのつじつま合わせをおこなわねばなりません。 例えば、 switch-to-buffer (C-x b) コマンドで Window が表示しているバッファを変更すると、 それまで表示していた Buffer の window 属性から Window 自身を取り除かなければなりません。 続いてこれから表示する Buffer の window 属性に window 自身を追加します。 これらは暗黙のうちにおこなわれるようにコーディングしてあるものの、 delete-window (C-x 0) コマンド等では、 メソッドを明示的に実行することで、 Window と Buffer の関連付けを切っていました。

予防処置として、 双方向リンクのつじつま合わせを明示的にコードに書き込むのは避けておきたいものです。 なぜならば、 メモリ・リークの原因の一つが双方向リンクの切断ミスだからです。 多少のオーバー・ヘッドで済むなら、 Window と Buffer の双方向リンクなしでマークとキャッシュの更新ができるように、 修正しておきましょう。

都合の良いことに、 今や、 すべての Window を Screen が保持しているので、 Buffer から Screen へリンクすることで、 循環リンクを作ることができます。 これを使うことで、 Buffer が更新したいマークとキャッシュを所有する Window を探すことが可能です。 また、 screen から window を削除するだけで、 自動的に screen → window → buffer → screen の循環リンクを切ることができ、 ゴミ集めが回収対象の window オブジェクトを検出できるようになります。

まず、 Screen クラスの initialize をブロックで初期化をおこなえるように変更し、 今度は、 最初の画面表示用の buffer と、 miniwindow 用の buffer をブロックで作るようにしました。 これにより、 buffer を作るには screen が必要で、 screen を初期化するのには buffer が欲しいという事情に対応しています。

class Screen < ScreenBase
  def initialize(tty)
    super(tty)
    @window_min_height = 4
    @tile = []
    buffer, miniwin_buffer = yield self
    @activated_window = @tile[0] = Window.new(self, buffer)
    @miniwindow = MiniWindow.new(self, miniwin_buffer)
  end

  def window_of_buffer_owner(buffer)
    each_window.select {|win| win.buffer.equal?(buffer) }
  end

  def each_window
    block_given? or return to_enum(:each_window)
    @tile.each {|win| yield win }
    @miniwindow.nil? or yield @miniwindow
    self
  end

#@<delete_window@>
end

Screen の delete_window メソッドで、 もはや相互リンクを切るメッセージを window オブジェクトへ送る必要がなくなりました。

#@<delete_window@>=
  def delete_window(window=nil)
    not one_window?() or return
    i = find_index_window(window) or return
    window = tile[i]                          #+
    # 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

今度は Buffer から window 属性を削除して screen 属性を追加します。 window_raise メソッドが不要になったので撤去します。

class Buffer
  attr_reader :screen
  # attr_reader :window       #- 削除

  def initialize(screen, name)
    @name = name.dup
    @screen = screen
    # 省略
  end

#@<content_insert@>
#@<content_delete@>

  # def window_raise(win) ... end         #-
end

content_insert で window 属性の each を使っていた箇所を、 screen 属性を使って書き改めます。 修正箇所は 1 行だけです。 content_delete も同様に修正すれば良いので、 省略します。

#@<content_insert@>=
  def content_insert(dot, str)
    content.insert(dot.point, str)
    count = str.size
    screen.window_of_buffer_owner(self).each do |win|     #!
      update_mark_insert(win.mark_list, dot, count)
      update_cache(win.layout.cache, dot, 0, count)
    end
    update_mark_insert(@mark_list, dot, count)
    update_cache(@line_cache, dot, 0, count)
    dot.point += count
    @revision += 1
    self
  end

#@<content_delete@>=
  # 同上につき省略

WindowBase から Buffer との相互リンクを作る箇所を削除します。

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                  #-
    # 省略
  end

  # 省略
end

Window から buffer_release を削除します。 現点と表示開始点をセーブして、 レイアウト・キャッシュを空にする部分は buffer= へ引き継ぎました。 バッファが表示されなくなってから、 再度表示されるとき、 以前の現点と表示開始点を覚えておくために、 Buffer にも dot 属性と start 属性があります。 なお、 他の window に表示中のバッファへに変えるときは、 一番上の window から現点と表示開始点を受け継ぎます。

class Window
  def buffer=(buf)
    a = (buf.nil?) ? [] : screen.window_of_buffer_owner(buf)
    if @buffer
      @buffer.dot.point = dot.point
      @buffer.start.point = start.point
      @buffer.start.revision = start.revision
    end
    layout.cache.clear
    @buffer = buf or return buf
    if a.empty?
      dot.point = @buffer.dot.point
      dot.revision = 0
      start.point = @buffer.start.point
      start.revision = @buffer.start.revision
    else
      dot.point = a.first.dot.point
      dot.revision = 0
      start.point = a.first.start.point
      start.revision = a.first.start.revision
    end
    buf
  end
end

予防処置はここまでです。 ここから、 マルチ・バッファのための機能追加に入ります。

表示中の window を screen が管理しているように、 開いている buffer を editor が管理しています。 Editor に buffer_list 属性があり、 表示・未表示を問わず、 buffer はここに入れます。 例外は screen.miniwindow の buffer です。 混乱を避けるため、 これだけは buffer_list に入れてません。

class OptionError < RuntimeError
end

class Editor
  attr_reader :screen, :buffer_list
  # 省略

  def initialize()
    @screen = nil
    @buffer_list = []
    # 省略
  end

#@<getopt@>
#@<run@>
#@<get_buffer@>
#@<get_buffer_create@>
#@<get_buffer_create_from_path@>
#@<kill_buffer@>
end

get_buffer は buffer_list から、 名前が name である buffer を探します。 同時に見つけた buffer を先頭に移動し、 バッファ操作コマンドが最近使った順で buffer を探すのに備えます。

#@<get_buffer@>=
  def get_buffer(name)
    i = buffer_list.find_index {|x| x.name == name } or return nil
    buffer = buffer_list[i]
    buffer_list.delete_at(i)
    buffer_list.unshift buffer
    buffer
  end

上の探索で見つからなかったとき、 名前が name の buffer を新しく作って buffer_list に追加するのが、 get_buffer_create です。 新しく作った buffer の filename 属性は空文字列にしてあります。

#@<get_buffer_create@>=
  def get_buffer_create(name)
    buffer = get_buffer(name)
    if buffer.nil?
      buffer = Buffer.new(screen, name)
      buffer.mode = mode
      buffer.isearch.mode = search_local
      buffer_list.unshift buffer
    end
    buffer
  end

switch-to-buffer (C-x b) コマンドはこれを使って記述します。 buffer_list にあるバッファ名を補完に使う前準備があるものの、 ミニバッファでの対話結果で得た名前のバッファを得て、 ウィンドウのバッファを切り替えます。

class SwitchToBuffer < Interactive
  def self.name() :switch_to_buffer end

  def edit(arg=nil)
    name_default, list = take_buffer_completion()
    prompt = (name_default == '') ? 'Switch to buffer : ' \
           : 'Switch to buffer (default %s): ' % [name_default]
    screen.read_string(prompt, '', completion: list) do |str|
      screen.minibuffer_pop()
      if str == ''
        str = name_default
      end
      if str != '' && str[0] != ' '
        buffer = editor.get_buffer_create(str)
        if ! window.buffer.equal?(buffer)
          window.buffer = buffer
        end
      end
    end
  end

#@<take_buffer_completion@>
end

補完用リストは、 Editor の buffer_list から buffer.name を抜き出してリストにしたものです。 その際、 空文字列の名前と空白で始まる名前を除外します。 さらに、 その buffer が未表示のとき、 最近使ったものを優先してバッファのデフォルト名として選択します。

#@<take_buffer_completion@>=
  def take_buffer_list()
    name_default = nil
    list = []
    editor.buffer_list.each do |buf|
      if buf.name && buf.name[0] != ' '
        list << buf.name
        if screen.window_of_buffer_owner(buf).empty?
          name_default ||= buf.name
        end
      end
    end
    [name_default || '', list.sort]
  end

get_buffer_create と同じですが、 名前をパス名から作るのが get_buffer_create_from_path です。 このメソッドは、 単に名前が付いている空の buffer を作るだけで、 ファイルの読み込みをおこないません。

#@<get_buffer_create_from_path@>=
  def get_buffer_create_from_path(path)
    filename = File.expand_path(path)
    name = Buffer.filename_friendly(filename)
    get_buffer_create(name)
  end

find-file (C-x C-f) コマンドはこれを使って記述します。 ミニバッファでファイル名を受け取ってから、 バッファを求め、 ファイルを読み込みます。 既に開いているときは、 GNU Emacs とは異なり、 既にあるバッファに切り替えます。

class FindFile < Interactive
  include FileInteractivable

  def self.name() :find_file end

  def edit(arg=nil)
    return read_file(arg) if String === arg
    read_file_name(1, 'Find file: ') do |str|
      screen.minibuffer_pop()
      buffer = editor.get_buffer_create_from_path(str)
      window.buffer = buffer
      if buffer.filename.empty?
        read_file(str)
      end
    end
  end
end

Buffer の特異メソッド filename_friendly は、 絶対パスからディレクトリを適度に取り除いて、 名前を作る関数です。

require 'pathname'
require 'fileutils'

class Buffer
  def self.filename_friendly(filename)
    abspath = Pathname.new(filename)
    curdir = Pathname.pwd
    homedir = Pathname.new(Dir.home)
    if abspath.ascend.any? {|x| x == curdir }
      abspath.relative_path_from(curdir).to_s
    elsif abspath.descend.any? {|x| x == homedir } \
        && curdir.descend.any? {|x| x == homedir }
      abspath.relative_path_from(curdir).to_s
    else
      filename
    end
  end
end

kill_buffer は buffer_list から名前が name である buffer を取り除きます。 取り除いた後、 buffer_list が空になってしまったときは、 UNTITLED の名称で get_buffer_create します。 さらに、 削除対象 buffer を表示中の window を削除していきます。 window が 1 つだけ残り、 それも buffer を表示しているときは、 buffer_list の先頭か UNTITLED の表示に切り替えます。 最後に、 バッファの内容を掃除して、 バッファの削除は終わりです。

#@<kill_buffer@>=
  def kill_buffer(name)
    i = buffer_list.find_index {|x| x.name == name } or return
    buffer = buffer_list[i]
    buffer_list.delete_at(i)
    if buffer_list.empty?
      get_buffer_create('UNTITLED')
    end
    screen.window_of_buffer_owner(buffer).each do |window|
      not screen.one_window? or break
      screen.delete_window(window)
    end
    if screen.one_window? && screen.activated_window.buffer.equal?(buffer)
      if buffer_list.empty?
        screen.activated_window.buffer = get_buffer_create('UNTITLED')
      else
        screen.activated_window.buffer = buffer_list.first
      end
    end
    buffer.kill!
    nil
  end

kill-buffer (C-x 0) コマンドはこれを使って、 バッファを削除します。 現窓のバッファ名をデフォルトにして、 buffer_list から補完してミニバッファからバッファ名を得るところは一緒です。 もしバッファが変更されていないときは、 上の kill_buffer を使って削除して終わりです。 変更されているときは、 引き続き、 y か n の入力を待ち、 y のときに、 上のメソッドを使って削除します。

class KillBuffer < SwitchToBuffer
  def self.name() :kill_buffer end

  def edit(arg=nil)
    _, list = take_buffer_completion()
    name_default = window.buffer.name
    prompt = 'Kill buffer (default %s): ' % [name_default]
    name = ''
    screen.read_string(prompt, '', completion: list) do |str|
      case minibuffer.control
      when 1
        name = ('' == str) ? name_default : str
        buf = editor.get_buffer(name)
        if buf.nil?
          screen.minibuffer_pop()
          screen.miniwindow.print('', 'Kill buffer not found %s.' % [name])
        elsif ! buf.changed?
          screen.minibuffer_pop()
          screen.miniwindow.print('', 'Kill buffer %s.' % [name])
          editor.kill_buffer(name)
        else
          minibuffer.control = 2
          minibuffer.miniwindow.print(
            'Kill buffer %s changed. y or n ? (default n): ' % [name], '')
          minibuffer.completion = nil
          minibuffer.confirm = 'yn'
        end
      when 2
        if str == ''
          str = 'n'
        end
        screen.minibuffer_pop()
        if 'y' == str
          editor.kill_buffer(name)
        end
      end
    end
  end
end

これでエディタの導入部を記述することができるようになりました。 まず、 argv を getopt で解釈して、 パス名とプラス記号オプションによる行番号指定の組を files 配列に並べます。 続いて、 tty を初期し、 screen を作ります。 Screen の new のブロックで、 @screen に設定して get_buffer_create で screen 属性を使えるようにしてから、 files 配列から buffer を作り、 ファイルを読み込みます。 この導入部ではコマンドラインに同じファイル名が重複していてもエラーとはせず、 2 番目以降を無視します。 buffer_list の先頭と、 miniwindow 用バッファをブロックの戻り値にして、 screen の初期化をおこなわせます。 その後は、 画面表示・キーボード入力・コマンド実行を繰り返します。

#@<run>=
  def run(argv)
    opt = getopt(argv)
    if ! opt[:error].empty?
      raise OptionError, "invalid option " + opt[:error].join(" ")
    elsif opt[:version] || opt[:help]
      puts "version %s" % [VERSION] if opt[:version]
      puts "craft of text editing on ruby-lang." if opt[:help]
      return 0
    end
    # @kill_ring = KillRing.new(kill_ring_max)
    XTerm.open('/dev/tty') do |tty|
      tty.raw do
        tty.alternate_buffer do
          Screen.new(tty) do |scr|
            @screen = scr
            opt[:files].reverse_each do |path, line_number|
              buffer = get_buffer_create_from_path(path)
              if buffer.filename.empty?
                buffer.read_file(path)
                if line_number > 0
                  buffer.dot.point = buffer.forward_line(0, line_number - 1)
                end
              end
            end
            if buffer_list.empty?
              get_buffer_create('UNTITLED')
            end
            [buffer_list.first,
             Buffer.new(screen, ' *miniwindow*').flush_undo]
          end
          screen.miniwindow.buffer.mode = minibuffer_local
          read_evaluate_print_loop(tty)
        end
      end
    end
    0
  end

現在、 コマンド・ライン引数のオプションはプラス記号による行番号指定だけです。 コメントの例のように、 ファイルごとに行番号を指定することができます。

#@<getopt@>=
  # $ ruby SCRIPT +100 foo bar +120 baz
  #=> {:files => [["foo", 100], ["bar", 0], ["baz", 120]]}
  def getopt(argv, opt={})
    opt[:help] = opt[:version] = false
    opt[:error] = []
    opt[:files] = []
    opt_plus = 0
    i = 0
    while i < argv.size
      if %r/^[+](\d+)$/ =~ argv[i]
        opt_plus = $1.to_i
      elsif '--help' == argv[i]
        opt[:help] = true
      elsif '--version' == argv[i]
        opt[:version] = true
      elsif %r/^[+-]/ =~ argv[i]
        opt[:error] << argv[i]
      else
        opt[:files] << [argv[i], opt_plus]
        opt_plus = 0
      end
      i += 1
    end
    opt
  end