6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-04

cursesとは

公式ドキュメントによりますと、

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

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

やりたいこと

例えば以下の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を作りたいと思います。

実装

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

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?