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

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

テキスト挿入もしくは削除に伴い、 マークと行折り返しレイアウト・キャッシュを更新する目的で、 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

テキスト・エディタのインクリメンタル・サーチ

Emacs のインクリメンタル・サーチを真似してみます。 このサーチ法は、 1 文字入力するごとに、 パターンを 1 文字増やしながら同時に文字列を探します。 開始方法は 2 つあります。 isearch-forward (C-s) コマンドで現点から後方への検索を開始し、 isearch-backward (C-r) コマンドで前方への検索を開始します。 どちらにしても、 ISearchDialog のインスタンスを作成してキーボード入力を譲り渡します。 これのクラスは MiniBufferBase の派生クラスで、 tail にキーボード入力を戻すウィンドウを覚えています。 そして、 restart メッセージでインクリメンタル・サーチを開始します。

class ISearchForward < Interactive
  def self.name() :isearch_forward end

  def edit(arg)
    screen.activated_window_push ISearchDialog.new(window)
    minibuffer.restart(:search_forward)
  end
end

class ISearchBackward < Interactive
  def self.name() :isearch_backward end

  def edit(arg)
    screen.activated_window_push ISearchDialog.new(window)
    minibuffer.restart(:search_backward)
  end
end

キーボード入力とエコー領域への表示を ISearchDialog が担当し、 検索の仕組みは ISearchBuffer クラスが提供します。 ISearchBuffer は GapBuffer にインクリメンタル・サーチ機能をつけるためのラッピング・オブジェクトで、 Buffer の isearch 属性になっています。

class Buffer
  attr_reader :content, :isearch
  # 省略

  def initialize(name)
    # 省略
    @content = GapBuffer.new
    @isearch = ISearchBuffer.new(@content)
    # 省略
  end
end

ISearchBuffer は、 被検索バッファを content 属性に、 前に検索したパターンを history インスタンス変数に覚えておきます。 mode 属性は、 検索時用の局所的なモードです。 ここに検索中のコマンドとキー結合を定義しておきます。 restarted 以下の状態変数は、 エコー領域のプロンプトを Emacs 風の記述で表示するために使います。

class ISearchBuffer
  attr_reader :content, :pattern
  attr_accessor :mode

  def initialize(content)
    @content = content
    @mode = nil
    @journal = []
    @pattern = ''
    @history = ''
    @restarted = false
    @failing = false
    @wrapped = false
    @direction = @default = :search_forward
  end

isearch-forward (C-s) コマンドで始めたのか、 それとも isearch-backward (C-r) コマンドで始めたのかを記録して、 Undo (DEL) のためのジャーナリング・リストを空にして、 パターンも空文字列にします。

#@<インクリメンタル・サーチ開始@>=
  def restart(direction)
    @journal.clear
    @pattern.clear
    @restarted, @failing, @wrapped = true, false, false
    @direction = @default = direction
  end

検索の正常終了時に、 入力済みのパターンを @history インスタンス変数に覚えておきます。

#@<検索正常終了@>=
  def ok()
    @history = @pattern.dup
    @journal.clear
    @pattern.clear
  end

Undo (DEL) のためのジャーナリング・リストへ記録するのは、 現点、 パターン長、 3 つのプロンプト表示用状態変数です。

#@<ジャーナリング@>=
  def log(dot)
    @journal << [dot.point, @pattern.size, @failing, @wrapped, @direction]
  end

さて、 C-s C-s のように開始直後に開始時と同じキーをタイプすると、 覚えておいた @history インスタンス変数から入れ直します。 開始直後ではなく既にパターンを入力済みのときに search-repeat (C-s) か search-reverse (C-r) とタイプすると、 パターンを変更せずに再検索をかけます。 パターンが見つかってないときは、 C-s ならバッファの先頭へ戻り、 C-r ならバッファの末尾へ戻ってから再検索をかけます。 先頭や末尾へ戻ったときは wrapped 状態をオンにします。 log は後述するように、 インクリメンタル・サーチ専用のジャーナリング・ログを記録します。

#@<同じパターンで再検索@>=
  def again(dot, direction)
    log(dot)
    if @restarted && @default == direction
      @pattern = @history.dup
    end
    loc = dot.point
    if @failing && @direction == direction
      loc = :search_forward == direction ? 0 : content.size
      @wrapped = true
    end
    dot.point = search(content, loc, dot.point)
    if @failing
      @journal.pop
    end
  end

self-insert-command で文字をタイプすると、 パターンに追加します。 このとき、 追加後もパターンに一致し続けるときは、 一致点を動かしません。 一致しないときは次に一致する座標を探します。 その検索方向はインクリメンタル・サーチを始めたときのものに従います。

#@<パターンに一文字追加@>=
  def insert_char(dot, ch, count)
    log(dot)
    if failing?
      loc = dot.point
    elsif :search_forward == @default
      loc = dot.point - @pattern.size
    else
      loc = dot.point + @pattern.size
    end
    @pattern << ch
    dot.point = search(@default, loc, dot.point)
  end

パターンに追加できるのはキーボード入力からだけでなく、 yank-word (C-w) 一致している箇所の単語の後ろまでをパターンへ取り込むこともできます。 単語は空白で終わるものと決め打ちしています。 パターンへの取り込みは、 単語の終わりまで一度におこなうのではなく、 1 文字ずつ取り込んでいます。 こうすることで、 パターンの末尾の数文字を DEL で削ってタイプし直すことができるようにしています。

#@<現点以後の単語をパターンに取り込み@>=
  def yank_word(dot)
    @restarted = false
    not @failing or return
    loc = content.find_first_in_forward(" \n\t", dot.point, false)
    if dot.point < loc
      a = content[dot.point ... loc].grapheme_clusters
      while ! a.empty?
        log(dot)
        @direction = :search_forward
        @failing = false
        @pattern << a.shift
        dot.point += 1
      end
    end
  end

文字列検索は content に任せます。 search の引数は、 成功時と失敗時の座標を指定し、 どちらかが search の戻り値になります。

#@<パターンを検索@>=
  def search(direction, loc, fail_loc)
    @restarted = false
    @direction = direction
    if ! @pattern.empty? && (loc = content.send(direction, @pattern, loc))
      @failing = false
      loc
    else
      @failing = true
      fail_loc
    end
  end

content 属性は GapBuffer クラスのインスタンスでした。

class GapBuffer
#@<search_forward@>
#@<search_backward@>
  # 省略
end

文字列の検索は力任せ法でやっています。 ギャップの前で探し、 ギャップに重なる分を探し、 ギャップの後を探します。 見つかったら、 一致した文字列の右端の座標を返します。 見つからなかったときは nil を返します。

#@<search_forward@>=
  def search_forward(str, loc)
    not str.empty? or return loc
    gs, ge, gap = @gap_start, @gap_end, @gap_end - @gap_start
    n = self.size
    while true
      j = loc + str.size
      if n - loc < str.size
        return nil
      elsif loc + str.size <= gs
        return j if @data[loc, str.size] == str
      elsif gs <= loc
        return j if @data[loc + gap, str.size] == str
      else
        return j if @data[loc ... gs] + @data[ge ... loc + str.size + gap] == str
      end
      loc += 1
    end
  end

前向き検索も同じです。 一致した文字列の左端の座標を返すのが異なっています。

#@<search_backward@>=
  def search_backward(str, loc)
    not str.empty? or return loc
    gs, ge, gap = @gap_start, @gap_end, @gap_end - @gap_start
    loc = [loc + str.size - 1, self.size].min
    while true
      j = loc - str.size
      if loc < str.size
        return nil
      elsif loc <= gs
        break j if @data[j, str.size] == str
      elsif gs <= j
        break j if @data[j + gap , str.size] == str
      else
        break j if @data[j ... gs] + @data[ge ... loc + gap] == str
      end
      loc -= 1
    end
  end

DEL キーで実行する undo では、 状態変数を一つ前に戻して、 パターンを前の状態へ切り詰めます。

#@<undo@>=
  def undo(dot)
    if ! @journal.empty?
      dot.point, size, @failing, @wrapped, @direction = @journal.pop
      @pattern[size ... @pattern.size] = ''
    end
    @restarted = false
  end

C-g キーは、 途中までパターンと一致していて、 残りが不一致のときは、 一致している分までパターンを入力を巻き戻します。 というのが GNU Emacs のふるまいなのですが、 ここでは横着しているので、 パターンとの一致部分がなくて検索しているときは、 前に一致した文字列の位置へ巻き戻す DEL と同じふるまいをしてしまいます。

#@<パターンの不一致分を巻き戻す@>=
  def clean(dot)
    while ! @journal.empty? && @journal.last[2]
      @journal.pop
    end
    undo(dot)
  end

C-g キーで、 検索が成功しているときは、 検索そのものをおこなわなかったことにして、 検索を開始した座標へ現点を戻します。

#@<検索をなかったものにする@>=
  def revert(dot)
    dot.point = @journal.first[0] if ! @journal.empty?
    @journal.clear
    @pattern.clear
  end

ISearchDialog がプロンプトを組み立てるために状態変数を読めるようにしておきます。

#@<状態変数読み取り@>=
  def failing?()
    @failing
  end

  def wrapped?()
    @wrapped
  end

  def backward?()
    @direction == :search_backward
  end

続いて、 ISearchDialog です。 これは MiniBufferBase の派生クラスです。 buffer の isearch 属性をバッファに指定し、 このオブジェクトでラッピングされた Window を window 属性にしています。 コマンドと isearch とを適切に結びつけて、 isearch の状態変数からプロンプトを作っていきます。

class ISearchDialog < MiniBufferBase
  def initialize(window)
    super
    @inactive = true
    @auto_exit_minibuffer = true
  end

  def buffer() tail.buffer.isearch end
  def window() tail end
  def restart(direction) buffer.restart(direction) end
  def insert_char(ch, count) buffer.insert_char(dot, ch, count) end
  def redisplay_setup() miniwindow.print(prompt, buffer.pattern) end
  def redisplay_teardown() nil end

  def exit_minibuffer()
    buffer.ok()
    miniwindow.clear
    screen.activated_window_pop()
  end

  def abort_recursive_edit()
    if buffer.failing?
      buffer.clean(dot)
    else
      buffer.revert(dot)
      miniwindow.clear
      screen.activated_window_pop()
    end
  end

  def guess_key(command)
    print_key(command)
    screen.redisplay()
    screen.refresh
    activate
  end

  def print_key(command)
    miniwindow.print(prompt, '%s %s' % [buffer.pattern, command])
  end

  def unbound(command)
    miniwindow.print(prompt, '%s (Unbound %s)' % [buffer.pattern, command])
  end

private

  def prompt()
    t = ''
    t << 'Failing ' if buffer.failing?
    t << 'Wrapped ' if buffer.wrapped?
    t << 'I-search'
    t << ' backward' if buffer.backward?
    t << ': '
  end
end

テキスト・エディタのミニバッファ

ミニバッファは、 コマンド実行に必要な文字列を利用者との対話によって得たいときに使います。 普段、 キーボード入力は Window につながっています。 それを一時的にミニバッファが譲り受けて、 対話が終わると、 Window へ返却します。 キーボード入力のつなぎ先は Screen の activated_window 属性で指定します。 MiniBuffer が必要になったとき、 インスタンスをその都度作成して、 この属性にセットし、 不要になった時点でキーボード入力を返却してインスタンスを廃棄します。 そこは定型処理なので、 Screen の read_string と minibuffer_pop メソッドにまとめてあります。

class Screen
  def read_string(prompt, initial, completion:nil, confirm:nil, &blk)
    activated_window_push(MiniBuffer.new(activated_window))
    activated_window.completion = completion
    activated_window.confirm = confirm
    activated_window.ok(&blk) if block_given?
    activated_window.restart(prompt, initial)
  end

  def minibuffer_pop()
    activated_window.completion = nil
    activated_window.completion_table = nil
    activated_window_pop()
  end
end

文字列補完の例が典型的な read_string の使い方です。 文字列補完のためのリストを作成して completion に渡すとそれを使った補完が働きます。 confirm を "yn" のように指定すると、 y か n の一文字入力で文字列入力を終了させるようになります。 read_string にブロックを渡すと ok コールバックにします。 ok コールバックは RET キーや confirm の文字で入力が終わる都度に呼び出されます。 そして、 プロンプトと文字列の初期設定値を、 restart に渡します。

なお、 手元のコードでは、 未だに GNU Emacs の interactive 特殊形式の機能を実装しておらず、 それぞれのコマンドが read_string でミニバッファを作った対話を進める書き方をしています。 interactive 特殊形式に倣うと、 コマンド実行に必要な文字列引数を Editor がコマンド実行前にミニバッファを使って読み取ってから、 Interactive に引数として渡すようになるのでしょう。 その方が、 コマンドと関数の差が埋まって都合が良いので、 いずれは interactive 特殊形式を取り入れて、 コマンドの書き方を変更する予定です。

MiniBufferBase クラスは、 これの派生クラスになる MiniBuffer と ISearchDialog に共通するメソッドを抜き出したものです。 lookup_key は、 @auto_exit_minibuffer が真のとき、 キー・シーケンスが元のウィンドウで解釈可能な場合に自動的にミニバッファを完了させる機能があります。 ISearchDialog が使います。 @inactive 属性は、 真のときカーソル表示を tail ウィンドウで、 偽のとき miniwindow でおこないます。 inactive 属性も ISearchDialog のための機能です。

class MiniBufferBase
  attr_reader :tail

  def initialize(window)
    @tail = window
    @inactive = false
    @auto_exit_minibuffer = false
  end

  def lookup_key(&blk)
    command = buffer.mode.keymap.match(screen.tty, &blk)
    if @auto_exit_minibuffer && command.name.nil?
      command.unmatch(screen.tty)
      command = window.lookup_key(&blk)
      if ! command.name.nil?
        exit_minibuffer()
      end
    end
    command
  end

  def activate()
    if @inactive
      tail.activate()
    else
      miniwindow.activate()
    end
  end

  def minibuffer?() true end
  def screen() tail.screen end
  def miniwindow() tail.screen.miniwindow end
  def last_command=(x) nil end
  def release_sticky_x() nil end
  def layout() window.layout end
  def dot() window.dot end
  def guess_key(command) nil end
  def print_key(command) nil end
  def unbound(command) nil end
  def before_execute_command(command) nil end
end

MiniBuffer のメソッドの半数は文字列補完で説明が済んでいます。

class MiniBuffer < MiniBufferBase
  include Completable

  attr_accessor :control, :confirm
  attr_accessor :completion, :completion_table

  def initialize(window)
    super
    # 省略
    @control = 1
    @confirm = nil
    @ok_callback = lambda {|x| screen.activated_window_pop() }
    @cancel_callback = lambda { nil }
  end

  def ok(&blk) @ok_callback = blk; self end
  def cancel(&blk) @cancel_callback = blk; self end

#@<buffer と window@>
#@<restart@>
#@<exit_minibuffer@>
#@<abort_recursive_edit@>

  # 省略
end

MiniBuffer では miniwindow を window として扱います。 buffer も miniwindow のバッファとします。 こうすることで、 MiniBuffer のキーボード入力によって編集がおこなわれる箇所が miniwindow のバッファへ切り替わります。

#@<buffer と window@>=
  def buffer()
    miniwindow.buffer
  end

  def window()
    miniwindow
  end

Screen の read_string は MiniBuffer オブジェクトを作成し、 文字列補完用のリスト等を登録してから、 MiniBuffer に restart メッセージを送ります。 これによってプロンプトと文字列を MiniWindow にセットし、 control 属性を 1 にします。

#@<restart@>=
  def restart(prompt, initial)
    miniwindow.print(prompt, initial)
    @control = 1
  end

MiniBuffer で利用可能なコマンドは miniwindow のバッファの mode に設定してあります。 それらのコマンドは特に設定していなくても、 tail のバッファから区別せず、 miniwindow のバッファの編集をおこないます。 ただし、 いくつかは特別扱いが必要なので、 外に切り出してあります。

切り出した筆頭は self-insert-command 等が使う self_insert メソッドです。 @confirm が nil だと、 バッファへ挿入します。 nil でないときは、 バッファに挿入してあから ok コールバックを呼びます。

#@<insert_char@>=
  def insert_char(ch, count)
    if @confirm.nil?
      buffer.insert_char(dot, ch, count)
    elsif @confirm.include?(ch)
      buffer.insert_char(dot, ch, count)
      @ok_callback.call(buffer.to_s)
    end
  end

@confirm が nil ときに RET キーを押すと、 ok コールバックを呼びます。 このコールバックは、 @confirm が nil でないときに登録されたキーを押しても呼び出します。

#@<exit_minibuffer@>=
  def exit_minibuffer()
    @ok_callback.call(buffer.to_s)
  end

どのようなときであっても、 ミニバッファで abort-recursive-edit (C-g) コマンドを実行すると、 現在の編集内容を捨てて、 元のウィンドウへ戻ります。 その途中、 cancel コールバックを呼びます。

#@<abort_recursive_edit@>=
  def abort_recursive_edit()
    @cancel_callback.call
    @completion = nil
    @completion_table = nil
    miniwindow.clear
    screen.activated_window_pop()
  end