🌟

viのようなTUIエディタを作ってみる

に公開

はじめに

先日のMS BuildにてNotepadに変わるTUIエディタとして「Edit」が発表された。Editという名前の検索性が悪すぎてSNS上ではそればかり話題になっていたが、Rustで書かれているLightweightな実装ということで一部のRust界隈では違う盛り上がり方もしていた。

そんな中、ふとこのようなTUIエディタはどうやって作られているのだろうか?と気になった。こういう時は自分で実装してみるのが一番早いだろう、ということで一番手癖で書けるRubyで雑に実装してみることにした。

とりあえずこのくらいはできるようになっている。

https://x.com/razokulover/status/1928831239554789880

ソースコードはこれ。

https://github.com/YuheiNakasaka/ruvi

現状の機能としては以下。

  • 表示
    • 行折り返しのみ
  • ファイルの作成&編集
    • 文字の挿入
    • 文字の削除
    • 行の削除
  • 移動
    • 1文字単位
    • ページ単位
  • コマンド
    • 全文検索
  • ファイルの保存
  • マルチバイト文字非対応

本当にTUIエディタとして最小限の機能だが仕組みを理解するにはまずはこれで十分な気がする。以降では基本的な仕組みと実装の工夫点について書く。

基本的な仕組み

全体の構成

まずは全体の構成。このTUIエディタは以下の5つのファイルで構成されている。

  • main.rb
    • エントリーポイント
  • editor.rb
    • エディタのメインクラス
  • screen.rb
    • 画面表示とテキスト操作を担当
  • input.rb
    • ユーザー入力の処理を担当
  • escape_code.rb
    • ANSIエスケープシーケンス(後で説明)を扱うユーティリティ

main.rbはシンプルで、コマンドライン引数からファイルパスを取得し、Editorクラスのインスタンスを作成して実行するだけ。

def main
  args = ARGV
  file_path = args[0]
  editor = Editor.new(file_path)
  editor.run
end

editor.rbはエディタのメインループを担当している。ファイルの読み込み、画面の更新、入力処理の振り分けを行なっている。

def run
  $stdin.raw do
    loop do
      # フレームの更新処理色々
      @screen.init_logical_lines
      @screen.update_scroll_offset
      @screen.clear_screen
      @screen.draw_lines
      @input.draw_status_bar
      @screen.update_cursor_position

      # ユーザー入力に応じたコマンドの処理
      case handle_input
      when :insert
        next
      when :search_down
        next
      when :search_up
        next
      when :quit_force
        break
      when :quit
        break unless @screen.dirty?

        @input.draw_status_bar(message: ': No write since last change (add ! to override)')
        @input.to_command_mode
      when :write_quit_force
        @screen.save_file(@file_path)
        break
      else
        next
      end
    end
  end
end

あとはScreenクラスは画面表示とテキスト操作を担当し、Inputクラスはユーザー入力の処理を担当し、Editorクラスがそれらを統括しているという関係(ちゃんと責任分離しきれてない部分もある)。

ファイルを開く

まずはファイルを開くところから。

def initialize(file_path)
  @file_path = file_path

  if file_path.nil?
    puts 'No file path provided'
    exit
  end

  File.write(file_path, '') unless File.exist?(file_path)

  lines = File.exist?(file_path) && !File.zero?(file_path) ? File.readlines(@file_path, chomp: true) : []
  @screen = Screen.new(lines)
  @input = Input.new(screen: @screen)
end

File.readlinesでファイルを開いて配列に格納する。chomp: trueオプションを使うことで、各行末の改行文字を自動的に削除してくれる。

ファイルが存在しない場合は空のファイルを作成し、ファイルが空の場合は空の配列を用意する。これにより、新規ファイルの作成と既存ファイルの編集の両方に対応できる。最初は既存ファイルしか考えずに雑に作り始めたので新規ファイルの作成に対応するときに画面の表示領域のoffsetがバグってて地味に面倒だった...。

ユーザー入力を受け取る

エディタにするためには少なくともユーザー入力を受け入れ続ける必要がある。そのためにRubyのio/consoleという標準ライブラリとloopを使っている。

def run
  $stdin.raw do
    loop do
      # 画面の更新処理色々
      case handle_input
      # ユーザー入力に色々
      end
    end
  end
end

io/consolerawメソッドとgetchメソッドを組み合わせるとユーザーの入力を1文字ごと取得できるようになる。通常のターミナルでは、Enterキーを押すまで入力がバッファリングされるが、rawモードにすることで、キーを押した瞬間に入力を取得できるようになる。便利。

def escaped_input
  input = $stdin.getch
  case input
  when "\e"
    # escと矢印キーの組み合わせを切り分ける処理。
    Timeout.timeout(0.01) do
      input << $stdin.read_nonblock(2)
    rescue IO::WaitReadable, EOFError
      input
    rescue Timeout::Error
      input
    end
  when normal? && 'd'
    input << $stdin.getch
  else
    input
  end
end

ここで工夫が必要なのは、エスケープキー単体と、矢印キーなどのエスケープシーケンスを区別する処理。矢印キーは\e[Aのように複数バイトで構成されるため、タイムアウトを使って追加のバイトがあるかどうかを確認している。これでいいのかはわからんがとりあえず動く。

また、viライクなキーバインドを実装するために、dキーが押された場合は次の入力も取得して、例えばdd(行削除)のような複合コマンドに対応できるようにしている。雑。

画面表示の仕組み

読み込んだファイルをコンソール上に表示するには、メインループの中で毎ループ、ファイルの内容をレンダリングする必要がある。

def draw_lines
  visible_lines.each_with_index do |line, i|
    EscapeCode.move_to(1, i + 1)
    print line.ljust(visible_width)
  end
end

def visible_lines
  @display_lines[@scroll_offset, @visible_height] || []
end

ljustメソッドを使って、各行を画面幅に合わせて空白で埋めている。いい感じに横幅が詰まる。

ただまぁ画面表示には複雑な問題があって、コンソールは表示領域が限られているため、その中でどうやってテキストを表示するかを考える必要がある。特に、横方向に長い行は折り返す必要があり、その結果、表示される行数と実際のファイルの行数が一致しなくなる。この辺りから地獄の始まり。

def init_logical_lines
  @display_lines = []
  @line_map = []

  @lines = [''] if @lines.empty?

  @lines.each_with_index do |line, i|
    wraps = wrap_line(line, @col)
    wraps.each_with_index do |wrap, j|
      @display_lines << wrap
      @line_map << [i, j]
    end
  end
end

def wrap_line(line, width)
  return [''] if line.nil? || line.empty?

  line.scan(/.{1,#{width}}|.+/)
end

@line_mapという配列で、表示行と実際のファイルの行の対応関係を管理している。これにより、カーソル位置から実際の編集位置を計算できるようになる。

例えば、ファイルの1行目が長くて画面上で3行に折り返されている場合、@line_map[[0,0], [0,1], [0,2]]のようになる。これは「表示上の1行目はファイルの0行目の0番目の折り返し部分、表示上の2行目はファイルの0行目の1番目の折り返し部分...」ということを表している。

カーソル移動の実装

カーソル移動を実装するには、ANSIエスケープシーケンスを使う。エスケープシーケンスはコンソールに対して特殊なコマンドを送信するためのシーケンス。これを使うことで、カーソルを移動させたり、画面をクリアしたりできる。

module EscapeCode
  def self.move_to(x, y)
    print "\e[#{y};#{x}H"
  end

  def self.clear_screen
    print "\e[2J"
    print "\e[H"
  end
end

move_toメソッドは、カーソルを指定した座標に移動させる。clear_screenメソッドは、画面をクリアしてカーソルを左上に移動させる。とりあえずはこれだけで事足りてる。

カーソル移動の処理はInputクラスで実装されてる。

def handle_normal
  input = escaped_input
  case input
  when 'h', "\e[D"
    @screen.move_left
  when 'l', "\e[C"
    @screen.move_right
  when 'j', "\e[B"
    return if @screen.over_bottom?
    @screen.move_down
  when 'k', "\e[A"
    @screen.move_up
  end
end

viライクなキーバインド(hjkl)と矢印キーの両方をサポートしている。これにより、viに慣れたユーザーも、そうでないユーザーも使いやすくなる。まぁこれも雑に実装している。

実際のカーソル移動の処理はScreenクラスで行われる。

def move_left
  @abs_x = [1, @abs_x - 1].max
end

def move_right
  current_display_line_index = @abs_y
  return if current_display_line_index >= @display_lines.size

  current_line = @display_lines[current_display_line_index] || ''
  max_x = current_line.length + 1
  @abs_x = [@abs_x + 1, max_x].min
end

def move_down
  @abs_y += 1
  @scroll_offset += 1 if @abs_y >= @scroll_offset + @visible_height - 1
  adjust_x_position
end

def move_up
  @abs_y = [0, @abs_y - 1].max
  @scroll_offset -= 1 if @abs_y < @scroll_offset
  adjust_x_position
end

カーソル移動では、画面の端に達した場合の処理や、行の長さを超えないようにする処理が必要。テトリスとか作るのと同じで2次元配列を移動するときは壁判定が重要。

テキスト編集の実装

テキスト編集は、最初に読み込んだファイル内容を格納した配列を操作することで実現してる。

def insert_char(input)
  line_index, line, pos = char_position
  @lines[line_index] = line.dup.insert(pos, input)
  @abs_x += 1
  @editted = true
end

def insert_newline
  line_index, line, pos = char_position
  left_part = line[0...pos]
  right_part = line[pos..] || ''

  @lines[line_index] = left_part
  @lines.insert(line_index + 1, right_part)

  @abs_y += 1
  @abs_x = 1
  @editted = true
end

def delete_char
  line_index, line, pos = char_position
  @lines[line_index] = "#{line.dup.slice(0, pos)}#{line.dup.slice(pos + 1..-1)}"
  @abs_x = [@abs_x - 1, 1].max
end

def delete_line
  return if @lines.empty?

  current_line_index, = @line_map[@abs_y] || [0, 0]
  @lines.delete_at(current_line_index)
  @editted = true
  ...
end

編集操作では、現在のカーソル位置から実際のファイル上の位置を計算する必要がある。これはchar_positionメソッドでやっとる。

def char_position
  return [0, '', 0] if @line_map.empty? || @abs_y >= @line_map.size

  line_index, wrap_index = @line_map[@abs_y]
  line = @lines[line_index] || ''
  pos = (wrap_index * @col) + @abs_x - 1
  pos = [line.size, pos].min
  [line_index, line, pos]
end

このメソッドは、現在のカーソル位置(@abs_x, @abs_y)から、実際のファイルの行番号(line_index)、その行の内容(line)、その行内での文字位置(pos)を計算する。これにより、折り返し表示されている場合でも正確に編集位置を特定できる。このへんの折り返し処理が一番だるい...。

ファイル保存の実装

ファイル保存は、編集した内容を配列からファイルに書き出すだけ。

def save_file(file_path)
  content = @lines.empty? ? '' : @lines.join("\n")
  File.write(file_path, content)
end

配列の各要素を改行文字で結合してファイルに書き出している。空ファイルの場合は空文字列を書き出すようにしている。

実装の工夫点

モードの管理

viライクなエディタなのでとりあえずノーマルモード、インサートモード、コマンドモードを実装した。

各モードに応じて、入力処理を切り替えている。

def handle_input
  if @input.normal?
    @input.handle_normal
  elsif @input.insert?
    @input.handle_insert
  elsif @input.command?
    @input.handle_command
  elsif @input.search_down? || @input.search_up?
    @input.handle_search
  end
end

また、現在のモードをステータスバー(可視領域+1行目)に表示することで、ユーザーが現在のモードを把握しやすくしている。文言もviと似た感じにしてる。

def draw_status_bar(message: '')
  print "\e[#{@screen.visible_height + 1};1H"

  unless message.empty?
    print message.ljust(@screen.visible_width)
    return
  end

  if insert?
    print '--- INSERT ---'.ljust(@screen.visible_width)
  elsif command?
    print ":#{command_text}".ljust(@screen.visible_width)
  elsif search_down?
    print "/#{command_text}".ljust(@screen.visible_width)
  elsif search_up?
    print "?#{command_text}".ljust(@screen.visible_width)
  else
    print ''.ljust(@screen.visible_width)
  end
end

行の折り返し処理

何回も書いてるが面倒な折り返し処理。

画面幅を超える長い行を折り返して表示するために、行の折り返し処理を実装する必要がある。

def wrap_line(line, width)
  return [''] if line.nil? || line.empty?

  line.scan(/.{1,#{width}}|.+/)
end

正規表現を使って、指定した幅で行を分割している。

折り返された行と元の行の対応関係は@line_mapで管理している。

def init_logical_lines
  @display_lines = []
  @line_map = []

  @lines = [''] if @lines.empty?

  @lines.each_with_index do |line, i|
    wraps = wrap_line(line, @col)
    wraps.each_with_index do |wrap, j|
      @display_lines << wrap
      @line_map << [i, j]
    end
  end
end

この処理により、表示上の行番号から実際のファイルの行番号と折り返し番号を取得できるようになる。

スクロール処理

大きなファイルでも快適に編集できるように、スクロール処理を実装している。

def update_scroll_offset
  @total_display_lines = @display_lines.size
  @scroll_offset = safe_scroll_offset([0, @scroll_offset].max)
end

def safe_scroll_offset(offset)
  return 0 if @total_display_lines <= @visible_height
  [offset, @total_display_lines - @visible_height].min
end

カーソルが画面の上端や下端に達した場合、自動的にスクロールするようにしている。

def move_down
  @abs_y += 1
  @scroll_offset += 1 if @abs_y >= @scroll_offset + @visible_height - 1
  adjust_x_position
end

def move_up
  @abs_y = [0, @abs_y - 1].max
  @scroll_offset -= 1 if @abs_y < @scroll_offset
  adjust_x_position
end

また、ページ単位のスクロールも実装している(ctrl+fとか)。

def move_page_down
  @abs_y = [@abs_y + @visible_height, @total_display_lines].min
  @scroll_offset = safe_scroll_offset([0, @scroll_offset + @visible_height].max)
  adjust_x_position
end

def move_page_up
  @abs_y = [0, @abs_y - @visible_height].max
  @scroll_offset = safe_scroll_offset([0, @scroll_offset - @visible_height].max)
  adjust_x_position
end

検索機能

/?で前方検索と後方検索を実装している。これはかなり愚直な実装で普通に正規表現で何度も検索してる。上端(もしくは下端)に達したら1周回って検索できるようになってる。だいぶ力技。

def search_down(pattern)
  return if pattern.empty?

  begin
    regex = Regexp.new(pattern, Regexp::IGNORECASE)
  rescue RegexpError
    return
  end

  current_line_index, = @line_map[@abs_y] || [0, 0]
  current_pos = char_position[2]

  current_line = @lines[current_line_index] || ''
  match = current_line.match(regex, current_pos + 1)
  if match
    move_to_match(current_line_index, match.offset(0)[0])
    return
  end

  @lines[(current_line_index + 1)..].each_with_index do |line, i|
    match = line.match(regex)
    if match
      move_to_match(current_line_index + 1 + i, match.offset(0)[0])
      return
    end
  end

  @lines[0..current_line_index].each_with_index do |line, i|
    match = line.match(regex)
    if match
      move_to_match(i, match.offset(0)[0])
      return
    end
  end
end

最後に

今回解説した実装は下記のリポジトリにある。

https://github.com/YuheiNakasaka/ruvi

ただし本当にこれは最小限の実装しかしていない。例えばマルチバイト対応できていないとか、Undoができないとか、シンタックスハイライトがないとか。大きなサイズのファイルを読み込むにはどうしたらいいか?とか色々なパフォーマンス問題も今のままだと発生するので対応が必要。

さらに自前のスクリプトを書いてエディタを拡張するような機能もあったら良さそうだが今のままだと対応できない。

ただ、TUIのエディタはこんな感じで実装されているのだという仕組みをざっくり理解するには良い導入になるのではないかと思う。

自前で実装してみると世の中に存在しているemacsやvimといったエディタはとんでもなく良く出来ていてすごい!と感じる。今回の仕様の参考にしたviでさえもっとリッチな仕様を持っているのでさらに大変なはず。すごい。

あと実はさらにもっと本格的な実装としてはTextbringerというEmacsクローン(Ruby製)もある(というか記事を書いている最中に見つけた。これを参考にすればもっといい感じの実装にできたかもしれない...)

https://github.com/shugo/textbringer

https://shugo.net/jit/20170320.html

TUIエディタの実装は思った以上に奥が深く、特に表示管理や折り返しのカーソル制御の部分で多くの考慮事項がある。しかし基本的な仕組みを理解すれば、いざTUIエディタを作りたくなった時に役立つと思う(そんな時ある?)。

参考リンク

Discussion