テキスト・エディタのカーソル移動

Craig Finseth は、 文字単位で現点を前後へ動かす関数を point_move としていました。 その関数は移動量を示す count を引数にとってました。 count が負のときは前方向へ現点を動かし、 正のときは後方向へ現点を動かしていました。 count の絶対値は移動量の文字数でした。 ところで、 文字とは何を指して文字とすれば良いのでしょうか。 自然な解釈では、 画面に表示されているグリフ 1 個になるのでしょう。 この場合、 表示されているグリフはバッファでは書記素クラスタに対応していると考えられます。 そのため、 point_move は count で指定した数の書記素クラスタの先頭座標分、 現点を前後に動かす関数だと考えれば良いのでしょう。

書記素クラスタの開始座標列は、 行の折り返し表示用に求めることができています。 それを point_move でも利用することにしましょう。 ところが、 行単位でしか座標列を求めることができないため、 行から行へ移動する手当てが必要です。 なお、 Finseth の point_move は現点を動かすのに対して、 この point_move は引数で指定したユーザ座標 loc から count 移動した先のユーザ座標を返します。

module CharInteractivable
  def point_move(loc, count)
    if count >= 0
      grapheme_cluster_forward(loc, count)
    else
      grapheme_cluster_backward(loc, -count)
    end
  end

#@<grapheme_cluster_forward@>
#@<grapheme_cluster_backward@>
end

grapheme_cluster_forward は、 ユーザ座標 loc から count 個後書記素クラスタのユーザ座標を求めて返します。 loc を含む行の書記素クラスタ開始座標列を line へ取り出して、 loc を探索します。 count 個後が line 中あるなら、 そこの座標に loc をセットします。 ないときは、 次の行へ移るために、 loc と count を更新します。

#@<grapheme_cluster_forward@>=
  def grapheme_cluster_forward(loc, count=1)
    while count > 0 && loc < current_buffer.size
      line = current_buffer.grapheme_clusters_of_line(loc)
      i = line.h.index {|x| loc <= line.first + x }
      if i + count < line.h.size
        loc = line.first + line.h[i + count]
        count = 0
      else
        loc = line.last
        count -= line.h.size - i - 1
      end
    end
    loc
  end

前方向版は grapheme_cluster_backward です。 loc を含む行の line を取り出し、 loc を探すところは同じです。 count 個前が line 中に見つかったときは探索は終わりです。 そうでないときは、 前の行へ移るために、 loc と count を更新します。 なお、 座標列 line の最後の要素は次の行の先頭座標にしてしまったのに対応して、 それのさらに一つ前へ移っておきます。

#@<grapheme_cluster_backward@>=
  def grapheme_cluster_backward(loc, count=1)
    while count > 0 && loc > 0
      line = current_buffer.grapheme_clusters_of_line(loc)
      i = line.h.index {|x| loc <= line.first + x }
      if count <= i
        loc = line.first + line.h[i - count]
        count = 0
      else
        loc = line.first
        count -= i
        if loc > 0
          loc -= 1
          count -= 1
        end
      end
    end
    loc
  end

forward-char (C-f) コマンドを、 point_move を使って作ることができます。 手元のコードでは、 編集コマンドは関数オブジェクトの一種にしています。 ESC x でコマンドを指定するための名前がついており、 edit メソッドで、 編集を実行するようにしてあります。 edit の引数 arg へ数値引数が渡り、 引数で指定した分、 後方へ現点 window.dot を動かします。 count に負の数を指定したときは、 point_move のふるまいにより、 前方へ現点を動かすことになります。

class ForwardChar < Interactive
  include CharInteractivable

  def self.name() :forward_char end

  def edit(arg)
    count = digit_argument(arg) || 1
    window.dot.point = point_move(window.dot.point, count)
  end
end

backward-char (C-b) コマンドも同様に point_move を使って現点を前方へ動かします。

class BackwardChar < Interactive
  include CharInteractivable

  def self.name() :backward_char end

  def edit(arg)
    count = digit_argument(arg) || 1
    window.dot.point = point_move(window.dot.point, -count)
  end
end

文字削除にも、 point_move を使います。 現点の左側の文字を削除する delete-backward-char (DEL) コマンドでは、 point_move で数引数の個数分、 現点 window.dot から前方向にある座標を loc に求めて、 loc と 現点の間にある文字列をバッファから削除します。

class DeleteBackwardChar < Interactive
  include CharInteractivable

  def self.name() :delete_backward_char end

  def edit(arg)
    count = digit_argument(arg) || 1
    loc = point_move(window.dot.point, -count)
    current_buffer.delete(window.dot, loc - window.dot.point)
  end
end

現点の右側の文字を削除する delete-char (C-d) コマンドも、 point_move で数引数の個数分、 現点から後方向にある座標 loc を求め、 現点と loc の間にある文字列をバッファから削除します。

class DeleteChar < Interactive
  include CharInteractivable

  def self.name() :delete_char end

  def edit(arg)
    count = digit_argument(arg) || 1
    loc = point_move(window.dot.point, count)
    current_buffer.delete(window.dot, loc - window.dot.point)
  end
end

現点を後方の画面表示行へ動かす next-line (C-n) コマンドは、 Window の Layout を使って、 移動先を求めます。 Layout には move_line メソッドがあり、 表示行単位で移動できるようにしてあるので、 それを使います。 window の sticky_x 属性は、 ある桁からカーソル移動を始めたところ、 それよりも短い行やその桁に全角文字の右半分があるようなとき、 とりあえずカーソルを置ける位置へずらしますが、 またもや他の行に移ったときに同じ桁にカーソルを置けるなら、 その桁へカーソルを戻すために桁を控えておくための属性です。

class NextLine < StickyxInteractive
  def self.name() :next_line end

  def edit(arg)
    count = digit_argument(arg) || 1
    count != 0 or return
    round_flag = current_buffer.mode.round_flag
    y, x = window.layout.move_line(window.dot.point, count).divmod(window.size.x)
    loc = y * window.size.x + window.retain_sticky_x(x)
    window.dot.point = window.layout.set_column(loc, round_flag)
  end
end

逆に前方の画面表示行へ動かす previous-line (C-p) コマンドもほとんど同じになります。

class PreviousLine < StickyxInteractive
  def self.name() :previous_line end

  def edit(arg)
    count = digit_argument(arg) || 1
    count != 0 or return
    round_flag = current_buffer.mode.round_flag
    y, x = window.layout.move_line(window.dot.point, -count).divmod(window.size.x)
    loc = y * window.size.x + window.retain_sticky_x(x)
    window.dot.point = window.layout.set_column(loc, round_flag)
  end
end

折り返しを考慮しないバッファ内で行単位の移動をおこなうには、 forward-line コマンドを使います。 このコマンドは、 改行を行の区切り記号として count が正のときは後方へ、 負のときは前方へ現点を動かします。 動かした後、 行頭に現点を置きます。

class ForwardLine < Interactive
  include LineInteractivable

  def self.name() :forward_line end

  def edit(arg)
    count = digit_argument(arg) || 1
    window.dot.point = forward_line(window.dot.point, count)
  end
end

forward-line コマンドの本体の forward_line メソッドは、 Line 構造体を使わずに、 改行コードを探すやりかたで書いています。 forward_line の使い道は数行内の移動か、 画面表示高を越える大きな移動のどちらかで、 キャッシュしている Line をごっそり入れ替えてしまうことがあるためです。

module LineInteractivable
  def forward_line(loc, count)
    if count > 0
      count.times {
        loc = [current_buffer.find_first_in_forward("\n", loc) + 1, current_buffer.size].min
        loc < current_buffer.size or break
      }
    else
      loc = current_buffer.find_first_in_backward("\n", loc)
      count.abs.times {
        loc > 0 or break
        loc = current_buffer.find_first_in_backward("\n", loc - 1)
      }
    end
    loc
  end

  def beginning_of_line(loc)
    current_buffer.find_first_in_backward("\n", loc)
  end

  def end_of_line(loc)
    current_buffer.find_first_in_forward("\n", loc)
  end
end

beginning-of-line (C-a) コマンドで行の先頭へ現点を動かすには、 上の beginning_of_line を使います。

class BeginningOfLine < Interactive
  include LineInteractivable

  def self.name() :beginning_of_line end

  def edit(arg)
    window.dot.point = beginning_of_line(window.dot.point)
  end
end

end-of-line (C-e) コマンドで行の末尾へ現点を動かすには、 上の end_of_line を使います。

class EndOfLine < Interactive
  include LineInteractivable

  def self.name() :end_of_line end

  def edit(arg)
    window.dot.point = end_of_line(window.dot.point)
  end
end

scroll-up (C-v) コマンドで次画面へ画面開始点を動かすのにも、 layout の move_line を使います。 画面開始点が画面の左端になるように通算桁を選ぶのが next-line との違いです。 また、 画面移動に伴い現点が画面外に出たときは、 画面内に移動します。

class ScrollUp < StickyxInteractive
  def self.name() :scroll_up end

  def edit(arg)
    count = digit_argument(arg)
    count ||= window.size.y - current_buffer.mode.next_screen_context_lines - 1
    count != 0 or return
    count > 0 or return editor.edit(:scroll_down, -count)
    x = window.layout.get_column(window.dot.point) % window.size.x
    window.layout.framer()
    y = window.layout.move_line(window.start.point, count) / window.size.x
    window.start.point = window.layout.set_column(y * window.size.x, false)
    if window.dot.point < window.start.point
      round_flag = current_buffer.mode.round_flag
      loc = y * window.size.x + window.retain_sticky_x(x)
      window.dot.point = window.layout.set_column(loc, round_flag)
    end
  end
end

逆方向の scroll-down (ESC v) コマンドでは、 画面の外に現点が出てしまったとき、 画面の下の行へ現点を移動するので、 画面の最下行を求めなければなりません。

class ScrollDown < StickyxInteractive
  def self.name() :scroll_down end

  def edit(arg)
    count = digit_argument(arg)
    count ||= window.size.y - current_buffer.mode.next_screen_context_lines - 1
    count != 0 or return
    count > 0 or return editor.edit(:scroll_up, -count)
    window.layout.framer()
    x = window.layout.get_column(window.dot.point) % window.size.x
    y = window.layout.move_line(window.start.point, -count) / window.size.x
    window.start.point = window.layout.set_column(y * window.size.x, false)
    y = window.layout.move_line(window.start.point, window.size.y - 2) / window.size.x
    round_flag = current_buffer.mode.round_flag
    loc = y * window.size.x + window.retain_sticky_x(x)
    bottom_point = window.layout.set_column(loc, round_flag)
    if window.dot.point > bottom_point
      window.dot.point = bottom_point
    end
  end
end

表示中の端末画面内で、 上から何行目、 もしくは下から何行目へと、 表示行の先頭へ現点を動かすのが move-to-window-line (ESC r) コマンドです。 このコマンドは表示開始点を動かさずに、 現点を移動します。 これも Layout を使って書くことができます。 数引数の指定がないときは GNU Emacs にならって、 画面の中央の行の行頭へ現点を動かします。 数引数がゼロ以上のときは画面の上からの行位置を指定し、 画面上端の行をゼロとします。 負のときは画面の下からの行位置を指定子、 画面下端の行を -1 とします。 ただし、 バッファ末尾を表示しているときは、 画面下端ではなく、 バッファ末尾の行を -1 とします。

class MoveToWindowLine < Interactive
  def self.name() :move_to_window_line end

  def edit(arg)
    count = digit_argument(arg)
    count ||= (window.size.y - 1) / 2
    if count < 0
      count = window.size.y + count - 1
    end
    count = count < 0 ? 0 : count > window.size.y - 2 ? window.size.y - 2 : count
    window.layout.framer()
    y = window.layout.move_line(window.start.point, count) / window.size.x
    window.dot.point = window.layout.set_column(y * window.size.x, false)
  end
end

画面上での行位置を数引数で指定するのが同じでも、 move-to-window-line コマンドとは逆に、 現点を動かさずに表示開始行を動かすのが、 recenter (C-l) コマンドです。 なお、 手元のコードでは、 window.redisplay が、 画面内にバッファ末尾があるとき、 強制的に画面開始点をセットしてしまうため、 バッファ末尾を表示中のときは、 recenter は単なる画面の書き直しをするためだけのコマンドとしてふるまいます。

class Recenter < StickyxInteractive
  def self.name() :recenter end

  def edit(arg)
    arg or screen.clear
    count = [4] == arg ? nil : digit_argument(arg)
    count ||= (window.size.y - 1) / 2
    if count < 0
      count = window.size.y + count - 1
    end
    count = count < 0 ? 0 : count > window.size.y - 2 ? window.size.y - 2 : count
    round_flag = current_buffer.mode.round_flag
    y = window.layout.move_line(window.dot.point, -count) / window.size.x
    window.start.point = window.layout.set_column(y * window.size.x, round_flag)
  end
end