10 月 30 日(月)

curses による端末制御

端末とは,コンピュータの基本的な機能 (キーボードから命令を受け取り,画面に文字を描く機能) を実現するためのハードウェアやソフトウェアのことである.

コマンドラインの入力に普段使っているアプリケーション, たとえば, Linux の「GNOME 端末(gnome-terminal)」や MacOSX の「ターミナル」等は, 端末ソフトウェアの一種.

これまでのC言語プログラムでは,標準ライブラリの入出力関数 printf( )scanf( ) 等を利用して, 端末の入出力を実行してきた. しかし,これらの関数では,一行単位でしか入出力できないし, 白黒でしか表示できない,等の制約が多かった. 「なーんだC言語では,まったく面白味のないプログラムしか作れないのかー」 いや,それは誤解だ.

端末自身は,キーボードや画面をより自由自在に制御する機能をもっており, C言語の標準ライブラリでも,それらの機能の一部を利用できる. さらに,curses ライブラリを利用すれば, エディタやビデオゲームのような高機能な端末制御プログラム効率的に作成できるようになる.

参考資料


エスケープシーケンス

端末画面上の任意の位置にカーソルを移動したり, 表示する文字の色を変更したりするには, エスケープシーケンス(escape sequence) と呼ばれる特殊な文字列を利用する.

たとえば,次のようなコマンドを実行すると, 文字の色を変更できる:

$ echo "^[[31m Red"		# '^['の入力方法は下記の通り
Red
$ echo "^[[0m Reset"
Reset
$
端末やシェルの設定を独自にカラフルに変更している場合, 上のコマンドの効果が見えないかもしれない. 次のコマンドで色設定を無効にできるかもしれない:
$ PS1="$ "

ただし,文字列の先頭の記号 '^[' は, [Esc] キーを表わす特殊な文字である. 2 文字の文字列 "^[" ではない. この文字をコマンドラインで入力するためには, 次のように操作する:

[Ctrl]+[v] キー → [Esc] キー

これら Esc 文字から始まる文字列 ( "^[[31m""^[[0m") がエスケープシーケンスである. 代表的なエスケープシーケンスを Table 1 に紹介しておく. なお,この表では,エスケープ文字を「Esc」と表記している.

Table 1 代表的なエスケープシーケンス
文字色の変更
Esc[30m
Esc[31m
Esc[32m
Esc[33m
Esc[34m
Esc[35mマゼンタ
Esc[36mシアン
Esc[37m
背景色の変更
Esc[40m
Esc[41m
Esc[42m
Esc[43m
Esc[44m
Esc[45mマゼンタ
Esc[46mシアン
Esc[47m
カーソルの移動
Esc[nAn 行上へ
Esc[nBn 行下へ
Esc[nCn 列右へ
Esc[nDn 列左へ
Esc[n;mHn 行目 m 列目へ
その他
Esc[0m色のリセット
Esc[2J画面のクリア
本授業では端末モデルとして VT100 のみを想定している. 実際には,複数種の端末モデルがあり, エスケープシーケンスは端末モデルにより異なる. なお,MacOSX,Linux,Windows 用のほぼ全ての端末ソフトでは, VT100 がサポートされている...ハズなんだが,完全ではない. (利用できない機能もある.)

これらのエスケープシーケンスは,端末自身がもっている機能なので, 端末の上で動くものであれば,Cプログラムでも利用できる. 次のように, エスケープシーケンスを printf( ) 関数などで出力するだけだ.

...
printf("クリアして \e[2J");
printf("\e[3;0H 3行目で \e[31m 赤にしたり\n");
printf("\e[1;10H 1行目へ戻ってみたり\n");
printf("\e[5;0H\e[0m 5行目で元通りに\n");
...

ここで '\e'Esc を表わす特殊文字である. 改行記号 '\n' などと同様,2文字で1文字を表わす.

実際には '^[' でも OK. (ただし,'^[' を入力できない, とほほなエディタもあるようだ.)

実行例:

...

$  ./a.out





          1行目へ戻ってみたり

3行目で赤にしたり

5行目で元通りに
$
これまでと何がちがう? 以前作成のプログラムでは, コマンド行の下の方向にしか実行結果を表示できなかった. 一方,このプログラムでは,行に囚われず, 画面上の好きな位置に出力できている.

curses ライブラリ

エスケープシーケンスの文字列は, 人間が読んでもほとんど意味不明な「呪文(curse)」のようなものであり, それを直接利用してプログラムを作成するなんてことは非常に面倒だ. そこで,この面倒な処理を簡単化するための関数群が curses ライブラリとして用意されている.

現代では,このライブラリは ncurses(new curses) と呼ばれるバージョンに置き換えられている. (元々の curses は Unix 創世記から利用されている.)

このライブラリの関数を利用すると, 端末画面上の任意の位置にカーソルを移動したり, 表示する文字の色を変更したりするプログラム (いわゆるフルスクリーンアプリケーション) が簡単に実現可能になる.

単純な curses プログラムの例を List 0 に示す. これをコンパイルするには,-lncurses オプションを指定し, ライブラリをリンクする必要がある:

$ cc  hello.c  -lncurses  -o hello

実行すると,メッセージが表示され,何かキーを押すと終了する.

List 0. 簡単な curses プログラム hello.c
#include <ncurses.h>

int main()
{
	initscr();	// 端末制御の開始

	start_color();	// カラーの設定
	init_pair(1, COLOR_RED, COLOR_BLUE);	// 色番号1を赤文字/青地とする
	bkgd(COLOR_PAIR(1));			// 色1をデフォルト色とする

	erase();	// 画面表示
	move(10, 20);
	addstr("Hello World");
	refresh();

	timeout(-1);
	getch();	// キー入力

	endwin();	// 端末制御の終了
	return (0);
}

List 0 のソースを読んでみよう. これまでのプログラムとは全然違うものに見えるだろう. とりあえず今は,細かいことについては,気にしないでよい.

まず,おなじみのヘッダファイル stdio.h とか 標準ライブラリの入出力関数 printf( )scanf( ) とかは, まったく使われていない. その代わり,ncurses.h をインクルードし, curses ライブラリの関数だけを使っている.

画面表示やキー入力のために, 標準ライブラリの標準入出力関数(printf() 等)と curses ライブラリの端末入出力関数(addstr( ) 等)とを 混ぜて使わないこと. 一緒に使ってもエラーにはならないが, 多分,期待した結果にはならない. 注意せよ.

ま,curses の入出力関数があれば, 標準入出力関数を使う必要はないので, 心配する必要もない. 端末入出力には,curses の関数だけを使うこと. ただし,ファイルの入出力の場合には, 端末制御とは無関係なので, 標準ライブラリの入出力関数(fprintf( ) 等)のお世話にもなる.

curses の関数についての詳しくは, 参考資料を参照せよ.

次のセクションから, curses ライブラリの使い方を段階的に学習して行く. ある程度まで理解できたら,自由に改造してみよう.

沢山のサンプルソースコードがあり,面倒臭そうだが, 実際にソースを編集し,実行すること. そして,「何をどうして,どうなったのか?」確認すること. さらに,「どんなことが,どうすればできそうか?」推測しよう.

画面への出力

最も単純な curses プログラムを List 1 に示す.

List 1. 基本のプログラム hello1.c
#include <ncurses.h>

int main()
{
	initscr();

	move(10, 20);
	addstr("Hello World");
	getch();

	endwin();
	return (0);
}

これは,端末画面の上から 10 行目,左から 20 桁目の位置に 文字列 "Hello World" を表示するだけのものであり, 何かキーを押すと終了する.

行と列の位置については,0 から数える. つまり,端末的な 10 行目は,世間一般的には 11 行目に相当.

ここで利用されている関数の意味は次の通り:

なお,画面表示の関数としては, addstr() の他に,次のようなものもある:


画面サイズの取得

List 1 を元にして, 文字列を画面の中央に表示するように改良したものが List 2 である.

List 2. センタリング表示 hello2.c
#include <ncurses.h>
#include <string.h>

int main()
{
	int  x, y, w, h;
	char *str = "Hello World";

	initscr();
	getmaxyx(stdscr, h, w);
	y = h/2;
	x = (w - strlen(str))/2;

	move(y, x);
	addstr(str);
	getch();

	endwin();
	return (0);
}

まず,一度,実行してみよう. そして,端末ウィンドウのサイズを変更してから, 再度,実行してみよう. サイズに応じて,表示位置が自動的に調整されるハズだ.

ただし,この単純すぎるプログラムでは, 実行中にサイズを変えても追従しない. 実行前にサイズを変えること.

次の関数が登場した:

ここで,stdscr は端末画面を表わす定数である. 標準出力 stdout の curses 版だと考えればよい.

ところで,Cの基本がわかっている人は疑問に思うハズだ. なぜ,利用方法が getmaxyx(stdscr, &h, &w) ではなく, getmaxyx(stdscr, h, w) なのか?

関数プロトタイプは getmaxyx(stdscr, ポインタ1, ポインタ2) とすべきではないのか? との思いが胸を去来することもあります.

それは,getmaxyx( ) が実は,関数ではなくマクロであるからだ. 次のような感じに引数付きマクロとして定義されている:

#define getmaxyx(画面, y, x) { y=画面の行数; x=画面の桁数; }

ポインタがわからないドシロートさんでもわかるような親切設計? しかし,Cについて生半可な知識があると,かえって混乱してしまいそうだ. 注意せよ. (このマクロは良い設計なのだろうか?良い子はマネしないこと!)


画面の変化

次に,何かキーを押すたびに表示位置を変化するようにしたものが List 3 である. List 2 の大改造になる. [q] キーを押すと終了する.

List 3. 表示位置の変化 hello3.c
#include <ncurses.h>
#include <string.h>

int main()
{
	int  x, y, w, h;
	char *str="Hello World";
	int	key;

	initscr();
//	noecho();		// echo();
//	cbreak();		// nocbreak();
	getmaxyx(stdscr, h, w);
	y = h/2;
	x = (w - strlen(str))/2;

	while (1) {
//		erase();
		move(y, x);
		addstr(str);
//		refresh();

		key = getch();
		if (key == 'q') break;

		y++; if (y >= h) y = 0;
	}
	endwin();
	return (0);
}

無効化(コメント化)されている部分(// 以降)を 有効化しないと(// を外さないと)うまく行かないだろう. まず,コメントのまま実行し,その後,有効化して実行し, 動作を比較してみよう.

次の関数が出てきた:

いくつかの関数は,省略しても変わらない.

ここで,refresh() の意味がよくわからないかもしれない. curses では, 実際の表示用の画面(物理画面,実画面)の他に, 内部的な作業用の画面(論理画面,裏画面)を利用している. 通常,出力の処理は,論理画面だけに対して作用する. つまり,出力しただけでは,実際の画面上には表示されない(場合がある).

表示したい場合には,出力処理の後で,refresh() しよう. これで,論理画面の内容が物理画面へ確実にコピーされ, 実際に見えるようになるハズだ.

つまり,refresh() しないと, 表示したつもりの内容が実際には表示されない可能性がある. ありがちなミスなので注意しよう.

アニメーション

今度は,キー入力しなくても自動的に動くようにしてみよう. List 3 のコピーを作り,List 4 のように変更しよう. (また後で使うので,List 3 については書き換えずに残しておこう.)

List 4. 自動的な変化 hello4.c
#include <ncurses.h>
#include <string.h>
#include <unistd.h>

int main()
{
	int  x, y, w, h;
	char *str="Hello World";
	int  key;

	initscr();
	noecho();
	cbreak();
	timeout(0);

	getmaxyx(stdscr, h, w);
	y = h/2;
	x = (w - strlen(str))/2;

	while (1) {
		erase();
		move(y, x);
		addstr(str);
		refresh();

		key = getch();
		if (key == 'q') break;

		y++; if (y >= h) y = 0;

		usleep(100000);
	}
	endwin();
	return (0);
}

次の関数を追加しただけ:


キーボードによる操作

List 3 では,下方向にしか移動できなかった. 今度は,カーソルキーの入力によって上下左右に移動できるようにする. List 3 を元にして,List 5 のように書き換えよう.

List 5. 上下左右の移動 hello5.c
#include <ncurses.h>
#include <string.h>

int main()
{
	int  x, y, w, h;
	char *str="Hello World";
	int  key;

	initscr();
	noecho();
	cbreak();
	keypad(stdscr, TRUE);

	getmaxyx(stdscr, h, w);
	y = h/2;
	x = (w-strlen(str))/2;

	while (1) {
		erase();
		move(y, x);
		addstr(str);
		refresh();

		key = getch();
		if (key == 'q') break;
		switch (key) {
		case KEY_UP:	y--; break;
		case KEY_DOWN:	y++; break;
		case KEY_LEFT:	x--; break;
		case KEY_RIGHT:	x++; break;
		}
	}
	endwin();
	return (0);
}

新たな関数はひとつだけ:

さてここで, getch( ) の戻り値の型が char ではなく int だったことの理由を明かそう. ASCII コード(1バイトの数値,0x00 〜 0xFF) だけでは表わしきれない程に多数ある特殊キーにも 番号(4バイトの数値,0x00000100 〜 0xFFFFFFFF)が割り当てられている. また,各特殊キーには, わかり易いマクロ名も定義されている:


カラーの利用

今度は,カラーを使ってみる. List 6 をゼロから作成しよう.

List 6. カラー表示 hello6.c
#include <ncurses.h>

int main()
{
	// 端末の準備
	initscr();

	// 色の準備
	start_color();
	init_pair(1, COLOR_RED, COLOR_BLUE);	// 色1 は青地に赤文字
	init_pair(2, COLOR_GREEN, COLOR_BLUE);	// 色2 は青地に緑文字
	init_pair(3, COLOR_YELLOW, COLOR_BLUE);	// 色3 は青地に黄文字
	init_pair(10, COLOR_WHITE, COLOR_BLUE);	// 色10 は青地に白文字
	bkgd(COLOR_PAIR(10));			// 背景は色10

	// 表示
	attrset(COLOR_PAIR(1));			// 色1 を使う
	mvaddstr(5,  5, "Hello World");
	attrset(COLOR_PAIR(2));			// 色2 を使う
	mvaddstr(5, 25, "Hello World");
	attrset(COLOR_PAIR(3));			// 色3 を使う
	mvaddstr(5, 45, "Hello World");

	attrset(COLOR_PAIR(1) | A_BOLD);		// 色&強調表示
	mvaddstr(6,  5, "Hello World");
	attrset(COLOR_PAIR(2) | A_BOLD);
	mvaddstr(6, 25, "Hello World");
	attrset(COLOR_PAIR(3) | A_BOLD);
	mvaddstr(6, 45, "Hello World");

	attrset(COLOR_PAIR(1) | A_REVERSE);		// 色&反転表示
	mvaddstr(7,  5, "Hello World");
	attrset(COLOR_PAIR(2) | A_REVERSE);
	mvaddstr(7, 25, "Hello World");
	attrset(COLOR_PAIR(3) | A_REVERSE);
	mvaddstr(7, 45, "Hello World");

	attrset(COLOR_PAIR(1) | A_REVERSE | A_BOLD);	// 色&反転&強調
	mvaddstr(8,  5, "Hello World");
	attrset(COLOR_PAIR(2) | A_REVERSE | A_BOLD);
	mvaddstr(8, 25, "Hello World");
	attrset(COLOR_PAIR(3) | A_REVERSE | A_BOLD);
	mvaddstr(8, 45, "Hello World");

	// 終了
	getch();

	endwin();
	return (0);
}
ここで,attrset() の引数の数式にある記号「|」は, 論理和(OR)演算子であり, 複数の属性値を1個の引数にまとめるためのものだ.

端末の種類や設定によっては,うまく働かない機能もあるかもしれないが, あまり気にしないでおこう.


練習問題

List 5 のプログラムで左右に移動しているとき, 文字列の一部が画面からハミ出すと表示が乱れてしまう. この対策を施せ.

  1. 画面内からハミ出さないように, 文字列の移動範囲を制限するように改良せよ.
  2. ヒント:画面右側の範囲チェックでは, 文字列の末尾の座標を調べる必要があるだろう.

  3. [上級者用問題]ハミ出した文字が画面の反対側から (右にハミ出ると左から,左にハミ出ると右から) 出てくるように改良せよ.
  4. ヒント:addstr( ) ではなく, addch( ) または mvaddch( ) を利用するとよい. つまり,文字列を一度に出力するのではなく, 一文字ずつ位置を指定しながら出力するとよい. このとき,当然,各文字の出力位置が画面内に収まるようにすること.

範囲のチェックの際,座標を 0 から数えることに注意. たとえば画面の「幅」が w の場合, 画面内の「座標」の範囲は 0wー1 となる. 0 w ではない!!

日本語の利用

全角日本語文字列(というかマルチバイト文字列)も利用できる:

#include <ncurses.h>
#include <locale.h>

int main(...)
{
	setlocale(LC_ALL, "");
	initscr();
	...
	addstr("こんにちWorld");	// 全角・半角の混合文字列
	...
}

コンパイル方法: (ncurses ではなく,ncursesw ライブラリを使用)

$ cc ... -lncursesw

このように,文字列全体を表示するだけなら,とても簡単だ. しかし,全角文字では,2バイト以上で1文字を表現していたり, 画面上で半角2文字分の幅を使ったりするので, 練習問題2のように, 1文字ずつとか1バイトずつに分解して処理することは難しい.

無理ではない.しかし,文字コードに関する高度な知識が必要. したがって,とりあえず, 文字列分解処理が必要な場合には,全角文字列を使わない ってことで妥協しよう. たとえば,今回の練習問題2では, 文字列を日本語に変えると動作保証対象外としてよい.

上級者向け情報: ワイド文字(日本語文字というか英語以外の文字)用のデータ型 wchar_t, マルチバイト列(Cの標準の文字列)とワイド文字列の変換関数 mbtowc(), etc. で検索してみよう.


(c) 2017, yanagawa@kushiro-ct.ac.jp