n十年ぶりにcurses使ったらやっぱいいじゃんっていう話

## はじめに

とあるCUIのアプリケーションを作ってて、100以上あるリストの中から8個を選択して入力するインタフェースが欲しいな、となった。
できればインタラクティブに、サクッと入力したい。どんな方法が一番いいかなぁ。

- 最小の入力と最小の視点移動で最大の効率を(最小の実装で)
- マウスとか矢印キーとかはできれば使いたくないなぁ
- 入力したいものの名前はだいたい覚えてる

入力シーンをイメージしてみる。

- 1個目の先頭の文字を入れる
- 意図した候補が表示される
- それでいい。リターン
- 2個目の入力。先頭の文字を入れる
- 思ってるのと違う候補が表示される
- それじゃない。2文字目、3文字目を入れる
- 意図した候補が表示される
- それだ。リターン
- 8回繰り返し

うーん、cursesかな。

## cursesとは

簡単に言うと、文字やカーソルを自由自在に移動表示するための、テキストをベースとした技術。入力と出力で表示がどんどん上に流れていくのがよくある表示モードだとすると、cursesでは表示が流れずに入力、出力する位置を指定できるような場、機能を提供してくれる。

topコマンドやテキストエディタなんかも同じ技術をベースに表示とか入力を制御しているんじゃないかなぁ、知らんけど。

でも今でもcursesってあるのかなぁ。

>グラフィカルなディスプレイが当たり前の今となっては、「なんでわざわざ？」と疑問に思うかもしれません。確かに文字表示端末は時代遅れな技術ではありますが、ニッチな領域が存在していて、意匠を凝らすことができるため、いまだに価値のあるものとなっています。
(https://docs.python.org/ja/3/howto/curses.html)

ふーん、そうなんだ。で、今回の環境はWindows7+Python3。

Pythonはcursesを標準装備してるはずなのにエラーが出るので調べてみると、


>Windows 版 Python には curses が含まれていません。 UniCurses という名の移植版が利用可能です。Fredrik Lundh の手による the Console module も試してみると良いでしょう。
(https://docs.python.org/ja/3/howto/curses.html)

なんでやねん… 紹介されてる2つの実装もいろいろと不都合があって諦めかけるも、windows-cursesという実装がPyPiに！

https://pypi.org/project/windows-curses/

せんきゅー、PyPi！


## 実装

最終的なコードは以下のとおり。

```python
import curses
import bukisp
import re

yomis = [buki['yomi'] for buki in bukisp.bukis]

def main(stdscr):
    selected = []
    window = curses.initscr()
    for i in range(8):
        key = ''
        input_str = ''
        choices = []
        stdscr.clear()
        stdscr.addstr(0,0,'%d番目のブキ:' % (i+1))
        while key != '\n':
            stdscr.refresh()
            if len(input_str) == 0:
                choices = ['???']
            else:
                choices = [s for s in yomis if re.match(input_str, s)]
            if len(choices) == 0: choices = ['???']
            stdscr.addstr(0,8,choices[0])
            window.clrtobot()
            window.attrset(curses.A_REVERSE)
            stdscr.addstr(0,8,input_str)
            window.attrset(curses.A_NORMAL)
            window.move(0,13+len(input_str))
            key = stdscr.getkey()
            if key == '\x08':
                input_str = input_str[:-1]
            else:
                input_str += key
        selected.append(choices[0])
        continue

if __name__ == '__main__':
    curses.wrapper(main)
```

ちなみに候補リスト(`bukisp.py`)は以下のような構造です。

```python
bukis = [
    {'name':'わかば', 'cate':cates[0], 'sub':subs[1], 'sp':sps[0], 'yomi':'wakaba'},
    {'name':'もみじ', 'cate':cates[0], 'sub':subs[3], 'sp':sps[2], 'yomi':'momiji'},
    # : (150個くらい)
]
```

操作例

```console:初期表示
1番目のブキ: ???
```

```console:先頭の文字(s)を入力
1番目のブキ: susi
```

(実際は入力した「s」のみ反転表示。)

```console:susiでおっけー。リターンを入力
2番目のブキ: ???
```

```console:先頭の文字(m)を入力
2番目のブキ: momiji
```

(実際は入力した「m」のみ反転表示。)

```console:momijiじゃない。2文字め(a)を入力
2番目のブキ: manyu
```

(実際は入力した「ma」のみ反転表示。)

```console:manyuでおっけー。リターンを入力
3番目のブキ: ???
```

8回繰り返すと終了。`selected`リストに選択した`'susi'`などが格納される。だいたいイメージどおりにできた。久々に使ってみたけど、curses便利だな。


## 実装メモ

2時間くらいここら辺 (https://docs.python.org/ja/3/library/curses.html?highlight=curses#module-curses) と格闘したレベル。はっきりいってウィンドウとかスクリーンとか全然理解してません。

```python
import curses

def main(stdscr):
    stdscr.clear()
    stdscr.addstr(0,0,'1番目のブキ:')
    key = stdscr.getkey()

if __name__ == '__main__':
    curses.wrapper(main)
```

これがベース。初期化とかを`wrapper()`がやってくれるのでお約束。`main`に`stdscr`というオブジェクトが渡される。`stdscr.getkey()`でキーボードからの入力待ちをする。そうしないとプログラムが即座に終了して表示も消え、あたかもなにも起きなかったかのような錯覚に陥ってしまう。

上記はキーボードから何か1文字入力すると終了する。

とりあえず入力を8回繰り返すようにしよう。

```python
def main(stdscr):
    for i in range(8):
        stdscr.clear()
        stdscr.addstr(0,0,'%d番目のブキ:' % i)
        key = stdscr.getkey()
```

1文字入力したら次にいっちゃうんで、リターンキーが入力されるまでは連続して入力できるようにしたい。

```python
def main(stdscr):
    for i in range(8):
        key = ''
        input_str = ''
        stdscr.addstr(0,0,'%d番目のブキ:' % (i+1))
        while key != '\n':
            key = stdscr.getkey()
            input_str += key
        continue
```

Win7の場合、上記のように`\n`でリターンキーを指定できるようです。

ただ、リターンキーを打つまでは連続で入力を受け付けてくれるのに、リターンキーを打つと`for`を抜けて終了してしまうので、`continue`を入れてます。

これだけだと実際に入力した文字が表示されないので、`input_str`と入力位置を示すカーソルを`stdscr.addstr()`と`window.move()`で表示する。

```python
    window = curses.initscr()
    :
        while key != '\n':
            stdscr.addstr(0,8,input_str)
            window.move(0,13+len(input_str))
            key = stdscr.getkey()
            input_str += key
```

入力ミスは修正したいので、バックスペースの処理を追加。

```python
        while key != '\n':
            stdscr.addstr(0,8,input_str)
            window.move(0,13+len(input_str))
            key = stdscr.getkey()
            if key == '\x08':
                input_str = input_str[:-1]
            else:
                input_str += key
```

Win7の場合、`^H`も`BACKSPACE`も`\x08`で拾えるみたい。

入力系はだいたいできたので、次に候補表示を実装。もともとのリストは、

```python
bukis = [
    {'name':'わかば', 'cate':cates[0], 'sub':subs[1], 'sp':sps[0]},
    {'name':'もみじ', 'cate':cates[0], 'sub':subs[3], 'sp':sps[2]},
    # : (150個くらい)
]
```

これに、入力に対応するよう、`yomi`というキーを追加。

```python:bukisp.py
bukis = [
    {'name':'わかば', 'cate':cates[0], 'sub':subs[1], 'sp':sps[0], 'yomi':'wakaba'},
    {'name':'もみじ', 'cate':cates[0], 'sub':subs[3], 'sp':sps[2], 'yomi':'momiji'},
    # : (150個くらい)
]
```

プログラムの最初の方で、`yomi`だけ`yomis`リストに詰め込みます。

各回の入力に従って、入力文字列(`input_str`)に先頭マッチする候補だけを`choices`リストに入れ、先頭の候補を入力中のカーソルの位置に表示します。候補表示と入力文字列をわかりやすくするため、`attrset()`で入力中の文字列のみ反転表示します。

```python
import re
import bukisp

yomis = [buki['yomi'] for buki in bukisp.bukis]
:
        while key != '\n':
            stdscr.refresh()
            if len(input_str) == 0:
                choices = ['???']
            else:
                choices = [s for s in yomis if re.match(input_str, s)]
            stdscr.addstr(0,8,choices[0])
            window.clrtobot()
            window.attrset(curses.A_REVERSE)
            stdscr.addstr(0,8,input_str)
            window.attrset(curses.A_NORMAL)
```

以上。