Rust
テキストエディタ
RustDay 14

Rustでテキストエディタを作る


はじめに

最近コンソール上で動くテキストエディタを作ったので、その中で得たノウハウなどを記事にしてみます。

作ったのはこちら https://github.com/hatoo/Accepted (Rustで競技プログラミングをするために作ったエディタなので興味のある方は使ってみてください!:muscle:)

ですが、この記事では新たに簡単なテキストエディタを作ってみたいと思います!

https://github.com/hatoo/kiro

参考文献

Build Your Own Text Editor


この記事で作るもの


  • ターミナル上で動くテキストエディタ

  • 日本語対応

  • モードレス


    • 状態の管理が面倒そうなのでVimとは違いノーマルモード、インサートモードなどのモードがないエディタを作ります

    • 要はターミナル上で動く「メモ帳」です




Rawモード

普通にコンソールプログラムを作って動かしているときの様子を思い出してみてください。

特にキーボードを入力したときの画面に注目すると。。。


  • 入力するとそのまま画面に表示される

  • エンターキーで入力するまでは同じ行内で文字を編集できる

  • エンターキーを押すと入力が確定する

こんな感じでしょうか?

これではテキストエディタなんてとても作れませんね

というわけでターミナルの状態を変更して、上記のようなことにならないようにする必要があります。

ちなみに、このデフォルトの状態をCanonicalモードと呼ぶみたいです。


Termion

今回はターミナルに関するあれこれを行うのにTermionというライブラリを使います。

できるだけ説明していきますが、割と薄いライブラリなので裏で何をやっているかの細かい部分が気になる方はソースを見ると良いでしょう。


Rawモードに移行する

ターミナルをRawモードと呼ばれる状態にすると、キー入力が画面に表示されなくなったり、入力を1文字づつ即座に取得できたりします。

さっそくコードを書いてみましょう。

https://github.com/hatoo/kiro/tree/65a1db492e1f03c06a3e7a0987fc87d51e652985


Cargo.toml

[dependencies]

termion = "1"


main.rs

extern crate termion;

use std::io::{stdin, stdout, Write};
use termion::event::{Event, Key};
use termion::input::TermRead;
use termion::raw::IntoRawMode;

fn main() {
let stdin = stdin();
// Rawモードに移行
// into_raw_modeはIntoRawModeトレイトに定義されている
// めんどくさいので失敗時は終了(unwrap)
// stdout変数がDropするときにrawモードから元の状態にもどる
let mut stdout = stdout().into_raw_mode().unwrap();

// eventsはTermReadトレイトに定義されている
for evt in stdin.events() {
// Ctrl-cでプログラム終了
// Rawモードなので自前で終了方法を書いてかないと終了する方法がなくなってしまう!
if evt.unwrap() == Event::Key(Key::Ctrl('c')) {
return;
}
}
}


実行して挙動を確認してみてください。

キーを入力しても完全に無反応なところがポイントです。

Ctrl-cを押すと終了します。

実際にどうやってRawモードに移行するかの詳しい解説はここではしませんので、興味のある方は https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html を見てください。

ですが、この記事では上記のコードでRawモード移行できる程度の認識でOKです。


入力

上記プログラムではキー入力もtermionにまかせています。(stdin.events()のところ)

Rawモードでも入力は普通のプログラムと同じようにSTDINから行います。

しかし、例えばCtrlで修飾されたキーなどは複数バイトの列で出てくるため、そこらへんのパースをtermionにうまくやってもらいます。


まとめ

これで、準備ができました。テキストエディタを作るときもSTDINから入力を受け取って、STDOUTに出力するという基本は変わりません。次に行きましょう。


テキストビューアを作る

まず、テキストを見るだけのプログラムを作ります。


画面のクリア

実は、STDOUTに適当なバイト列を出力することによって画面をクリアしたり、カーソルを移動できたりします。

https://github.com/hatoo/kiro/tree/d6cb36259f135eece6a8b666d2f8ebd5a182acbe


main.rs

extern crate termion;

use std::io::{stdin, stdout, Write};
use termion::clear;
use termion::cursor;
use termion::event::{Event, Key};
use termion::input::TermRead;
use termion::raw::IntoRawMode;

fn main() {
let stdin = stdin();
// Rawモードに移行
// into_raw_modeはIntoRawModeトレイトに定義されている
// めんどくさいので失敗時は終了(unwrap)
// stdout変数がDropするときにrawモードから元の状態にもどる
let mut stdout = stdout().into_raw_mode().unwrap();

// 画面全体をクリアする
write!(stdout, "{}", clear::All);
// カーソルを左上に設定する(1-indexed)
write!(stdout, "{}", cursor::Goto(1, 1));
// Hello World!
write!(stdout, "Hello World!");
// 最後にフラッシュする
stdout.flush().unwrap();

// eventsはTermReadトレイトに定義されている
for evt in stdin.events() {
// Ctrl-cでプログラム終了
// Rawモードなので自前で終了方法を書いてかないと終了する方法がなくなってしまう!
if evt.unwrap() == Event::Key(Key::Ctrl('c')) {
return;
}
}
}


実行すると真っ暗な画面にHello World!と表示されるはずです。

termionのclear::Allcursor::Gotoなどの構造体を使っていますが、これらは単にDisplayトレイトを実装した構造体に過ぎないことに注意してください。実際やっていることはSTDOUTに特定のバイト列を出力しているだけです。あとはターミナルがうまいこと解釈してくれます。


Alternate screen

上記のプログラムを実行すると画面がクリアされますが終了したあとに元に戻らず微妙です。

例えば、VIMを起動して終了してみると画面の状態が起動する直前に戻っていますよね?

これはAlternate screenという機能を使うことで簡単に実装することができます。

Alternate screenについても詳しいところには立ち入らず、こうすればできるよくらいの感じでいきます。

一部抜粋

https://github.com/hatoo/kiro/tree/0490a92764aca03505704ace9fc7e98d753b87b6


main.rs

use termion::screen::AlternateScreen;

fn main() {
let stdin = stdin();
// Rawモードに移行
// into_raw_modeはIntoRawModeトレイトに定義されている
// めんどくさいので失敗時は終了(unwrap)
// stdout変数がDropするときにrawモードから元の状態にもどる
// ついでにAlternate screenにする。これもDrop時にもどるようになっている。
let mut stdout = AlternateScreen::from(stdout().into_raw_mode().unwrap());



テキストの保持方法

テキストエディタを作りたいので、当然テキストを何らかの方法で保持する必要があります。

私の知る限りでは大きく3通りの方法があるようです。


  1. 二次元配列Vec<Vec<char>>で管理する。つまりbuf[i][j]はi行目のj文字目に対応する


    • Viは大体これなようです




  2. Gap Bufferを使う


    • Emacsはこの方式らしい

    • オライリーのプログラミングRustの最後の方にのってるので興味のある方はどうぞ




  3. Ropeを使う



    • ropeyというライブラリがあるのでやる気のある方はこれを使ってみると良いかもしれません



今回は、簡単のためにVec<Vec<char>>でやることにします。

一行の長さをNとすると、1文字挿入するたびにO(N)の時間がかかりますが、まあとても長い行が現れない限りこのやり方はうまく動くでしょう。

また、1文字をchar(4byte)で保持するので、Asciiなテキストファイルを読み込んだ場合ファイルのサイズ4倍のメモリを使用することになりますがそれも良しとしましょう。

参考文献

Architectural musing and ideas

A Brief Glance at How Various Text Editors Manage Their Textual Data


ファイル入力

とりあえず、コマンドラインでファイルを入力して画面に表示するところまでやってしまいましょう。

今回はコマンドライン引数のパースにClapを使うことにします。

大した使い方はしていないのでClap未経験の方も雰囲気でわかると思います。

https://github.com/hatoo/kiro/tree/33ebbdc2de91e95628c8b2727c7466304bd1aec7


Cargo.toml

[dependencies]

termion = "1"
clap = "2"


main.rs

extern crate clap;

extern crate termion;

use clap::{App, Arg};
use std::ffi::OsStr;
use std::fs;
use std::io::{stdin, stdout, Write};
use std::path;
use termion::clear;
use termion::cursor;
use termion::event::{Event, Key};
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::AlternateScreen;

fn main() {
// Clap
let matches = App::new("kiro")
.about("A text editor")
.bin_name("kiro")
.arg(Arg::with_name("file"))
.get_matches();

// ファイルパスはUTF-8でない可能性があるのでOsStrを使います
let file_path: Option<&OsStr> = matches.value_of_os("file");

// テキストを読み込む
// 改行コードに関してはlinesに一任している
let buffer: Vec<Vec<char>> = file_path
.and_then(|file_path| {
// エラー処理は適当
fs::read_to_string(path::Path::new(file_path))
.ok()
.map(|s| {
s.lines()
.map(|line| line.trim_right().chars().collect())
.collect()
})
})
.unwrap_or(Vec::new());

let stdin = stdin();
// Rawモードに移行
// into_raw_modeはIntoRawModeトレイトに定義されている
// めんどくさいので失敗時は終了(unwrap)
// stdout変数がDropするときにrawモードから元の状態にもどる
let mut stdout = AlternateScreen::from(stdout().into_raw_mode().unwrap());

// 画面全体をクリアする
write!(stdout, "{}", clear::All);
// カーソルを左上に設定する(1-indexed)
// column, rowの順番
write!(stdout, "{}", cursor::Goto(1, 1));

// bufferの内容を出力する
for line in &buffer {
for &c in line {
write!(stdout, "{}", c);
}
// Rawモードでは改行は\r\nで行う
write!(stdout, "\r\n");
}

// 最後にフラッシュする
stdout.flush().unwrap();

// eventsはTermReadトレイトに定義されている
for evt in stdin.events() {
// Ctrl-cでプログラム終了
// Rawモードなので自前で終了方法を書いてかないと終了する方法がなくなってしまう!
if evt.unwrap() == Event::Key(Key::Ctrl('c')) {
return;
}
}
}



カーソルの移動

カーソルを方向キーで移動できるようにします。

まず、カーソルの位置を表す構造体を定義します。

本プログラムでは可能な限り内部では0-indexedを使うことにします。

こんな感じで方向キーでカーソルをバッファ内で移動できるようにします。

https://github.com/hatoo/kiro/tree/arrow_key

#[derive(Debug, Clone, Copy, PartialEq, Eq)]

// カーソルの位置 0-indexed
struct Cursor {
row: usize,
column: usize,
}


main.rs

    // 入力処理

for evt in stdin.events() {
match evt.unwrap() {
// Ctrl-cでプログラム終了
// Rawモードなので自前で終了方法を書いてかないと終了する方法がなくなってしまう!
Event::Key(Key::Ctrl('c')) => {
return;
}

// 方向キーの処理
Event::Key(Key::Up) => {
if cursor.row > 0 {
cursor.row -= 1;
cursor.column = min(buffer[cursor.row].len(), cursor.column);
}
}
Event::Key(Key::Down) => {
if cursor.row + 1 < buffer.len() {
cursor.row += 1;
cursor.column = min(cursor.column, buffer[cursor.row].len());
}
}
Event::Key(Key::Left) => {
if cursor.column > 0 {
cursor.column -= 1;
}
}
Event::Key(Key::Right) => {
cursor.column = min(cursor.column + 1, buffer[cursor.row].len());
}
_ => {}
}

// 画面上のカーソルの位置を設定する
write!(
stdout,
"{}",
cursor::Goto(cursor.column as u16 + 1, cursor.row as u16 + 1)
);

stdout.flush().unwrap();
}



スクロール処理

気づいた方もおられるかもしれませんが上記のプログラムには


  • テキストの行数が多くてが画面に収まらないとき

  • 1行が長すぎて画面に収まらないとき

にカーソルがうまく動きません。

まず、プログラムを見やすくするためにエディタの状態をstructにまとめて、書き直します。

https://github.com/hatoo/kiro/commit/58eb6bd9834ceba2e57462c4d9f380c61dd9ad61

extern crate clap;

extern crate termion;

use clap::{App, Arg};
use std::cmp::min;
use std::ffi::OsStr;
use std::fs;
use std::io::{stdin, stdout, Write};
use std::path;
use termion::clear;
use termion::cursor;
use termion::event::{Event, Key};
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::AlternateScreen;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
// カーソルの位置 0-indexed
struct Cursor {
row: usize,
column: usize,
}

// エディタの内部状態
struct Kiro {
// テキスト本体
buffer: Vec<Vec<char>>,
// 現在のカーソルの位置
cursor: Cursor,
}

impl Default for Kiro {
fn default() -> Self {
Self {
buffer: vec![Vec::new()],
cursor: Cursor { row: 0, column: 0 },
}
}
}

impl Kiro {
// ファイルを読み込む
fn open(&mut self, path: &path::Path) {
self.buffer = fs::read_to_string(path)
.ok()
.map(|s| {
let buffer: Vec<Vec<char>> = s
.lines()
.map(|line| line.trim_right().chars().collect())
.collect();
if buffer.is_empty() {
vec![Vec::new()]
} else {
buffer
}
})
.unwrap_or(vec![Vec::new()]);

self.cursor = Cursor { row: 0, column: 0 };
}
// 描画処理
fn draw<T: Write>(&self, out: &mut T) {
write!(out, "{}", clear::All);
write!(out, "{}", cursor::Goto(1, 1));

for line in &self.buffer {
for &c in line {
write!(out, "{}", c);
}
write!(out, "\r\n");
}

write!(
out,
"{}",
cursor::Goto(self.cursor.column as u16 + 1, self.cursor.row as u16 + 1)
);
out.flush().unwrap();
}
fn cursor_up(&mut self) {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.column = min(self.buffer[self.cursor.row].len(), self.cursor.column);
}
}
fn cursor_down(&mut self) {
if self.cursor.row + 1 < self.buffer.len() {
self.cursor.row += 1;
self.cursor.column = min(self.cursor.column, self.buffer[self.cursor.row].len());
}
}
fn cursor_left(&mut self) {
if self.cursor.column > 1 {
self.cursor.column -= 1;
}
}
fn cursor_right(&mut self) {
self.cursor.column = min(self.cursor.column + 1, self.buffer[self.cursor.row].len());
}
}

fn main() {
// Clap
let matches = App::new("kiro")
.about("A text editor")
.bin_name("kiro")
.arg(Arg::with_name("file"))
.get_matches();

let file_path: Option<&OsStr> = matches.value_of_os("file");

let mut state = Kiro::default();

if let Some(file_path) = file_path {
state.open(path::Path::new(file_path));
}

let stdin = stdin();
let mut stdout = AlternateScreen::from(stdout().into_raw_mode().unwrap());

state.draw(&mut stdout);

for evt in stdin.events() {
match evt.unwrap() {
Event::Key(Key::Ctrl('c')) => {
return;
}
Event::Key(Key::Up) => {
state.cursor_up();
}
Event::Key(Key::Down) => {
state.cursor_down();
}
Event::Key(Key::Left) => {
state.cursor_left();
}
Event::Key(Key::Right) => {
state.cursor_right();
}
_ => {}
}
state.draw(&mut stdout);
}
}


画面の大きさの取得

termion::terminal_sizeでターミナルの大きさを取得できます。

これが何をやっているかはここでは気にしませんが興味のある方はhttps://viewsourcecode.org/snaptoken/kilo/03.rawInputAndOutput.html#window-size-the-easy-way を読んでみると良いでしょう。(リンク先にはeasy wayのあとにhard wayがのっていますがtermionではeasy wayの方法でターミナルの大きさを取得しています)


行オフセットの保持

スクロールできるようにstructに変数を追加します。

// エディタの内部状態

struct Kiro {
buffer: Vec<Vec<char>>,
cursor: Cursor,
// 画面の一番上はバッファの何行目か
row_offset: usize,
}


実装

https://github.com/hatoo/kiro/tree/scroll

描画処理を書き換えます。


main.rs

impl Kiro {

// 省略
fn terminal_size() -> (usize, usize) {
let (cols, rows) = termion::terminal_size().unwrap();
(rows as usize, cols as usize)
}
// 描画処理
fn draw<T: Write>(&self, out: &mut T) {
// 画面サイズ(文字数)
let (rows, cols) = Self::terminal_size();

write!(out, "{}", clear::All);
write!(out, "{}", cursor::Goto(1, 1));

// 画面上の行、列
let mut row = 0;
let mut col = 0;

let mut cursor: Option<Cursor> = None;

'outer: for i in self.row_offset..self.buffer.len() {
for j in 0..=self.buffer[i].len() {
if self.cursor == (Cursor { row: i, column: j }) {
// 画面上のカーソルの位置がわかった
cursor = Some(Cursor {
row: row,
column: col,
});
}

if let Some(c) = self.buffer[i].get(j) {
write!(out, "{}", c);
col += 1;
if col >= cols {
row += 1;
col = 0;
if row >= rows {
break 'outer;
} else {
// 最後の行の最後では改行すると1行ずれてしまうのでこのようなコードになっている
write!(out, "\r\n");
}
}
}
}
row += 1;
col = 0;
if row >= rows {
break;
} else {
// 最後の行の最後では改行すると1行ずれてしまうのでこのようなコードになっている
write!(out, "\r\n");
}
}

if let Some(cursor) = cursor {
write!(
out,
"{}",
cursor::Goto(cursor.column as u16 + 1, cursor.row as u16 + 1)
);
}

out.flush().unwrap();
}
// カーソルが画面に映るようにrow_offsetを設定する
fn scroll(&mut self) {
let (rows, _) = Self::terminal_size();
self.row_offset = min(self.row_offset, self.cursor.row);
if self.cursor.row + 1 >= rows {
self.row_offset = max(self.row_offset, self.cursor.row + 1 - rows);
}
}
// 省略
}



日本語の文字幅

上記プログラムに日本語を含むテキストを読み込ませてみましょう

kiro1.gif

どうやってもカーソルが右端まで行きません!

どうやら日本語は1文字でアルファベット2文字分の幅を使うみたいです。

東アジアの文字幅というのがあってユニコードでは文字ごとの幅が決まってるようです。

ということで、今回はunicode-widthで文字幅を取得します。(もっといいやり方がありそうなので知っている方は教えてください)

https://github.com/hatoo/kiro/tree/japanese


Cargo.toml

[dependencies]

unicode-width = "0"

draw関数の一部を書き換えます。

        'outer: for i in self.row_offset..self.buffer.len() {

for j in 0..=self.buffer[i].len() {
if self.cursor == (Cursor { row: i, column: j }) {
// 画面上のカーソルの位置がわかった
display_cursor = Some((row, col));
}

if let Some(c) = self.buffer[i].get(j) {
// 文字の幅を取得する
let width = c.width().unwrap_or(0);
if col + width >= cols {
row += 1;
col = 0;
if row >= rows {
break 'outer;
} else {
write!(out, "\r\n");
}
}
write!(out, "{}", c);
col += width;
}
}
row += 1;
col = 0;
if row >= rows {
break;
} else {
// 最後の行の最後では改行すると1行ずれてしまうのでこのようなコードになっている
write!(out, "\r\n");
}
}


編集機能を作る

ついにテキストエディタっぽい機能を作る準備ができました。

ここまでくれば編集機能を作るのは結構簡単です。


文字の入力

charを受け取って現在のカーソル上に挿入するメソッドを作ります。

エンターキーは\nで入力されるので処理します


main.rs

impl Kiro {

// 文字の挿入
fn insert(&mut self, c: char) {
if c == '\n' {
// 改行
let rest: Vec<char> = self.buffer[self.cursor.row]
.drain(self.cursor.column..)
.collect();
self.buffer.insert(self.cursor.row + 1, rest);
self.cursor.row += 1;
self.cursor.column = 0;
self.scroll();
} else if !c.is_control() {
self.buffer[self.cursor.row].insert(self.cursor.column, c);
self.cursor_right();
}
}
}


バックスペース&デリート

対応する操作をバッファ上に行うメソッドを作るだけです


main.rs

impl Kiro {

// バックスペースが押された
fn back_space(&mut self) {
if self.cursor == (Cursor { row: 0, column: 0 }) {
// 一番始めの位置の場合何もしない
return;
}

if self.cursor.column == 0 {
// 行の先頭
let line = self.buffer.remove(self.cursor.row);
self.cursor.row -= 1;
self.cursor.column = self.buffer[self.cursor.row].len();
self.buffer[self.cursor.row].extend(line.into_iter());
} else {
self.cursor_left();
self.buffer[self.cursor.row].remove(self.cursor.column);
}
}
// デリートキーが押された
fn delete(&mut self) {
if self.cursor.row == self.buffer.len() - 1
&& self.cursor.column == self.buffer[self.cursor.row].len()
{
// テキスト末尾のときはなにもしない
return;
}

if self.cursor.column == self.buffer[self.cursor.row].len() {
// 行末
let line = self.buffer.remove(self.cursor.row + 1);
self.buffer[self.cursor.row].extend(line.into_iter());
} else {
self.buffer[self.cursor.row].remove(self.cursor.column);
}
}
}



イベントループに追加

はい


main.rs

fn main() {

// ...
for evt in stdin.events() {
match evt.unwrap() {
Event::Key(Key::Ctrl('c')) => {
return;
}
Event::Key(Key::Up) => {
state.cursor_up();
}
Event::Key(Key::Down) => {
state.cursor_down();
}
Event::Key(Key::Left) => {
state.cursor_left();
}
Event::Key(Key::Right) => {
state.cursor_right();
}
Event::Key(Key::Char(c)) => {
// 文字入力
state.insert(c);
}
Event::Key(Key::Backspace) => {
// バックスペースキー
state.back_space();
}
Event::Key(Key::Delete) => {
// デリートキー
state.delete();
}
_ => {}
}
state.draw(&mut stdout);
}
}

https://github.com/hatoo/kiro/tree/a38ec20a9f56beaa42214ec477d9c6478a5eef09


セーブ機能

Ctrl-Sでテキストを保存するようにして完成です!

簡単なのでコードはGitHubを参照してください

https://github.com/hatoo/kiro


おまけ

本記事では扱いませんが実際にテキストエディタを作り使っていくときに重要そうなことを書いていきます


画面のちらつきを抑制する

本プログラムではキー入力のたびにすべてを描画し直しているので、例えばカーソルを左右に移動しているだけで環境によっては画面がちらつきます。

毎回すべてを描画するのではなく、前回の描画内容を保持しておき差分のみ描画するようにするとかなり改善します。

(意外と面倒なので本記事では割愛しました:sweat_smile:)


60FPSで描画する

本プログラムではキー入力のたびに描画をしていますが、例えば画面サイズが変更したときなどに画面が真っ暗になってしまうなどの問題があります。

kiro.gif

後々アニメーションなどを追加する場合に備えて描画を60FPSにしてしまうと良いでしょう。ただし、上記のちらつき対策をしておかないと60FPSで描画するとちらつきがひどくなってしまうので注意しましょう。

別スレッドで入力を受け取り、チャンネルに流すことでタイムアウト付きの入力を実現できます。


サンプルコード

    use std::sync::mpsc::channel;

let (tx, rx) = channel();

// 入力は別スレッドで受け取りmpscに流す
thread::spawn(move || {
for c in stdin.events() {
if let Ok(evt) = c {
tx.send(evt).unwrap();
}
}
});

loop {
// 16ミリ秒でタイムアウト
if let Ok(evt) = rx.recv_timeout(Duration::from_millis(16)) {
// キーイベント処理
}
// 描画処理とか
}



シンタックスハイライトを実装する

なんと、Rustにはsyntectというシンタックスハイライトを実装するためのライブラリがあります:grinning:

基本的にはこれを使えばいいのですが、毎フレームシンタックスハイライトをすると重くなってしまう(意外と高価な処理のようです)のでシンタックスハイライトの情報をうまくキャッシュして何度も同じ箇所をシンタックスハイライトし直さないようにする必要がありますので注意しましょう。


おわりに

本記事ではRustを使って簡単なエディタを作っていきました。

tokeiで調べてみると230行ほどで一応動くテキストエディタが出来たということなので結構簡単にできたと言えるのではないでしょうか?

私は自作テキストエディタAcceptedを作り始めて1ヶ月くらいで競技プログラミングで常用できるくらいの完成度になりました。

テキストエディタの自作は思っているより簡単だと思うのでみなさんもテキストエディタを作って使っていきましょう!