いものやま。

雑多な知識の寄せ集め

Curses for Ruby。(その5)

昨日はウィンドウの位置と移動について説明した。

今日は文字の入力について。

文字の入力と入力モード

通常、文字の入力はバッファリングされ、Enterキーを押された時点で初めてユーザプログラムにはデータが渡される。
しかし、それではCUIアプリケーションを作るには不都合。
そこで、Cursesでは入力モードが用意されてる。

入力モードには、以下の3つがある:

  • cookedモード
  • cbreakモード
  • rawモード

cookedモード
通常の入力と同じように、バッファリングを行う状態。
Enterキーが押されるまでは、入力されたデータはユーザプログラムに渡ってこない。

cbreakモード
バッファリングを行わない状態。
Curses.#getch()モジュール関数などを呼んだときに、入力された文字は即座にユーザプログラムに渡される。

rawモード
バッファリングを行わず、さらに通常はshellで解釈される特殊な文字(Ctrl+CやCtrl+Zなど)をユーザプログラム側で扱えるようにする。
このモードの場合、Ctrl+Cを押してもプログラムは強制終了しなくなるので、ユーザプログラム側で適切にハンドルする必要がある。

入力モードの切り替え

入力モードを切り替えるには、以下のモジュール関数を使用する:

Curses.#nocbreak()
Curses.#nocrmode()
Curses.#noraw()
入力モードをcookedモードにする。

Curses.#cbreak()
Curses.#crmode()
入力モードをcbreakモードにする。

Curses.#raw()
入力モードをrawモードにする。

入力の取得

ユーザからの入力を得るには、以下のメソッドを使う:

Curses::Window#getch()
ユーザの入力から一文字読み込んで返す。

Curses::Window#getstr()
ユーザの入力から一行読み込んで返す。
(cbreakモードやrawモードでも、Enterキーが押されて一行になるまではブロッキングされる)

関連するメソッドで、以下のようなものもある:

Curses::Window#ungetch(ch)
文字chをバッファの先頭に戻す。
戻せるのは一文字まで。

Curses::Window#inch()
カーソル位置の文字を読み込んで返す。

なお、カーソルキーやファンクションキーの入力を受けたい場合、そのままでは扱えない。
その場合、以下のメソッドを呼び出しておく必要がある:

Curses::Window#keypad(bool)
booltrueの場合、キーパッドが有効になる。
すなわち、カーソルキーやファンクションキーの入力が、Curses::KEY_*(あるいはCurses::Key::*)という定数で返ってくるようになる。
(これらの定数については、RDocなどを参照)

あと、マルチバイト文字には対応していないみたい・・・

エコーバックとカーソルの表示

ユーザからの入力を得るメソッドがウィンドウのインスタンスメソッドになっているのは、入力のエコーバックの関係。
ユーザが文字を入力すると、基本的にはウィンドウのカーソルの位置にエコーバックされる。

エコーバックするかどうかは、以下のモジュール関数で設定することが出来る:

Curses.#echo()
入力のエコーバックを有効にする。

Curses.#noecho()
入力のエコーバックを無効にする。

また、次のモジュール関数を使うことで、カーソルの表示/非表示を切り替えることも出来る:

Curses.#curs_set(visibility)
visibilityが0の場合、カーソルが非表示になる。
visibilityが1の場合、カーソルが表示される。

入力待ちのタイムアウト

getchなどでは、ユーザから入力があるまでプログラムは停止して待つことになる。
この状態をブロッキングと呼ぶ。
一方、プログラムは停止せず、入力がなかった場合にはエラーを返して戻る状態をノンブロッキングと呼ぶ。

ユーザから入力がないときにいつまでも待ち続けてしまうと困る場合、入力待ちのタイムアウトが必要になってくる。
入力待ちのタイムアウトを設定するには、次のメソッドを使う:

Curses::Window#timeout=(val)
ウィンドウの入力待ちについて、タイムアウトを設定する。

  • valが負の値の場合、ブロッキングされる。
  • valが0の場合、ノンブロッキングになる。
  • valが正の値の場合、入力を最大valミリ秒待つようになる。

上記のメソッドの場合、タイムアウトするまでの時間を設定しているので、それよりも前に入力があった場合には即座にユーザプログラムに制御が戻ってくる。
ただ、場合によっては、ユーザからの入力があってもなくても常に一定時間待って、そのあと処理を行いたいということもあるかと思う。
そういった場合には、timeoutライブラリを使うといい。

timeoutライブラリを読み込むと、KernelモジュールにKernel#timeoutメソッドが追加で定義される。
このtimeoutメソッドは、KernelモジュールがObjectクラスにincludeされているので、任意の場所から関数のように呼び出すことが出来る。

Kernel#timeout(sec) {|i| ...}
ブロックの内容を最大sec秒実行する。
secには小数も指定出来る。
指定された時間を越えた場合、TimeoutError例外が発生し、ブロックを抜ける。

例えば、次のようにすることで、常に一定時間待つようにすることが出来る:

input = nil
begin
  timeout(待つ秒数) do
    input = Curses.getch
    sleep
  end
rescue TimeoutError
  # ignore
end

ユーザから入力を受け付けたり、入力待ちのタイムアウトの例として、次のコードを書いてみた:

#====================
# input_dialog.rb
#--------------------
# 文字入力のサンプル
#====================

require 'curses'
require 'timeout'

class Curses::Window
  def touch
    self.move(self.begy, self.begx)
  end

  def touch_noutrefresh
    self.touch
    self.noutrefresh
  end
end

Curses.init_screen
Curses.cbreak

screen = Curses.stdscr

class << screen
  def show_message(message)
    self.setpos(0, 0)
    self.clrtoeol
    self.addstr(message)
  end
end

prompt_message = "Input any string!"
input_dialog = Curses::Window.new(
  4, prompt_message.size + 4,
  (Curses.lines - 4) / 2, (Curses.cols - prompt_message.size - 4) /2)
input_dialog.box('|', '-')
input_dialog.setpos(1, 2)
input_dialog.addstr(prompt_message)

class << input_dialog
  def get_input
    self.setpos(2, 2)
    Curses.echo
    Curses.curs_set(1)

    str = self.getstr
    if (str == "exit") || (str == "quit")
      str = nil
    else
      # clear input string.
      # (and recover box.)
      self.setpos(2, 2)
      self.clrtoeol
      self.box('|', '-')
    end

    Curses.noecho
    Curses.curs_set(0)
    return str
  end
end

# main loop

loop do
  screen.show_message("Input 'exit' or 'quit' to exit.")
  screen.touch_noutrefresh
  input_dialog.touch_noutrefresh
  Curses.doupdate

  input = input_dialog.get_input
  if input.nil?
    break
  end

  screen.show_message("Input 'q' to change string.")
  word_win = Curses::Window.new(
    1, input.size,
    0, (Curses.cols - input.size) / 2)
  word_win.addstr(input)

  line = 0
  speed = 0
  acc = 0.4
  rate = - 0.8
  done = false
  until(done)
    screen.touch_noutrefresh
    word_win.noutrefresh
    Curses.doupdate

    begin
      timeout(0.05) do
        ch = Curses.getch
        if ch == "q"
          done = true
        end
        sleep
      end
    rescue TimeoutError
      # ignore
    end

    line = (line + speed).ceil
    speed += acc
    if (line >= Curses.lines)
      line = Curses.lines - 1
      speed *= rate
    end

    word_win.move(line, word_win.begx)
  end

  word_win.close
end

Curses.close_screen

実行すると、文字列の入力が促される。
このとき、'exit'もしくは'quit'と入力してEnterキーを押すと、プログラムは終了。
それ以外の文字列が入力された場合、その文字列が画面内でバウンドして表示される。
その最中に'q'が入力されると文字列のバウンドはすぐに終わり、新しい文字列の入力が促される。

このコードについて少し説明すると、以下の通り:

まず、42〜63行目でやっているのは、ユーザからの入力を受け取るための特異メソッドの定義。
このとき、

  • ユーザから入力を受けるときは、カーソルを表示し、エコーバックする
  • ユーザの入力が終わったら、カーソル非表示、エコーバックなしにする

としている。

そして、エコーバックがあると、カーソル位置の文字が上書きされてしまう。
そこで、ユーザの入力が終わったら、上書きされた部分をクリアしている。
また、ユーザの入力が長かった場合、ウィンドウの枠が上書きされてしまうことがあるので、枠の修復も行っている。

そして、78〜113行目では、ユーザから入力された文字列を、画面内でバウンドするように描画している。
このとき、描画は一定間隔毎に行う必要があるので、ユーザからの入力があるかないかに関わらず、一定時間入力待ちをするようにしている。
(そうしないと、キーを押しっぱなしにされた場合、描画間隔が短くなってしまう)

今日はここまで!