Curses なプログラムで Readline を使う方法

Curses を使用したプログラムの中で Readline を利用したい場面というのは多々あると思うのですが、何も考えずに次のようにすると何を入力しても文字が表示されません。

require "curses"
require "readline"

Curses.init_screen

# 入力が端末に印字されない
s = Readline.readline
# でもちゃんと入力はされてます
puts s #=> "abcdefg"

入力がエコーされるととっても嬉しいです!というわけで、次のようにしてみるわけなのですが、これはダメです。

require "curses"
require "readline"

Curses.init_screen

# echo を有効にしたらいいのかな?
Curses.echo

# でもやっぱりエコーしません
Readline.readline

今の今まで解決方法が分からず悩んでおりました。しばらく分からないまま放置しておいたのですが、それもあまりにあまりなので、readline と ncurses を読んで strace して ioctl の man 読んで termios に行き着いてようやく解決しました。解決してしまえば、こんなの基本的な話じゃない!と思える無知がための苦労だったわけですが、私同様に困っている人もこの広い世の中には居ないこともないだろうと思いますので、ここにメモしておきます。これから書くことは正直恥さらし以外の何モノでもないわけですが、記録しておかないと、絶対すぐ忘れちゃうから。

解決方法

まずは正解から。Termios ライブラリが必要なので事前に gem install termios しておいて下さい。

require "rubygems"
require "curses"
require "readline"
require "termios"

Curses.init_screen

# 設定を取得
tio = Termios.getattr(STDIN)
# よろしくエコーして下さい(追記:間違ってました! "&=" じゃなくて "|=")
tio.lflag |= Termios::ECHO
# 設定を即時に反映
Termios.setattr(STDIN, Termios::TCSANOW, tio)

# これでちゃんとエコーされます!
Readline.readline

つまり termios を使って直接エコーするように設定し直してやれば良いわけです。と、さも何かを分かってる風に書いてますが、私は当初「"termios"ってなぁに?」状態でしたので、今回これを解決するために一から勉強しました。このことについては後でまとめます。

なお、上の方法で目的は達成されるわけですが、何でもかんでも入力が不必要にエコーされっぱなしというのも困った話ですから、readline の後にきちんとエコーを無効にしておきましょう。

require "rubygems"
require "curses"
require "readline"
require "termios"

Curses.init_screen

tio = Termios.getattr(STDIN)
tio.lflag |= Termios::ECHO
Termios.setattr(STDIN, Termios::TCSANOW, tio)
Readline.readline

# エコーを無効化
tio.lflag &= ~Termios::ECHO
Termios.setattr(STDIN, Termios::TCSANOW, tio)

解決までの道程

以上の解決方法に至った道程を記録しておきます。

まず出発点は次のような認識となります。

  • readline は入力をちゃんと受け取っているように思える
  • しかもなんかエコーしようとはしてるっぽい気がする
  • でも実際のところエコーはしない

この状況で最初に思い付いたのは、readlineが入力を受け取っていて、かつエコーしようという努力の痕跡が見られるのであれば、じゃあ STDOUT をファイルに置き換えてやって、そのファイルを読み出して出力してやればいいんじゃない?という非常に姑息な手段でした。つまり、以下のようなことをやってみました。

require "curses"
require "readline"

Curses.init_screen

out = File.open("test.out", "w")
out.sync = true
STDOUT.reopen(out)

reader = Thread.new do
  input = File.open("test.out")
  while true do
    str = input.sysread(10) rescue nil
    Curses.addstr str
    Curses.refresh
  end
end

Readline.readline

でもこれでもエコーされません(理由はファイルにしてもやっぱり ioctl で -echo にされてしまうからですよね、今なら分かる!)。そしてどう考えてもこんな邪悪な方法が全うな解決方法につながるわけがありません。

じゃあ、というわけでまず readline のソースを読みました。readline って地味なことを地道にこなすとっても偉いライブラリなんだなぁ、という事が分かってとても勉強になりましたが、しかし解決には至りそうもありませんでした。では次に ncurses のソースを読みました。ざっと見たところ、どうにも ioctl でなにかをやっているんじゃないか、という点に思い至りました。でも私は ioctl がそもそも何だか良く分かりません。とりあえず「実際どうなの?」ということを観察するために次のように strace で振舞いを眺めてみました。

% strace -o ioctl.txt ruby -rcurses -rreadline -e "Curses.init_screen; Readline.readline"
% grep "ioctl(1" ioctl.txt
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=24, ws_col=80, ws_xpixel=0, ws_ypixel=0}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=24, ws_col=80, ws_xpixel=0, ws_ypixel=0}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_STOP or TCSETSW, {B0 opost isig -icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_STOP or TCSETSW, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=24, ws_col=80, ws_xpixel=0, ws_ypixel=0}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost isig -icanon -echo ...}) = 0
ioctl(1, SNDCTL_TMR_STOP or TCSETSW, {B0 opost -isig icanon echo ...}) = 0
ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B0 opost -isig icanon echo ...}) = 0

なんだか echo ってのが有効になったりそうでなかったりするようです。というわけで readline の前に ioctl でエコーを有効にしてしまえば良いように思いました。しかし次のように linux の man ioctl はワケが分かりません。

http://www.linux.or.jp/JM/html/LDP_man-pages/man4/tty_ioctl.4.html

幸いなことに

ioctl を使用すると移植性のないプログラムになる。可能な場合は、 termios(3) に記述されている POSIX インタフェースを使うこと。

と書いてあって出来ることなら termios を使ってよということなので termios の man を読んでみます。

http://www.linux.or.jp/JM/html/LDP_man-pages/man3/termios.3.html

これなら読めるよ!分かるよ!というわけで無事にエコーする設定も見つかりました。これで ruby-termios を探してきて、無事解決に至った、という流れになります。

グダグタ言う前に stty 使えばいいんじゃないの?

これを読んでる方はひょっとして「面倒な事考えずに素直に stty しちゃえば?」とツッコミたくて仕方なく思ったかも知れません。でもそんなこと言っても stty も分かってなかったから仕方ない!

require "curses"
require "readline"

Curses.init_screen

system "stty echo"
Readline.readline
system "stty -echo"

一応 man にリンク。

http://www.linux.or.jp/JM/html/GNU_sh-utils/man1/stty.1.html

このように分かってしまえば、実は readline のエコーに関する話題が大昔に ruby-list で語られていたりすることを発見できたりして、随分とまぁ徒労だったんなぁと思いました。

でも自力で解決できたので万歳!と前向きに生きていこうと思います。

まとめ

まぁ早い話がTTY関係の常識が私にはなかったからこんなにも苦労したわけです。もう少しこういう基礎的な知識を身に付けておかないとマズいなぁ、と思いました。しかしどのみち世の中には分からない事がたくさんあるわけですから、いかにして学ぶのか、ということが大事だと思います。今回は一日でこの辺の知識(おまけにreadlineとncursesがなにをどうやっているのかについても大雑把に理解できました)がざっと入手できたので、とても良い経験になりました。