簡単なテキストエディタを作る(その1)

前のポストでテキストエディタ自作の知見のことを思い出したので,書いておこうかなと。


この記事では,端末で動く簡単なテキストエディタを作ります(ただし, そんなにまとまっていないかつサンプルコードとかも限りなく少なくなると思うので, 断片的な情報にすぎない気がする)。

この記事では GNU/Linux のシステムを前提としますが,macOS や BSD 系の OS など Unix 系の OS であればほとんどの内容は通用すると思われます。 また,Windows Subsystem for Linux (WSL) でもおそらく動作しますが, テストはしていません。 また,端末エミュレータは xterm 互換である必要があります。 GNOME 端末,Konsole,mlterm など主要な端末エミュレータ(端末)であれば問題ありません。 WSL を使っている場合で,Windows Terminal を使っている場合, 細かい部分で非互換が発生することがあります。 なお,筆者は ArchLinux 上の GNOME 端末で開発を行いました。

このポストでの表記法について

このポストでは繰り返し出てくる表記を簡略化するため,以下の表記法を使うことがあります。

特殊キー + 他のキー

“Ctrl + 他のキー” は C-(他のキー) という表記を使います。例えば Ctrl+A であれば C-a と表記します。 この表記法は Emacs のキーの表記法に由来します。

関数名(数字)

関数名やコマンド名等にはそれが記載されている man ページのセクション番号を併記するという 慣習があるので,それに従って表記します。これによって,printf コマンドと C の printf 関数を printf(1)printf(3) のように区別できます。

セクション番号に関しては,以下のものを頭に入れておけば問題ないでしょう。

セクション番号 内容
1 コマンド
2 システムコール
3 C 言語等の関数

システムコールについては後ほど説明します。

このポストの構成について

このポストは表示の非常に原始的な部分から作ることにより, 正しく動作していることを実際に動かして確認できるような構成になっています。 また,必要な知識はその都度説明していきます。

端末にエディタを表示する準備

端末で動くテキストエディタを作るには主に以下のことを行う必要があります。

  • non canonical mode にする
  • エコーバックをオフる
  • その他入力の細々した設定
  • Alternative Screen Buffer に切り替える

下準備

標準入力(standard input, stdin)と標準出力(standard output, stdout) が端末につながっているか確認しておきます。 ここで何が弾かれるかというと,テキストエディタのコマンドをパイプラインの途中とかで 起動した場合です。 一応最低限 stdin だけ端末につながっていれば入出力とか(描画とか)は問題なく できるんですが,パイプラインに制御文字を流しまくるのはたぶんユーザーが望んでいる挙動ではないので, 弾いたほうが良い。

non canonical mode に

canonical mode というのは,端末のデフォルトのモードで,行単位でバッファリングされて プログラムに送られてきます。例えば,以下のコマンドを実行してみてください。

$ cat

※ 行頭の $ はシェルでコマンドを実行することを示すもので,これは実際には入力しないでください。

コマンドの実行後,何も操作しなければプロンプトは返ってきません。 試しに abc と入力して,Enter キーを押してみてください。 さきほど入力した abc に加え,その下にもう一度 abc と出力されたと思います。 これは,cat が stdin から読んだ内容を stdout に書き出すというコマンドだからです。 ここで Backspace キーを押しても,さきほど入力した abc はもはや編集できなくなっています。

次に,もう一度 abc と入力してみてください。今度は Enter キーを押さないでください。 続いて,Backspace キーを押してみてください。行内であればまだ編集できる状態であるのがわかる と思います。これは,行の内容はまだ cat コマンドに渡されずに,バッファリングされているからです。 canonical mode では,プログラムはそれほど工夫しなくても,行単位の入力をユーザーが ある程度便利に与えることができます。

しかし,テキストエディタを実装するという場合はどうでしょう。おそらく,複数行にまたがった編集や, 独自のキーバインドなどを実装したいと思うことでしょう。その場合,端末を non canonical mode に切り替えて,文字単位での入力行うことになります。

non canonical mode にするには,以下のようにします。

struct termios orig_termios;
tcgetattr(0, &orig_termios);

struct termios new_termios = orig_termios;

new_temios.c_lflag &= ~ICANON;

tcsetattr(0, TCSADRAIN, &new_termios);

2行目の tcgetattr() で,ファイルディスクリプタ 0 関連付けられた端末の情報取得しています。 ファイルディスクリプタというのは,UNIX 系のシステムでプロセスごとに 開いているファイルを区別するために使われるものです。 そして,その 0 番というのは,stdin を指すということになっています。 ちなみに,1 は stdout,2 は stderr(標準エラー出力)を指します。

その次の行では,new_termiosorig_termios をコピーしています。 これはなぜかというと,さきほど取得した termios はプログラムが終了する際に 書き戻す必要があるからです。さもないと,あとで実行されるプログラムの実行時 に不都合が生じてしまいます。もとの設定はプログラム終了時にも参照できるように ちゃんと保存しておきましょう。(設定を変える関数を作ったのであれば,グローバル変数などに 入れておく必要があるというこになります)。

次の行では,ICANON を反転した値ともとのフラグとの論理積をとっています。 ICANON というのは,さきほど説明した canonical mode を使うかというフラグになっていて, それを反転することで,使わないという設定にしています。 さらに,既存の値との論理積をとることによって,それ以外の設定を変更せずに canonical mode の設定だけを変更することができます。

最後に tcsetattr を呼んでこの設定を適用します。第1引数の 0 はファイルディスクリプタ, 第2引数の TCSADRAIN というのは,今存在している「書き込まれているが読み込まれていないデータ」 が読み込まれてから設定が適用されるようにするというフラグです。

エコーバックをオフにする

この設定で,業単位のバッファリングなしで入力内容を読み込めるようになりましたが, まだ問題があります。それは,入力内容が画面に表示されてしまうことです。 場合によってはこれは都合がいいと思われるかもしれませんが, 制御文字等を入力(Ctrl キーと別のキーを同時に押す)した際にもキャレット記法で表示されてしまうので, これも無効にします。

さきほどの ICANON に加えて,ECHO も無効にします。

new_temios.c_lflag &= ~(ICANON | ECHO);

その他入力の細々した設定

端末への入力内容を read システムコールで読んでいくわけですが, これを確実に 1 byte(ASCII であれば 1 文字)ずつ読めるようにするため, new_termios をさらに書き換えます。

new_termios[VIM] = 1;
new_termios[VTIME] = 0;

この設定をしたことで,最低でも 1 byte 読めるまでは read システムコールは返ってこない ようになりました。

他にもう一点,設定すべき点があります。それは XON/XOFF フロー制御を無効にすることです。 デフォルトでは C-s で入力が止まり, C-q で再開できるというフロー制御が有効になっています。 これは C-s に機能を割り当てようとした際に不都合になるので無効にします。

new_temios.c_iflag &= ~(IXON | IXOFF);

また,デフォルトでは C-c や C-z が押されると SIGINT や SIGSTOP シグナルが発生するので, これを無効化します。

new_temios.c_lflag &= ~(ICANON | ECHO | ISIG);

Alternative Screen Buffer に切り替える

xterm は内部的に2つのバッファを持っています。それは,Normal Screen Buffer と Alternative Screen Buffer です。Normal Screen Buffer はスクロールして過去の出力を遡って見られるということになっています。 Alternative Screen Buffer ではバッファは画面と同じサイズしか持たず,履歴を遡ることはできないということになっています。

デフォルトでは Normal Screen Buffer になっています。通常のアプリケーションを alternative screen で利用したり,逆にテキストエディタを Alternative Screen Buffer で利用したりすることも 可能ではありますが,バッファを切り替えることでテキストエディタを終了した際にはもとの表示に戻ることができます。

この際に以下の3つのことを行う必要があります。

  1. 現在のカーソル位置を保存する
  2. Alternative Screen Buffer に切り替える
  3. 画面全体をクリアする(以前に Alternative Screen Buffer を利用したプログラムの表示が残っている場合があるので)

これは以下のそれぞれ以下のコントロールシーケンスで実現できます。 コントロールシーケンスとは,端末の制御に使う特殊なバイト列のことです。 \e はコンパイル時にエスケープ文字に変換されます。

  1. 現在のカーソル位置を保存する: \e7
  2. Alternative Screen Buffer に切り替える: \e[?47h
  3. 画面全体をクリアする: \e[2K

また,終了時には逆のことを行う必要があります。

  1. 画面全体をクリアする: \e[2K
  2. Normal Screen Buffer に切り替える: \e[?47l
  3. カーソル位置を復元する: \e8

このような処理を行うことは多いので,開始時,終了時の処理をまとめたコントロールシーケンスもあります。

\e[?1049h を出力することで,開始時の処理をまとめて行えます。終了時の処理をまとめて行うには \e[?1049l を出力します。

ここまでの内容を書くと下のようなソースコードになります。

#include <fcntl.h>
#include <termios.h>
#include <unistd.h>

int main(void) {
    struct termios orig_termios;
    tcgetattr(0, &orig_termios);

    struct termios new_termios = orig_termios;
    new_termios.c_iflag &= ~(IXON | IXOFF);
    new_termios.c_lflag &= ~(ICANON | ECHO | ISIG);
    new_termios.c_cc[VTIME] = 0;
    new_termios.c_cc[VMIN] = 1;
    tcsetattr(0, TCSADRAIN, &new_termios);

    write(1, "\e[?1049h", 8);

    write(1, "\e[?1049l", 8);

    tcsetattr(0, TCSADRAIN, &orig_termios);

    return 0;
}

残念ながら,これを実行しても一瞬で終了してしまうため正しく動いていることを 確認することはできません。 次の章でこれに文字の入力と出力を足していきます。

※ コントロールシーケンスについては, https://invisible-island.net/xterm/ctlseqs/ctlseqs.html に大きなリストがあります。この表の見方については後ほど説明します。

入力内容をそのまま出力できるようにする

入力した内容をそのまま出力するようにしてみましょう。

入力内容を 1 byte 読み込むには,次のようにします。

char c;
read(0, &c, 1);

これは c のアドレスに stdin からの入力を1バイト読み込むということを意味します。 逆に,1 文字書き出すには次のようにします。

char c = 'A';
write(1, &c, 1);

これは stdout に c へのポインタから1バイト書き出すということを意味します。 なぜ大きさが必要なのかというと,これはもっと大きい配列の内容を 出力することを想定されているからです。C では配列というのは配列の最初の要素のポインタで表すということになっている ので,このようなコードが動作します。

さきほどのコードに,stdin から 1 byte ずつ最大5文字ループで読み取って,それをそのまま stdout に書き出すという 処理を追加してみましょう。

#include <fcntl.h>
#include <termios.h>
#include <unistd.h>

void run(void) {
    char c;
    for (int i = 0; i < 5; i++) {
        read(0, &c, 1);
        write(1, &c, 1);
    }
}

int main(void) {
    struct termios orig_termios;
    tcgetattr(0, &orig_termios);

    struct termios new_termios = orig_termios;
    new_termios.c_iflag &= ~(IXON | IXOFF);
    new_termios.c_lflag &= ~(ICANON | ECHO | ISIG);
    new_termios.c_cc[VTIME] = 0;
    new_termios.c_cc[VMIN] = 1;
    tcsetattr(0, TCSADRAIN, &new_termios);

    write(1, "\e[?1049h", 8);

    run();

    write(1, "\e[?1049l", 8);

    tcsetattr(0, TCSADRAIN, &orig_termios);

    return 0;
}

入力内容が出力されているのが分かると思います。必要ならば書き込んでいる部分(write(1, &c, 1); の行)をコメントアウトしたりして 試してみてください。

繰り返し動かしてみると気づくと思いますが,この実装ではカーソルの位置がプログラムの中から予測できません。 そこで,最初にカーソルを左上に移動するようにしてみたいと思います。

Alternative Screen Buffer に切り替えている部分を以下のように変更します。

write(1, "\e[?1049h\e[H", 11);

\e[H が追加されているのが分かると思います。これは,カーソルを左上に戻すコントロールシーケンスです。 省略されていますが,これは引数を取ることができて,

\e[(row);(column)H

のようにすると,指定した行と列に移動することができます。 \e[1;1H とするのは \e[H とするのと全く同じように動作します。

次に,入力できる文字数を5文字ではなく,q を押すまでということにしてみましょう。

void run(void) {
    char c;
    for (; c != 'q';) {
        read(0, &c, 1);
        write(1, &c, 1);
    }
}

これで,任意の数の q 以外の文字が入力できるようになりました。

行の編集を実装する

入力した文字を削除できるようにしてみましょう。 ただし,Backspace キーと Delete キーは環境によって違う方法で実現されている 可能性があるので,ここでは C-h を使うことにします。

Ctrl キーを押しながら別のキーを押すと,そのキーのコードポイントから ('@’ のコードポイント分)だけ引いた値が読み取れるようになっています。 このことを利用してさきほどのコードを修正していきます。

void run(void) {
    char c;
    for (; c != 'q';) {
        read(0, &c, 1);

        if (c == 'H' - '@')  {
            write(1, "\e[D\e[K", 6);
        } else {
            write(1, &c, 1);
        }
    }
}

\e[D はカーソルを左に動かすコントロールシーケンスで,\e[K はカーソルから右 を削除するコントロールシーケンスです。現時点ではカーソルを動かすことができないため, このように実装しています。