cursesを使ってターミナル上でGUIを作る

## cursesとは
[公式ドキュメント](https://docs.python.org/ja/3/howto/curses.html)によりますと、

`curses ライブラリは、VT100s や Linux コンソール、さまざまなプログラムが提供するエミュレーション端末といったテキストベースの端末（ターミナル）のために、端末に依存しないスクリーン描画や、キーボードの処理を提供します。端末はカーソルの移動や、画面のスクロール、領域の消去といった共通の操作を行うための様々な制御コードをサポートします。端末の種類によって大きく異なる制御コードを使うことがあり、しばしば独特の癖があります。
`

簡単なGUIを作りたいときや、sshでしかアクセスできないサーバのデータを操作する時は便利かもしれません。
CSVとかDBとかの内容を表示することもできます。

## やりたいこと
例えば以下の[csvファイル](https://raw.githubusercontent.com/hrdrq/curses_sample/master/prefectures.csv)があるとします。

| ID | Prefecture | Capital    | Population    | Area        | Density | 
|----|------------|------------|---------------|-------------|---------| 
| 1  | Aichi      | Nagoya     | 70,43,235   | 5,153.81  | 1,366 | 
| 2  | Akita      | Akita      | 11,89,215   | 11,612.11 | 102     | 
| 3  | Aomori     | Aomori     | 14,75,635   | 9,606.26  | 154     | 
| ... | ...      | ...      | ...   | ...  | ... | 
| 45 | Yamagata   | Yamagata   | 12,44,040   | 9,323.34  | 133     | 
| 46 | Yamaguchi  | Yamaguchi  | 15,28,107   | 6,110.76  | 250     | 
| 47 | Yamanashi  | Kofu       | 8,88,170    | 4,465.37  | 199     | 

これをターミナルで内容を表示させて、都道府県を選択して処理できるGUIを作りたいと思います。

## 実装

```python:main.py
# -*- coding: utf-8 -*-
import curses
import csv
from math import ceil

ROWS_PER_PAGE = 20

ENTER = ord( "\n" )
ESC = 27
DOWN = curses.KEY_DOWN
UP = curses.KEY_UP

class UI():

    def __init__(self, header, rows):
        super().__init__()
        self.header = header 
        self.rows = rows
        # 初期化
        self.screen = curses.initscr()
        curses.noecho()
        curses.cbreak()
        curses.start_color()
        self.screen.keypad(1)
        # 色の設定
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
        self.highlight_text = curses.color_pair(1) # 上の行のpair idを使う
        self.normal_text = curses.A_NORMAL
        self.screen.border(0)
        curses.curs_set(0)
        self.rows_per_page = ROWS_PER_PAGE
        self.total_rows = len(self.rows)
        # 各カラムの幅を記録
        self.widths = []
        # ボーダーの描画
        self.tavnit = '|'
        self.separator = '+'

        for index, title in enumerate(self.header):
            # カラムのタイトルと各行の最長値をカラムの幅にする
            max_col_length = max([len(row[index]) for row in self.rows])
            max_col_length = max(max_col_length, len(title))
            self.widths.append(max_col_length)

        # ボーダーの設定
        for w in self.widths:
            # こんな感じになる：
            # | %-2s | %-10s | %-10s | %-11s | %-9s | %-7s |
            self.tavnit += " %-"+"%ss |" % (w,)
            # こんな感じになる：
            # +----+------------+------------+-------------+-----------+---------+
            self.separator += '-'*w + '--+'

        self.total_pages = int(ceil(self.total_rows / self.rows_per_page))
        self.position = 1
        self.page = 1
        # 表示させるメッセージ
        self.msg = 'Page: {}/{}'.format(self.page, self.total_pages)

    def end(self):
        curses.endwin()

    def draw(self):
        self.screen.erase()
        # 一番上にメッセージを表示        
        self.screen.addstr(1, 2, self.msg, self.normal_text)
        # テーブルの上ボーダー
        self.screen.addstr(2, 2, self.separator, self.normal_text)
        # ヘッダーを表示        
        self.screen.addstr(3, 2, self.tavnit % tuple(self.header), self.normal_text)
        # ヘッダーと内容の間のボーダー
        self.screen.addstr(4, 2, self.separator, self.normal_text)
        # 毎行を描画
        row_start = 1 + (self.rows_per_page * (self.page - 1))
        row_end = self.rows_per_page + 1 + (self.rows_per_page * (self.page - 1))
        for i in range(row_start, row_end):
            if i >= self.total_rows + 1:
                break
            row_number = i + (self.rows_per_page * (self.page - 1))
            # ハイライトの行
            if (row_number == self.position + (self.rows_per_page * (self.page - 1))):
                color = self.highlight_text
            else:
                color = self.normal_text
            # 上にメッセージやボーダーなど4行あるので+4
            draw_number = i - (self.rows_per_page * (self.page - 1)) + 4 # 上にメッセージやボーダーなど4行あるので
            self.screen.addstr(draw_number , 2, self.tavnit % tuple(self.rows[i - 1]), color)
        # テーブルの下ボーダー, 上にメッセージやボーダーなど4行あるので+4
        bottom = min(row_end, self.total_rows + 1) - (self.rows_per_page * (self.page - 1)) + 4
        self.screen.addstr(bottom, 2, self.separator, self.normal_text)
        self.screen.refresh()

    def down(self):
        if self.page == self.total_pages:
            if self.position < self.total_rows:
                self.position += 1
        else:
            if self.position < self.rows_per_page + (self.rows_per_page * (self.page - 1)):
                self.position += 1
            else:
                self.page += 1
                self.position = 1 + (self.rows_per_page * (self.page - 1))
                self.msg = 'Page: {}/{}'.format(self.page, self.total_pages)
        self.draw()

    def up(self):
        if self.page == 1:
            if self.position > 1:
                self.position -= 1
        else:
            if self.position > (1 + (self.rows_per_page * (self.page - 1))):
                self.position -= 1
            else:
                self.page -= 1
                self.position = self.rows_per_page + (self.rows_per_page * (self.page - 1))
                self.msg = 'Page: {}/{}'.format(self.page, self.total_pages)
        self.draw()

    def esc(self):
        self.end()

    def enter(self):
        # ここにやりたい処理
        prefecture_id = self.rows[self.position - 1][0]
        prefecture = self.rows[self.position - 1][1]
        self.msg = 'Page: {}/{} ({} {} was selected.)' \
                .format(self.page, self.total_pages, prefecture_id, prefecture)
        self.draw()

    def loop(self):
        # 入力したキーを検知
        key = self.screen.getch()
        while 1:
            if key == ENTER:
                self.enter()
            elif key == ESC:
                self.esc()
                break
            elif key == DOWN:
                self.down()
            elif key == UP:
                self.up()
            key = self.screen.getch()

if __name__ == '__main__':
    with open('prefectures.csv') as f:
        reader = csv.reader(f)
        data = list(reader)
    header = data[0]
    rows = data[1:]
    
    ui = UI(header, rows)
    ui.draw()
    ui.loop()

```

## 実行

```
$ python main.py
```

## 結果
ページの最多行数は20にしました。
`↑`と `↓`でスクロールできます。
`Enter`(return)すると該当する都道府県の情報がメッセージに表示されます。
`ESC`でGUIを終了させます。

![result](https://raw.githubusercontent.com/wiki/hrdrq/curses_sample/images/result.gif)