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

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

テキスト・エディタのウィンドウ

Window は WindowBase の派生クラスです。 同じ WindowBase の派生クラス MiniWindow との違い、 MiniWindow が画面出力専用であるのに対し、 Window はコマンド実行による編集作業を補助するメソッドをいくつか備えています。 また、 モード行をもっており、 redisplay でモード行の描画をおこないます。

class Window < WindowBase
  attr_accessor :last_command

  def initialize(screen, buffer)
    super
    @sticky_x = nil
    @last_command = false
  end

  def modeline_height() 1 end

#@<redisplay@>
#@<display_mode_line@>
#@<activate@>
#@<hilite_matching_parenthesis@>
#@<activate_cursor@>
#@<lookup_key@>
#@<guess_key@>
#@<print_key@>
#@<before_execute_command@>
#@<unbound@>
#@<insert_char@>
#@<keyboard_quit@>
#@<copy@>
#@<buffer_release@>
#@<buffer=@>
#@<stichy_x@>
end

メソッドは大きく 4 つに分かれます。

  1. Screen からの redisplay メッセージによって再描画をおこなうもの
  2. Editor からの activate メッセージによってカーソルを置くもの
  3. Editor からの lookup_key メッセージによって Command を読みこむもの
  4. Interactive からのコマンド実行過程で呼ばれるもの

これらはすべて Editor のコマンド実行ループの中から順に実行されていきます。 キーボード入力につながるウィンドウは 1 度に 1 つに限り、 Screen の activated_window 属性で示しています。 再描画はすべてのウィンドウに対しておこないますが、 その後のカーソル表示・コマンド入力までは activated_window が担当します。

class Editor
  def read_evaluate_print_loop(tty)
    screen.clear
    while live?
      if ! tty.ready?
        screen.redisplay                                        # 再描画
        screen.refresh
        screen.activated_window.activate                        # カーソル表示
      end
      @command = screen.activated_window.lookup_key {|x|        # キー入力
        tty.ready? or screen.activated_window.guess_key(x)      # 途中表示
      }
      tty.ready? or screen.activated_window.print_key(command)  # 完了時表示
      edit(command.name, command.arg)                           # コマンド実行
    end
    nil
  end
end

バッファからの再描画は Layout オブジェクトにまかせます。 その途中で、 ウィンドウ表示域にバッファ末尾を表示しているかどうを示す bottom_flag が求まります。 モード行の表示でそれを使います。

#@<redisplay@>=
  def redisplay()
    layout.framer()
    bottom_flag = layout.frame_end_of_buffer()
    layout.display_page()
    display_mode_line(bottom_flag)
  end

モード行は、 バッファが変更されているかどうか、 バッファの名前、 バッファのどこを表示しているかを整形して表示します。 モード行を反転表示させたくなったので、 暫定的に modes を Screen に新設し、 SGR のコードを指定できるようにしています。 バッファ名は文字列なので、 何桁で表示可能かを書記素クラスタに分解し、 wcwidth で書記素クラスタの幅を求めています。

#@<display_mode_line@>=
  def display_mode_line(bottom_flag)
    pos = (start.point.to_f / (buffer.size + 1).to_f * 100.0).to_i
    t1 = (buffer.changed?) ? '--**- ' : '----- '
    t3 = '(%s) ' % [buffer.mode.name]
    t3 += (0 == start.point && bottom_flag) ? '----ALL-------' \
       : 0 == start.point ? '----TOP-------' \
       : bottom_flag ? '----BOT-------' \
       : '----%2d%%-------' % [pos]
    width = size.x - t1.size - t3.size
    a = buffer.name.grapheme_clusters
    i, w = 0, 0
    t2 = ''
    while i < a.size
      span = screen.tty.wcwidth(a[i].ord)
      w + span <= width or break
      t2 << a[i]
      w += span
      i += 1
    end
    screen.lines[topleft.y + size.y - 1] = t1 + t2 + ' ' * (width - w) + t3
    screen.modes[topleft.y + size.y - 1] = 7
  end

Layout で display_page すると cursor 属性に現点の右側の文字を置いた位置が入ります。

#@<activate@>=
  def activate()
    hilite_matching_parenthesis()
    activate_cursor()
  end

そこに移動する前に、 現点の左側が閉じ括弧なら、 対応する開き括弧が画面に表示されている場合に限り、 そこへカーソルを短時間動かし、 括弧の対応を示します。

#@<hilite_matching_parenthesis@>=
  def hilite_matching_parenthesis()
    loc = buffer.find_left_parenthesis(start.point, dot.point) or return
    left = buffer[loc]
    y, x = layout.get_window_yx(loc)
    screen.tty.set_cursor(y, x).notice.print(left).flush
    sleep(0.3)
    screen.tty.set_cursor(y, x).normal.print(left).flush
  end
end

それから、 現点に対応する位置にカーソルを置くエスケープ・シーケンスを出力します。

#@<activate_cursor@>=
  def activate_cursor()
    screen.tty.set_cursor(cursor.y + topleft.y, cursor.x + topleft.x).flush
  end

カーソルを移動したら、 今度はキーボードから入力があるまで待ち、 コマンドが決まるまでキーを入力していきます。 コマンドはバッファの mode に登録済みの keymap を使って決定します。

#@<lookup_key@>=
  def lookup_key(&blk)
    buffer.mode.keymap.match(screen.tty, &blk)
  end

コマンドが決まるまで途中でキー入力待ちをするときは、 その都度、 入力済みのキー・シーケンスをエコー領域へ書いてして端末画面へ同期します。 そして、 カーソルをウィンドウの所定の位置へ戻します。

#@<guess_key@>=
  def guess_key(command)
    print_key(command)
    screen.redisplay()
    screen.refresh()
    activate_cursor()
  end

さらにコマンド決定後にも、 エコー領域にキー・シーケンスを書きます。 端末画面への同期はコマンド実行後におこなわれるので、 ここではバッファへ書き込むだけです。 ただし、 一文字挿入する文字をいちいちエコー領域に表示していくと見た目がうるさく感じたので、 数引数をつけてないときは表示しないようにしています。

#@<print_key@>=
  def print_key(command)
    if command.name == :self_insert_command && command.arg.nil?
      screen.miniwindow.clear()
    else
      screen.miniwindow.print('', command.to_s)
    end
  end

ここまでは Editor がコマンドを決定するまでの補助機能です。 ここからは、 コマンドを実行する Interactive が利用する機能に変わります。 すべての Interactive はコマンド実行前にキーボード入力がつながっているウィンドウもしくはミニバッファのコマンド実行前のフック・メソッドを呼びます。 Window ではコマンド実行前におこなうことはありません。

#@<before_execute_command@>=
  def before_execute_command(command)
    nil
  end

バッファの mode にキーが割り当てられていないとき、 もしくはキーが割り当ててあるコマンド名がつけてある Interactive オブジェクトが mode に登録されていないときは、 未登録を表すコマンド unbound を実行します。

class Unbound < Interactive
  def self.name() :unbound end

  def edit(arg)
    window.unbound(editor.command)
  end
end

unbound コマンドは activated_window の unbound メソッドを呼びます。 Window では、 エコー領域に未登録のメッセージを表示します。

#@<unbound@>=
  def unbound(command)
    screen.miniwindow.print('', '(Unbound %s)' % [command])
  end

コマンド自身になっている印字可能文字をバッファに挿入するのは self-insert-command です。

class SelfInsertCommand < Interactive
  def self.name() :self_insert_command end

  def edit(arg)
    count = digit_argument(arg) || 1
    ch = editor.command.string
    window.insert_char(ch, count)
  end
end

このコマンドはキーボード入力をつないでいるウィンドウの insert_char メソッドを呼びます。 Window では、 このメソッドにより、 buffer への文字挿入をおこないます。

#@<insert_char@>=
  def insert_char(ch, count)
    buffer.insert_char(dot, ch, count)
    screen.miniwindow.clear()
  end

keyboard-quit (C-g) コマンドは、 エコー領域をクリアするだけで、 他には何もしません。

#@<keyboard_quit@>=
  def keyboard_quit()
    screen.miniwindow.clear()
  end

split-window-below コマンドで上下にウィンドウを分割するとき、 下になる Window をコピー処理で作成します。

#@<copy@>=
  def copy()
    win = Window.new(screen, buffer)
    win.dot.point = dot.point
    win.start.point = start.point
    win
  end

kill-window コマンド等でウィンドウを削除する前にバッファとのつながりを断ちます。 バッファはウィンドウ・オブジェクトを覚えているので、 バッファにウィンドウを忘れさせます。 また、 バッファがどこにも表示されなくなった段階の現点と表示開始点を記録しておきます。

#@<buffer_release@>=
  def buffer_release()
    layout.cache.clear
    buffer.window_delete(self)
    if buffer.window.empty?
      buffer.dot.point = dot.point
      buffer.start.point = start.point
    end
    @buffer = nil
    self
  end

ウィンドウとバッファは循環参照しているので、 バッファからウィンドウへの参照も削除しておきます。

class Buffer
  def window_delete(win)
    window.delete_if {|item| item.equal?(win) }
    self
  end
end

switch-to-buffer コマンドや find-file コマンド等はバッファを置き換えます。 そのとき、 前のバッファは buffer_release でつながりを断つ必要があります。 新しくつなぐバッファがどこにも表示されていなかったときは window 属性が空になっています。 その場合、 バッファに記録しておいた以前にウィンドウに表示されていたときの現点と表示開始点に戻します。 新しくつなぐバッファが既に表示しているときは、 選択中のウィンドウの現点と表示開始点を受け継ぎます。

#@<buffer=@>=
  def buffer=(buffer)
    buffer_release()
    @buffer = buffer
    if buffer.window.empty?
      dot.point = buffer.dot.point
      dot.revision = 0
      start.point = buffer.start.point
      start.revision = buffer.start.revision
    else
      dot.point = buffer.window.first.dot.point
      dot.revision = 0
      start.point = buffer.window.first.start.point
      start.revision = buffer.window.first.start.revision
    end
    buffer.window << self
    buffer
  end

next-line コマンドや previous-line コマンドは、 移動先の行の長さが足りなかったり、 全角文字の右半分に移動しようとしたりするときに、 カーソルを横にずらします。 ずれているときからさらに先に進み、 移動を開始したときと同じ桁にカーソルを置くことができるときは、 元の桁へ戻ります。 このように移動開始時の桁を覚えておくのが stichy_x 属性です。 この属性は桁を覚えておく必要がないときは nil になっていて、 覚えているときは Numeric になっています。 そのように属性値をセットする用途に、 2 つのメソッドを Interactive クラスへ提供します。

#@<stichy_x@>=
  def retain_sticky_x(x)
    @sticky_x ||= x % size.x
  end

  def release_sticky_x()
    @sticky_x = nil
  end