PythonでTUIアプリを作ろう ( その5 ListBoxで一覧表示 )

UrwidのListBoxオブジェクトを使ってみよう。


画面に複数行を一覧表形式で表示する」のは、アプリケーションでは標準的な仕組みですが、Urwidでは、ListBoxがこの機能を担います。今回は、その概要について説明しましょう。


単純なListBoxを作ってみる

まずは、前回も使用したクラスのbodyに、ListBoxを作成していきます。ListBoxクラスの生成は、下記のようになります。

class urwid.ListBox(ListWalker)

ListWalkerは、SimpleListWalkerのように、事前に用意されているクラスを使用するのが簡単ですが、ここでは応用の効くListWalkerを自前で書いてみましょう。と言っても難しい話ではなく、

  • get_focus()
  • set_focus()
  • get_next()
  • get_prev()

の4メソッドを実装したクラスを用意して、適切なデータを引き渡せば良いだけです。

スクリプトを下記に示します。
データは、仮のものを100件ほど渡すこととします(get_items()メソッド)。


#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# s05_01.py(ListBoxを使ってみる)  
#  
import urwid  
  
def get_items():  
    items = []  
    for i in range(100):  
        items.append("テストデータ"+str(i))  
    return items  
  
my_palette = [  
    ('body', 'default', 'default'),  
    ('foot', 'white', 'dark blue'),  
    ('button', 'yellow', 'default'),  
    ('button_focus', 'black', 'yellow'),  
    ('listwalker', 'black', 'light cyan')  
]  
  
def create_mybutton(label, callback, user_data=None):  
    button = urwid.Button(label, on_press=callback)  
    return urwid.AttrMap(button, 'button', 'button_focus')  
  
class ListWalker(urwid.ListWalker):  
    def __init__(self, items):  
        self.focus = 0  
        self.create_page(items)  
  
    def create_page(self, items):  
        self.lines = []  
        for item in items:  
            text = urwid.Text(item)  
            self.lines.append(urwid.AttrMap(text, None, 'listwalker'))  
  
    def get_focus(self):  
        return self.get_at_pos(self.focus)  
  
    def set_focus(self, focus):  
        self.focus = focus  
        self._modified()  
  
    def get_next(self, start):  
        return self.get_at_pos(start + 1)  
  
    def get_prev(self, start):  
        return self.get_at_pos(start - 1)  
  
    def get_at_pos(self, pos):  
        if pos < 0:  
            return None, None  
        if len(self.lines) > pos:  
            return self.lines[pos], pos  
        return None, None  
  
class Application:  
    main_loop = None  
    def __init__(self):  
        pass  
  
    def exit(self, button=None):  
        raise urwid.ExitMainLoop()  
  
    def unhandled_keypress(self, k):  
        '''  
        if self.main_loop.widget.focus_position == 'header' and k == 'down':  
            self.main_loop.widget.focus_position = 'body'  
        elif self.main_loop.widget.focus_position == 'body' and k == 'up':  
            self.main_loop.widget.focus_position = 'header'  
        '''  
        if k in ('q', 'Q'):  
            self.exit()  
        else:  
            return  
        return True  
  
    def doformat(self):  
        walker = ListWalker(get_items())  
        self.listbox = urwid.ListBox(walker)  
  
        btn_q = create_mybutton("Q", self.exit)  
        header = urwid.GridFlow([btn_q], 6, 1, 1, 'left')  
  
        self.footer_text = urwid.Text(u"これはフッター")  
        footer = urwid.AttrWrap(self.footer_text, "foot")  
  
        frame = urwid.Frame(urwid.AttrWrap(self.listbox, 'body'), header=header, footer=footer)  
        frame.focus_position = 'header'  
        return frame  
  
    def run(self):  
        self.main_loop = urwid.MainLoop(self.doformat(), my_palette, unhandled_input=self.unhandled_keypress)  
        self.main_loop.run()  
  
def main():  
    Application().run()  
  
if __name__=="__main__":  
    main()  

では、実際に動かしてみましょう。

python3 s05_01.py  

カーソルがListBoxに移らない....ですね。

これは、headerからbodyへの移動が出来ない状態になっているからです。

    def unhandled_keypress(self, k):  
        '''  
        if self.main_loop.widget.focus_position == 'header' and k == 'down':  
            self.main_loop.widget.focus_position = 'body'  
        elif self.main_loop.widget.focus_position == 'body' and k == 'up':  
            self.main_loop.widget.focus_position = 'header'  
        '''  

この部分のコメントを削除してみてください。見ればわかると思いますが、headerとbody間での移動を記述しています。
再度実行してみましょう。今度は、ListBox部にFocusが移動しました。

しかし、まだ動きがおかしいですね。今回は、header部からdownキーを押すとListBoxの最初のデータには移動できますが、もう一度downキーを押すと最終行に移動してしまいます。
これは、ListBox内部のWidgetであるTextオブジェクトが、選択可能ではないからです。考えてみると、Textの基本は表示機能ですから、Focusが当たるような処理を想定していないわけで、こうなるのは極めて当然なのです。


ListBox内のTextを選択可能にしよう。

Urwidでは、WidgetがSelectbaleでないとFocusを受け取れないという仕様になっています。可能にするためには、選択可能なTextクラスを作成すればよいのです。
それでは、Textのサブクラスを定義しましょう。ここでは、MyTextクラスとしておきます。このクラスでは、selectableメソッドでTrueを返し、入力を受け取れるようkeypressメソッドを追加、選択可能なWidgetとして定義するわけです。

先程のスクリプトに、MyTextクラスを追加します。また、ListBoxに追加するWidgetを、TextからMyTextに変更しましょう。

# MyTextクラスを追加する  
class MyText(urwid.Text):  
    def selectable(self):  
        return True  
  
    def keypress(self, size, key):  
        return key  
  
class ListWalker(urwid.ListWalker):  
    def __init__(self, items):  
        self.focus = 0  
        self.create_page(items)  
  
    def create_page(self,items):  
        self.lines = []  
        for item in items:  
            # MyTextクラスに変更する。  
            #text = urwid.Text(item)  
            text = MyText(item)  
            self.lines.append(urwid.AttrMap(text, None, 'listwalker'))  

これを実行します。

これで、カーソルキーによるデータ選択機能は問題がなくなりました。
しかし、下部にカーソルを移動していくと、画面に表示できなかったデータが、スクロールして出てきてしまいます。
これでもいいのかもしれませんが、TUIでは適切でないインタフェースでしょう。やはり、ここは画面のサイズに適切な行数分だけを表示したいものです。


画面サイズに合わせた表示とデータ選択

それでは、さらに先に進みましょう。
今回のアプリケーションでは、データは100件ほど用意していますが、通常のターミナルは24行が標準です。headerとfooterに1行ほど使用しますから、body部は22行ほどになるわけです。もちろん、決め打ちするのではなく、ターミナルのサイズに従って表示行を決定しないといけません。

画面サイズの取得

画面サイズは、raw_displayのScreenオブジェクトから取得します。

cols, rows = urwid.raw_display.Screen().get_cols_rows()

今回のアプリケーションでは、rowsからheaderとfooterの2行を引いた行数が、bodyの使用できる行数となるわけです。これをスクリプトにくわえます(get_cols_rowsメソッド)。

さらに、データ選択結果の表示を追加します。

ListBox内の行を’enter'キーで選択したときに、取得した内容をfooterに表示してみましょう。select_listというメソッドがそれにあたります。

#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# s05_03.py(ListBox内に表示できるText件数を考慮する)  
#  
import urwid  
  
def get_items():  
    items = []  
    for i in range(100):  
        items.append("テストデータ"+str(i))  
    return items  
  
my_palette = [  
    ('body', 'default', 'default'),  
    ('foot', 'white', 'dark blue'),  
    ('button', 'yellow', 'default'),  
    ('button_focus', 'black', 'yellow'),  
    ('listwalker', 'black', 'light cyan')  
]  
  
def create_mybutton(label, callback, user_data=None):  
    button = urwid.Button(label, on_press=callback)  
    return urwid.AttrMap(button, 'button', 'button_focus')  
  
class MyText(urwid.Text):  
    def selectable(self):  
        return True  
  
    def keypress(self, size, key):  
        if key in ('j', 'J'):  
            return "down"  
        elif key in ('k', 'K'):  
            return "up"  
        return key  
  
class ListWalker(urwid.ListWalker):  
    def __init__(self, items, rows):  
        self.focus = 0  
        self.create_page(items, rows)  
  
    def create_page(self,items, rows):  
        self.lines = []  
        self.is_last = False  
        for i in range(rows):  
            text = MyText(items[i])  
            self.lines.append(urwid.AttrMap(text, None, 'listwalker'))  
  
    def get_focus(self):  
        return self.get_at_pos(self.focus)  
  
    def set_focus(self, focus):  
        self.focus = focus  
        self._modified()  
  
    def get_next(self, start):  
        return self.get_at_pos(start + 1)  
  
    def get_prev(self, start):  
        return self.get_at_pos(start - 1)  
  
    def get_at_pos(self, pos):  
        if pos < 0:  
            return None, None  
        if len(self.lines) > pos:  
            return self.lines[pos], pos  
        return None, None  
  
class Application:  
    main_loop = None  
    def __init__(self):  
        pass  
  
    def exit(self, button=None):  
        raise urwid.ExitMainLoop()  
  
    def get_cols_rows(self):  
        cols, rows = urwid.raw_display.Screen().get_cols_rows()  
        return cols, rows-2  
  
    def select_list(self):  
        focus_widget, idx = self.listbox.get_focus()  
        self.footer_text.set_text("pos:"+str(idx)+" data:"+focus_widget.base_widget.text)  
  
    def unhandled_keypress(self, k):  
        if self.main_loop.widget.focus_position == 'header' and k == 'down':  
            self.main_loop.widget.focus_position = 'body'  
        elif self.main_loop.widget.focus_position == 'body' and k == 'up':  
            self.main_loop.widget.focus_position = 'header'  
  
        if k in ('q', 'Q'):  
            self.exit()  
        elif k == 'enter':  
            self.select_list()  
        else:  
            return  
  
    def doformat(self):  
        cols, rows = self.get_cols_rows()  
        walker = ListWalker(get_items(), rows)  
        self.listbox = urwid.ListBox(walker)  
  
        btn_q = create_mybutton("Q", self.exit)  
        header = urwid.GridFlow([btn_q], 6, 1, 1, 'left')  
  
        self.footer_text = urwid.Text(u"これはフッター")  
        footer = urwid.AttrWrap(self.footer_text, "foot")  
  
        frame = urwid.Frame(urwid.AttrWrap(self.listbox, 'body'), header=header, footer=footer)  
        frame.focus_position = 'header'  
        return frame  
  
    def run(self):  
        urwid.set_encoding('UTF-8')  
        self.main_loop = urwid.MainLoop(self.doformat(), my_palette, unhandled_input=self.unhandled_keypress)  
        self.main_loop.run()  
  
def main():  
    Application().run()  
  
if __name__=="__main__":  
    main()  

では、実際に動かしてみましょう。

python3 s05_03.py  

これで、想定した動きになりました。
矢印キーでListBox内を移動できますし、Enterキーを叩くと、footer部に選択されたデータが表示されています。

しかし、画面上には22行のデータだけが表示されています。実際のデータは100件ほどあるわけですから、表示されないデータがあるのは困りものです。
次回はページング処理を入れて、この部分を追加していきましょう。


ソースコードについて

GitHubに登録しました。今回のコードは、Section05となります。