制御シーケンスを投げたり受けたりする(2019-06-06、追記:2019-12-20)
Androidで Sshクライアントを作った 過程で、
制御シーケンスを投げたり実装したりしました。
そのあたりを少しメモしておこうと思います。
ふわっと説明します。
先に、具体例を上げておくと
printf '\033[31mTEST\033[m\n'
こうすると、TESTという「赤い文字」が出力されます。
033というのがESCを表す文字コードで、
文字コード指定するために数字をエスケープ(\)しています。
"man ascii"コマンドがある環境の方は文字コード一覧が見れますが、
033だと8進数。これを16進数で表すと0x1b、10進数なら27番を指定するとESCです。*1
このESCで始まる特殊なコマンド(\033[31m)が制御シーケンスで、
Webで xterm control sequense とか ansi control codes とかで検索すると出てきます。
手元のLubuntuだと "man console_codes" でマニュアルが見れました。
これらを日本語でWeb検索すると、大抵はTeraTermのヘルプマニュアルに行き着くと思います。
Unix/Linuxで使われる制御コードの大本になるのが、
ECMA48やら、VT100やらという規格らしいのです(=ふわっとした理解)。
なので、コマンドの概要が知りたい場合はTeraTermヘルプの「制御シーケンス」の項が、
コマンドの詳細な説明が必要な際は
ECMA48関連のサイトでコマンド一覧をダウンロードしたり、
VT100関連のサイトからそのコマンド名で検索するのが早いと思います。*2
TeraTermはじめ、端末エミュレータで設定する
端末タイプ(環境変数TERM)というのが、どの制御シーケンスに対応するのかという話にも関連していて、
ざっくり言えば「vt100 + その他制御シーケンス = xterm」です。*3
むずかしいはなしはさておき、ESCコマンドを使うことで、
文字の色を変えたり、カーソル位置を変えたり、
ウィンドウのサイズを変えたり、色々できるようになります。
つまり、遊べます。
制御シーケンスの例†
変数のTERM が xtermなんとかになっていれば
(envコマンドで確認して、sh だと "export TERM=xterm" 、cshだと "setenv TERM xterm" で設定できます)
私の使っているLinux系のOSだと ls やら top やらで実行ファイルやディレクトに色がついたり、*4
見出し部分が白黒反転表示されるようになりました。
各コマンドで、カラー対応のものはTERMを見ながら色関連の制御シーケンスを使うかどうか判断しているのだと思います。
例えばこれがTERM=vt100だと、色数が減ったりモノクロになったりする可能性が高いです。
でもモノクロの方が(情報量が減るので)処理は軽くなると思います、たぶん。
この表示、
「ls -la > ls.txt」 みたいにリダイレクションしてファイルに落とした後に表示しても、
色情報は抜け落ちて、テキストだけになると思います。
が、
これを script コマンドや、TeraTermだと「バイナリ」オプション付きで、
とにかく、出力を全てナマのまま拾うと、制御シーケンスもぜんぶ載ってきます。ESCは文字化け(印字できない)かもしれませんが。
ちなみにバックスペースボタンを押した時も(文字削除ではなく)バックスペースとしてログに載りますね。
では最初に戻って、文字の色を変える制御シーケンスの例です。
printf '\033[31mTEST\033[m\n'
- '\033'が制御シーケンス開始の合図です
- '[31m'が命令部分です
- '[ 番号 m' というのが色を指定する命令で(SGR)
31番は「文字を赤に」です。たとえば41番だと「背景を赤に」になります。
- 最後の'\033[m' も同様の命令で、
'[ 番号 m' の数字なしは '[ 0 m' と解釈されます。
つまり「文字属性をリセット」です。
なので、'\033[m' を打ち忘れると、文字が真っ赤なままになってしまいます。
落ち着いて、「printf '\033[m'」して下さい。
使ってみよう!†
色々あるので、興味が有る制御シーケンスを試しに投げてみるのが良いと思いますが、
とりあえず
(以下、見易さのためにスペース入れていますが、実際はスペースなしです)
- '\033[ 番号 m' で表示属性を指定する(SGR:Select Graphic Rendition)
- '\033[ 桁数 D' で左に移動(CUB: Cursor backward)
- '\033[K' でカーソル位置から行末までを削除(EL: Erase Line)
この辺を使うことで、文字の色を変えたりしつつ、
同じ行の文章を時間経過で更新、みたいなことができます。
以下、毎秒表示の変わるshellスクリプトの例です。
適当なファイルに書き込んで、chmod 744 とかして、実行しみて下さい。
#!/bin/sh
# シェルを中断(2=SIGINT、Ctrl+Cとか)した時に文字属性をリセットする
# 必要に応じて他のシグナルもtrapしましょう
trap "printf '\033[m'; exit" 2
printf '==' # 最初の2文字だけ、先に出力
while :; do # 以下、無限ループ。Ctrl+Cで中断します。
SEC_TEXT=`date +%S`
# 3を含むか、3の倍数の時にアオになります
AO_FLAG=0
echo $SEC_TEXT | grep "3" > /dev/null
if [ $? -eq 0 ]; then AO_FLAG=1; fi
AMARI=`expr $SEC_TEXT '%' 3`
if [ $AMARI -eq 0 -a $SEC_TEXT != "00" ]; then AO_FLAG=1; fi
if [ $AO_FLAG -gt 0 ]; then printf '\033[36m'; fi # アオに(blueは見づらいのでcyan)
printf '%s==' $SEC_TEXT
printf '\033[m' # 文字属性をリセット
sleep 1
printf '\033[4D' # 4文字もどる
printf '\033[K' # 行末まで消す
done
これ、書いて動かしてみた後に気がついたんですが(trapの件)
文字の色やカーソル位置を変える場合って、
中断した時に元に戻す処理が必要かもしれないですね。
以下、もっと複雑な感じの例。
使う制御シーケンスを増やしました。
罫線の記号が、色を変えながらくるくる回ります。
#!/bin/sh
# シェルを中断(2=SIGINT、Ctrl+Cとか)した時に
# 文字属性をリセット [m
# 文字のマッピングをリセット (B
# カーソルを表示 [?25h
trap "printf '\033[m\033(B\033[?25h'; exit" 2
# 端末がカラーオプションに対応している前提で、
# カラーテーブルの16から231番をローテーションする
COLOR_FROM16=16
COLOR_TO231=231
COLOR_NOW=$COLOR_FROM16
printf '==X=='
printf '\033[2D' # 2文字もどる
printf '\033[?25l' # カーソルを非表示にする
while :; do
SEC_TEXT=`date +%S`
# 毎秒、4種の文字をローテーションします
SHOW_TEXT=""
if [ `expr $SEC_TEXT '%' 4` -eq 1 ]; then SHOW_TEXT="t";
elif [ `expr $SEC_TEXT '%' 4` -eq 2 ]; then SHOW_TEXT="w";
elif [ `expr $SEC_TEXT '%' 4` -eq 3 ]; then SHOW_TEXT="u";
else SHOW_TEXT="v"
fi
COLOR_NOW=`expr $COLOR_NOW '+' 1`
if [ $COLOR_NOW -gt $COLOR_TO231 ]; then COLOR_NOW=$COLOR_FROM16; fi
printf '\033[38:5:%dm' "$COLOR_NOW" # 文字属性をカラーテーブルで指定する
printf '\033(0' # 文字(SHOW_TEXT=t,w,u,v)をグラフィックマッピングへ切替える
printf '\033[D' # 1文字分カーソルを左へ
printf '%s' $SHOW_TEXT # 文字、というか罫線を書く
printf '\033(B' # 文字を戻す
printf '\033[m' # 文字属性をリセット
# スリープしないほうが面白いけど、リモート接続だと通信量が跳ね上がるので...
# ローカルでやる場合は0.2秒くらい、リモート接続なら1秒くらいでどうぞ
sleep 1
# uleep 200000 # もしusleepがあれば 0.2 秒の例
# perl -e "select(undef,undef,undef,0.2);" # 同じくperlなら
done
ここまでやらないにしても、
カーソル移動して、同じ行で上書きするだけでも、少し表示が楽しくなります。
そもそも前述の、キーボードのバックスペースボタンを押した時の動作も
scriptコマンドとかで見ると「左に戻る、スペースで上書き、左に戻る」
っていう3段階の動作をしているんですよね。
それとやっていることは大して変わりません。
制御シーケンスの例2†
...自分で書いていて、わかりにくいなーと思うので、
別の例で、もうすこしこまかく説明してみます
以下、画面サイズを変える場合の例です。
画面の変更には「ESC [ 8 ; 縦幅 : 横幅 t」という命令を使います。
(16進数で書くと 0x1B 0x5B 0x38 0x3B 縦幅 0x3B 横幅 0x74)
文法としては、
- 「ESC [ なんとか t」がDEC*5の Set Lines per Physical Page (=DECSLPP) 制御で
- DECSLPPの「8」番はサイズの制御
- 「縦幅;横幅」で文字数を指定しています
お使いのターミナルから
(X window のターミナルだとxterm。xtermやらvttermやらをエミュレートする有名なアプリしてTeraTermがあります)
OSやらリモートのサーバやらに画面サイズ変更の制御を投げます。
制御を受けたシステム側で、画面サイズを変更する...というのは、
例えばtopコマンドとか、viコマンドとか、
画面サイズいっぱいに文字列を表示するようなコマンドは
画面の縦横幅にあわせて、ターミナルへ送り込む内容・文字数を調整しています。
そのため、ターミナルから正しい画面サイズを送らないと、
画面表示がはみ出たり崩れたりすることになるわけです。
つまり、
ターミナルのウィンドウの端っこをマウス等でドラッグして画面サイズを変えても表示が崩れないのは、
画面サイズを変えたタイミングで、ターミナルが上記のような制御シーケンスを自動で送って
ターミナル側の大きさを自己申告しているからです。
逆に、
システムからターミナルに対して画面サイズを指示することもあって、
突然ウィンドウの大きさが変わったりして驚くと思います。
(デフォルト値として横幅80文字が指定されたりします)
ターミナルは、基本的には飛んできた文字はぜんぶ画面に表示するわけですが、
制御シーケンスは画面に表示せずに処理される場合がほとんどです。
topやviコマンドを使うと、画面には表示されませんが、たくさんの制御シーケンスをターミナルが受け取っています。
(画面削除(ED:Erase in display)、行削除(EL:Erase in line)、カーソル移動(CUP:Cursor position)など)
この時、
各コマンドは相手のターミナルを環境変数TERMで判別します(するはず)。
たとえばTERM=vt100だったりする時に、
「ターミナルへ文字の色を細かく指定(ESC[38;2;赤;緑;青m)したかったけど、
この制御方法はTERM=vt100だと未対応だから、やっぱり色は変えずに表示しよう」
といった制御シーケンスの使い分けが発生することになります。
制御シーケンスの文法は各命令ごとに決まっていて、
ESC以外のたった一文字で完結する場合や(C1制御文字)
特定の文字を制御の終端とする場合(OSCシーケンスなど)、
それぞれのルールによって書式や長さが異なります。
書式については、まずは使うものだけ調べればいいと思います。
色々やるなら、どんな制御があるのか一通り見てみるのも良いかと。
(たとえばDECSETのsaveとrestore(CSI ? Pm s と CSI ? Pm r)や
ソフトリセット(DECSTR)による初期化とか)
ターミナルが対応していればマウスのクリックイベントを拾うこともできて、
これはCUI上でも動く「テキストベースのWebブラウザ」で使われています。
ちなみに、
日本語の文字コードの設定を間違えたり、バイナリファイルを間違えてcatやtailした時に、
画面が混沌として元に戻らなくなる原因の一つとして、
飛んできた(壊れた)文字列を制御シーケンスと誤って解釈してしまい、
内容が滅茶苦茶な命令によってターミナルの挙動がおかしくなってしまう
といった場合あります。
以上です。
これでCUIだけどCUIっぽくない感じの画面表示に挑戦してみましょう!*6
端末エミュレータ?†
...ここからは特殊な事情、趣味を抱える方に向けて。
制御シーケンスを投げるときではなく、受け取った時にどうするか
つまり、端末エミュレータ的なものを実装する時に考慮しなければならない話です。
何から調べて、どう実装すれば良いのやら、という方へ...
とりあえず、とっかかりとしては以下の3案はいかがでしょうか
- まずは既存のライブラリを探す
- TeraTermのソースコードから、vtterm.c を読む
- 制御シーケンスには触れないようにする
ライブラリは、自分の使うプログラミング言語に合わせて、
いわゆるvt100エミュレータ的なやつを探せば、色々と見つかります。
それらが制御シーケンス部分をいい感じに処理して、標準入出力との橋渡しをしてくれるはずです。
(キーボードの入力をサーバに渡せる形にしたり、
サーバからの応答を画面に出力できる段階まで翻訳してくれたり)
vt100?...俺はvt410が無いと嫌だ!という方は、
エミュレータなど使わず、VT410(実機)を買えばいいと思います。*7
いや、たぶん多くの人はvt100で十分なはずですから...
自力で制御シーケンスを読んだり作ったりしたい場合は、
vtterm.c がとても参考になります。
最終的には ecma-international.org や vt100.net で各コマンドの説明(英語)を見ることでようやく、具体的な仕様が分かってくるわけですが、
それ以前に、実装例と言うか、雰囲気とか、心意気とか、そういうのが無いと、辛いので。*8
...辛いので。*9
「制御シーケンスには触れないように」というのは、
前述のscriptコマンドのようにリアルタイムに出力を拾うのではなく、
リダイレクションなどで一旦ファイルに落としたものを読んで、
(パイプやら、tail -f やら tee -a やら、間に一枚コマンドをはさんで)
制御シーケンスを処理したあとのテキストを、ややリアルタイム気味に読めば良いのでは?
というお話です。
用途によっては、あまり対話式処理にこだわらないほうが良い気がします。*10
ライブラリにせよ自力にせよ、やってみるなら、
実装の対象とする制御コードは、まずは自分が使うやつだけで良いと思います。
最初はカーソル移動と、行(K)と画面(J)の削除があれば十分でしょう。
それよりも先に、バックスペース、CR、LF、Auto-Wrap*11の扱いで戸惑うのではないでしょうか。
それをふまえて、私が順に対応していったのは...
- 自分が使いそうな、簡単な制御シーケンス
- 自分が使うコマンドに出てくるやつ、viで飛ぶ制御シーケンス、とか
- vt100ぜんぶ vt100.net でVT100の範囲とされているもの(VT200とか410とかではなく)
- Linux の console_codes
- xtermぜんぶ xfree86.org/current/ctlseqs.html
- 現在のxtermぜんぶ? invisible-island.net/xterm/ctlseqs/ctlseqs.html
それにともなって、動作確認に使うコマンドも徐々に複雑にすれば良いかと
(くじょうはうけつけません。こころがおれます)
- ls
実装できていないと、表示がズレたり色が反映されなかったりします
- top コマンド
行削除 K やカーソル移動 H、SI/SOの扱いが正しく実装されていないと
みるみるうちに、混沌としてきます
- more/less コマンド
スクロールできるようにしましょう。
バックインデックス?なにそれ?
- viなどのエディタ
挿入、削除を繰り返すだけで表示と心が崩れていきます
- w3mなど、テキストブラウザ
マウスイベント?なにそれ?
...全角文字と特殊文字で、どんどん位置がズレるんだが!?
- vttest
タスケテ!
最初は ls と tail でログが見れれば十分だったんです...
printf で TEST をアカくしていたころが、なつかしい...
...おれは、もう...ここまでだ...あとは...頼む...*12