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_myedit、create_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.py、mywidget.py、listwalker.py、list.py、Detail.pyの5ファイルを、同一ディレクトリに入れておきます。
python3 list.py
先に示した画面が表示されたでしょうか。
データをEnterキーで選択、またはマウスでダブルクリックすると、Detailオブジェクトの画面に遷移します。また、Detail画面からは、<R>ボタンでList画面に戻ることが出来ます。
しかし、今回の例は単純に画面が遷移しただけで、データは全く引き継がれていませんね。
実際のアプリケーションでは、一覧画面(List)で選択されたデータの詳細(Detail)が次の画面で表示されねばなりません。次回は、このあたりに対応してみましょう。