viのようなTUIエディタを作ってみる
はじめに
先日のMS BuildにてNotepadに変わるTUIエディタとして「Edit」が発表された。Editという名前の検索性が悪すぎてSNS上ではそればかり話題になっていたが、Rustで書かれているLightweightな実装ということで一部のRust界隈では違う盛り上がり方もしていた。
そんな中、ふとこのようなTUIエディタはどうやって作られているのだろうか?と気になった。こういう時は自分で実装してみるのが一番早いだろう、ということで一番手癖で書けるRubyで雑に実装してみることにした。
とりあえずこのくらいはできるようになっている。
ソースコードはこれ。
現状の機能としては以下。
- 表示
- 行折り返しのみ
- ファイルの作成&編集
- 文字の挿入
- 文字の削除
- 行の削除
- 移動
- 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/console
のraw
メソッドと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
最後に
今回解説した実装は下記のリポジトリにある。
ただし本当にこれは最小限の実装しかしていない。例えばマルチバイト対応できていないとか、Undoができないとか、シンタックスハイライトがないとか。大きなサイズのファイルを読み込むにはどうしたらいいか?とか色々なパフォーマンス問題も今のままだと発生するので対応が必要。
さらに自前のスクリプトを書いてエディタを拡張するような機能もあったら良さそうだが今のままだと対応できない。
ただ、TUIのエディタはこんな感じで実装されているのだという仕組みをざっくり理解するには良い導入になるのではないかと思う。
自前で実装してみると世の中に存在しているemacsやvimといったエディタはとんでもなく良く出来ていてすごい!と感じる。今回の仕様の参考にしたviでさえもっとリッチな仕様を持っているのでさらに大変なはず。すごい。
あと実はさらにもっと本格的な実装としてはTextbringerというEmacsクローン(Ruby製)もある(というか記事を書いている最中に見つけた。これを参考にすればもっといい感じの実装にできたかもしれない...)
TUIエディタの実装は思った以上に奥が深く、特に表示管理や折り返しのカーソル制御の部分で多くの考慮事項がある。しかし基本的な仕組みを理解すれば、いざTUIエディタを作りたくなった時に役立つと思う(そんな時ある?)。
Discussion