PythonでTUIアプリを作ろう ( その7 複数画面間の連携について )

Python Urwidで、画面の遷移をサポートしてみよう。


今回は、新たな画面を追加して画面間の遷移処理を組み込んでみましょう。
これまでの画面がデータを一覧表示(List)するものでしたから、次の画面は、選択された1件分を表示する詳細画面(Detail)となります。


画面遷移を行うには

PythonでTUIアプリを作ろう(その3 Python Urwidの基本オブジェクト構造)で、Urwidの基本オブジェクト構造を、概念図にしてみました。

この図にありますように、Urwidにおいて中心にあるオブジェクトは、MainLoopであり、画面を構成するWidgetは、すべてこのMainLoopに紐付けられており、Urwidで複数画面連携するには、このMainLoopオブジェクトを共有する必要があります。

すなわち、ListクラスからDetailクラスに画面遷移する場合は、Listクラスで生成されたMainLoopオブジェクトを、Detailクラスに伝達する必要があるわけです。スクリプトの起動形態を、下記に2つに分けて考えるとよいでしょう。

(1) 初期起動の場合

MainLoopオブジェクトは、まだ存在しない状態(PythonでいえばNone)ですので、MainLoopオブジェクトの生成から開始します。

(2) 他のクラスから起動された場合

伝達されたMainLoopオブジェクトを再利用し、画面生成処理(doFormatメソッド)で生成されたContainer WidgetをMainLoopのwidgetプロパティに設定、新たな画面を表示します。

そこで、各クラスを始動するrunメソッドを、この2つに合うよう拡張してみましょう。
MainLoopオブジェクトを引数に設定し、その内容によってロジックを分割します。

    def run(self, main_loop=None):  
        if main_loop != None:  
            self.main_loop = main_loop  
            self.main_loop.unhandled_input = self.unhandled_keypress  
            self.main_loop.widget = self.doformat()  
        else:  
            self.main_loop = urwid.MainLoop(self.doformat(), my_palette, unhandled_input=self.unhandled_keypress)  
            self.main_loop.run()  

ちなみに、他のクラスを起動するコードは下記となります。この例では、ListクラスからDetailクラスを呼び出しています。

    detail.Detail().run(self.main_loop)  
  

抽象クラス(application)の作成

新しい画面クラスを作成する前に、これらの親クラスを定義しておきます。画面が複数になった場合、共通するメソッドが出てきますから、これらを先に抽象クラスとしてまとめておいたほうが効率的だからです。
ここでは、親クラス名をApplicationクラスとします。先に作成したListクラスや、これから定義するDetailクラスは、Applicationクラスを継承する形となります。先に説明したrunメソッドなど、共通化できるものはこちらにまとめてしまいましょう。
下記にコードを示します。

#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# application.py  
#  
import urwid  
from mywidget import my_palette  
  
class Application:  
    main_loop = None  
    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 unhandled_keypress(self, k):  
        return True  
    def doformat(self):  
        return urwid.widget  
    def display(self):  
        self.main_loop.widget = self.doformat()  
    def start(self, next_class):  
        next_class().run(self.main_loop)  
    def run(self, main_loop=None):  
        if main_loop != None:  
            self.main_loop = main_loop  
            self.main_loop.unhandled_input = self.unhandled_keypress  
            self.display()  
        else:  
            self.main_loop = urwid.MainLoop(self.doformat(), my_palette, unhandled_input=self.unhandled_keypress)  
            self.main_loop.run()  

詳細画面(Detail)の作成

さて、画面遷移を行う手法が明確になったところで、実際の画面を作成していきましょう。

上の画面イメージを作成していきます。新たに使用するWidgetは、表示フィールド、入力フィールドと、それを格納するコンテナです。

(1) 表示フィールド

ListBoxに使用しているTextは、選択可能にするため、Textのサブクラスを定義していますが、Detail画面は単純な表示だけですので、通常のTextクラスを使用します。
ここでは、AttraMapで属性を付加したオブジェクトを生成するcreate_mylabelというメソッドを、mywidget.pyに追加して使用します。

def create_mylabel(label):  
    return urwid.AttrMap(urwid.Text(label, align="left"), "label")  

(2) 入力フィールド

入力用のフィールドは、EditというWidgetで定義します。このWidgetにfocusが移動した場合、文字入力はもちろんですが、矢印キーでの移動、Delキー、Insキーでの文字の削除、追加などが標準でできるようになっています。また、オプションで複数行入力もサポートされています。
これも、属性を付加したオブジェクトを定義しておきます。

def create_myedit(edit_text, align='left', multiline=False):  
    return urwid.AttrMap(urwid.Edit(edit_text=edit_text, align=align, multiline=multiline), 'edit', 'edit_focus')  

(3) コンテナ

垂直方向にWidgetを配置するコンテナには、Pileを使用します。パラメータとして、オブジェクトのリストを渡します。

入力画面の構成は、下記のようなコードになります。

        self.edit_field01 = create_myedit("入力データ その1")  
        self.edit_field02 = create_myedit("入力データ その2")  
        self.edit_note = create_myedit("改行を含む入力データ\n2行目のデータ\n3行目のデータ",multiline=True)  
        pile = [  
            create_mylabel("Field01"),  
            self.edit_field01,  
            create_mylabel("Field02"),  
            self.edit_field02,  
            create_mylabel("Note"),  
            self.edit_note  
        ]  
        body = urwid.Filler(urwid.Pile(pile), valign='top')  
        #body = urwid.Pile(pile)  

注意すべき点は、Pileオブジェクトをそのままセットする(上のコメントになっているbody=の部分)と、下記のようなエラーになってしまいます。

    (maxcol,) = size  
ValueError: too many values to unpack (expected 1)  

これは、FAQにもありますように、

If you want to use a flow widget where a box widget is expected you need to first wrap it with a widget like Filler that will take care of filling the empty space above or below what the flow widget displays.

ということなのです。
Fillerでラップし、スペースを調整しないといけません。これ、最初はわからないんだよね(笑)。


スクリプト

5ファイルになります。listwalker.pyは変更がないので、前回のものをそのまま使ってください。

(1) mywidget.py

新しく使用するWidgetのメソッド、create_myeditcreate_mylabelと、その属性をmy_paletteに追加しています。

#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# mywidget.py  
#  
import urwid  
import time  
  
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'),  
    ('edit', 'white', 'black'),  
    ('edit_focus', 'yellow', 'black'),  
    ('listwalker', 'black', 'light cyan'),  
    ('label', 'light cyan,bold', 'default')  
]  
def create_mybutton(label, callback, user_data=None):  
    button = urwid.Button(label, on_press=callback)  
    return urwid.AttrMap(button, 'button', 'button_focus')  
def create_myedit(edit_text, align='left', multiline=False):  
    return urwid.AttrMap(urwid.Edit(edit_text=edit_text, align=align, multiline=multiline), 'edit', 'edit_focus')  
def create_mylabel(label):  
    return urwid.AttrMap(urwid.Text(label, align="left"), "label")  
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 MyFrame(urwid.Frame):  
    last_time_clicked = None  
    double_click = None  
    def mouse_event(self, size, event, button, col, row, focus):  
        if event == 'mouse press':  
            now = time.time()  
            if (self.last_time_clicked and (now - self.last_time_clicked < 0.5)):  
                if self.double_click:  
                    self.double_click()  
            else:  
                urwid.Frame.mouse_event(self, size, event, button, col, row, focus)  
            self.last_time_clicked = now  
  

(2) detail.py

今回新たに追加した詳細画面クラスです。

#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# detail.py  
#  
import urwid  
  
import application  
import list  
from mywidget import *  
  
class Detail(application.Application):  
    def __init__(self):  
        pass  
    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 in ('r', 'R'):  
            self.return_main()  
        else:  
            return  
        return True  
    def doformat(self):  
        btn_return = create_mybutton("R", self.return_main)  
        btn_q = create_mybutton("Q", self.exit)  
        header = urwid.GridFlow([btn_return, btn_q], 6, 1, 1, 'left')  
  
        self.edit_field01 = create_myedit("入力データ その1")  
        self.edit_field02 = create_myedit("入力データ その2")  
        self.edit_note = create_myedit("改行を含む入力データ\n2行目のデータ\n3行目のデータ",multiline=True)  
        pile = [  
            create_mylabel("Field01"),  
            self.edit_field01,  
            create_mylabel("Field02"),  
            self.edit_field02,  
            create_mylabel("Note"),  
            self.edit_note  
        ]  
        body = urwid.Filler(urwid.Pile(pile), valign='top')  
  
        self.footer_text = urwid.Text(u"これはフッター")  
        footer = urwid.AttrWrap(self.footer_text, "foot")  
  
        frame = MyFrame(urwid.AttrWrap(body, 'body'), header=header, footer=footer)  
        frame.focus_position = 'header'  
        return frame  
    def return_main(self, ignored=None):  
        self.start(list.List)  
def main():  
    Detail().run()  
if __name__=="__main__":  
    main()  

(3) list.py

#!/usr/bin/env python  
# -*- coding: utf-8 -*-  
#  
# list.py  
#  
import urwid  
  
import application  
import detail  
from listwalker import ListWalker  
from mywidget import *  
  
class List(application.Application):  
    from_rec = 1  
    def __init__(self):  
        pass  
    def next_page(self, ignored=None):  
        if not self.last_page():  
            self.from_rec += self.rows  
            self.main_loop.widget = self.doformat()  
    def prior_page(self, ignored=None):  
        if self.from_rec > self.rows:  
            self.from_rec -= self.rows  
            self.main_loop.widget = self.doformat()  
    def first_page(self):  
        return self.from_rec == 1  
    def last_page(self):  
        return self.is_last  
    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 in ('n', 'N'):  
            self.next_page()  
        elif k in ('p', 'P'):  
            self.prior_page()  
        elif k == 'enter':  
            self.start_detail()  
        else:  
            return  
        return True  
    def doformat(self):  
        self.cols, self.rows = self.get_cols_rows()  
        walker = ListWalker(get_items(), self.from_rec, self.rows)  
        self.is_last = walker.last_page()  
        self.listbox = urwid.ListBox(walker)  
  
        if self.last_page():  
            btn_next = urwid.Divider()  
        else:  
            btn_next = create_mybutton("N", self.next_page)  
        if self.first_page():  
            btn_prior = urwid.Divider()  
        else:  
            btn_prior = create_mybutton("P", self.prior_page)  
  
        btn_q = create_mybutton("Q", self.exit)  
        header = urwid.GridFlow([btn_next, btn_prior, btn_q], 6, 1, 1, 'left')  
  
        self.footer_text = urwid.Text(u"これはフッター")  
        footer = urwid.AttrWrap(self.footer_text, "foot")  
  
        frame = MyFrame(urwid.AttrWrap(self.listbox, 'body'), header=header, footer=footer)  
        frame.double_click = self.start_detail  
        frame.focus_position = 'header'  
        return frame  
    def start_detail(self):  
        self.start(detail.Detail)  
def main():  
    List().run()  
if __name__=="__main__":  
    main()  

動作確認

では、実際に動かしてみましょう。application.pymywidget.pylistwalker.pylist.pyDetail.pyの5ファイルを、同一ディレクトリに入れておきます。

python3 list.py  

先に示した画面が表示されたでしょうか。
データをEnterキーで選択、またはマウスでダブルクリックすると、Detailオブジェクトの画面に遷移します。また、Detail画面からは、<R>ボタンでList画面に戻ることが出来ます。

しかし、今回の例は単純に画面が遷移しただけで、データは全く引き継がれていませんね。
実際のアプリケーションでは、一覧画面(List)で選択されたデータの詳細(Detail)が次の画面で表示されねばなりません。次回は、このあたりに対応してみましょう。


ソースコードについて

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