naritoブログ

このブログはDjangoとBootstrap4で作成されました
ソースコード

Tkinterで、離れたウィジェット間でのメソッド呼び出しをまとめる

プログラミング関連 Python Tkinter 約31日前
2017年10月21日22:02
以下のようなレイアウトを考えてみます。


左側のボタンを押すと、下側のテキストエリアに「左側のボタンが押された」と表示され、
右側のボタンを押すと、「右側のボタンが押された」と表示されるようにしたいとします。

まずはmain.py
import tkinter as tk
import tkinter.ttk as ttk
from left import LeftFrame
from right import RightFrame
from bottom import BottomFrame


class MainFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        """ウィジェットの作成と配置"""
        # 左側、右側、下側にFrame配置
        self.left = LeftFrame(self)
        self.right = RightFrame(self)
        self.bottom = BottomFrame(self)

        # ウィジェットの配置
        self.left.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.right.grid(column=1, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.bottom.grid(
            column=0, row=1, columnspan=2, sticky=(tk.N, tk.S, tk.E, tk.W))

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1, uniform='group1')
        self.columnconfigure(1, weight=1, uniform='group1')
        self.rowconfigure(0, weight=1, uniform='group2')
        self.rowconfigure(0, weight=1, uniform='group2')


if __name__ == '__main__':
    root = tk.Tk()
    app = MainFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




このメインとなるFrameは、3つのFrameを表示しています。
        # 左側、右側、下側にFrame配置
        self.left = LeftFrame(self)
        self.right = RightFrame(self)
        self.bottom = BottomFrame(self)

        # ウィジェットの配置
        self.left.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.right.grid(column=1, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.bottom.grid(
            column=0, row=1, columnspan=2, sticky=(tk.N, tk.S, tk.E, tk.W))



他のFrameを見てみます。まずleft.py
今回だとボタンひとつとシンプルですが、実際は各Frame内も多数のウィジェットを持っており、ごちゃごちゃしているとしましょう。
import tkinter as tk
import tkinter.ttk as ttk


class LeftFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='左側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)


if __name__ == '__main__':
    root = tk.Tk()
    app = LeftFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




right.py
import tkinter as tk
import tkinter.ttk as ttk


class RightFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='右側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)


if __name__ == '__main__':
    root = tk.Tk()
    app = RightFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




そしてbottom.py
import tkinter as tk
import tkinter.ttk as ttk


class BottomFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.text = tk.Text(self)
        self.text.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))


if __name__ == '__main__':
    root = tk.Tk()
    app = BottomFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




それでは、ボタンが押されると下側のTextにメッセージが表示されるようにしましょう。
素直に考えると、以下のように書けます。
left.py
    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='左側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.button.bind('<Button-1>', self.insert_text)

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

    def insert_text(self, event):
        """表示エリアにテキストを挿入する"""
        self.master.bottom.text.insert('end', '左側のボタンです\n')



ボタンクリックで、直接Textに文字を挿入するようにしています。
        self.button.bind('<Button-1>', self.insert_text)
...
...

    def insert_text(self, event):
        """表示エリアにテキストを挿入する"""
        self.master.bottom.text.insert('end', '左側のボタンです\n')



ちゃんと動くようです。



人によってはこの書き方は気持ち悪いと感じるでしょう。文字を挿入するためのメソッドをbottom.pyに追加したほうが良さそうです。
呼び出し側から「\n」を追加するのは面倒だなと思い、呼び出し側からは.insert_text('文字')だけで改行もされるようにもしたとします。
bottom.py
    def insert_text(self, text):
        """文字を挿入する"""
        self.text.insert('end', text + '\n')  # 改行も済ませます



left.pyは、もう少しシンプルになります。
    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='左側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.button.bind('<Button-1>', self.insert_text)

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

    def insert_text(self, event):
        """表示エリアにテキストを挿入する"""
        self.master.bottom.insert_text('左側のボタンです')  # 直感的になった



もしかしたら、lambdaを使いメソッドの定義をしない人もいるかもしれません。
    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='左側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        self.button.bind(
            '<Button-1>',
            lambda event: self.master.bottom.insert_text('左側のボタンです')
        )

        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)



いくつものFrameに分かれたレイアウトを作成する場合、各Frameを別のモジュールに分け、部品化することは珍しくありません。
他からも利用できたり、モジュール単独実行でも動くように作りたい、と思うのは当然です。
しかし、今回のleft.pyはどうでしょうか。



試しにpython left.py として実行し、ボタンを押すとエラーになります。
bottomという属性が見つからないようです。当然です。
Exception in Tkinter callback
Traceback (most recent call last):
  File "/usr/lib/python3.6/tkinter/__init__.py", line 1702, in __call__
    return self.func(*args)
  File "left.py", line 22, in insert_text
    self.master.bottom.insert_text('左側のボタンです')  # 直感的になった
  File "/usr/lib/python3.6/tkinter/__init__.py", line 2098, in __getattr__
    return getattr(self.tk, attr)
AttributeError: '_tkinter.tkapp' object has no attribute 'bottom'




他ウィジェットとの連携する前提の処理であっても、エラーで落ちるのは困ります。
最低限、呼び出されたことを出力するとかしたいものです。
例えば、以下のように解決もできますが....これはちょっと複雑な感じもします。
if __name__ == '__main__':
    root = tk.Tk()
    app = LeftFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    # bottomオブジェクトを作成し、insert_textにprint関数を設定
    from collections import namedtuple
    Bottom = namedtuple('bottom', 'insert_text')
    bottom = Bottom(print)
    root.bottom = bottom
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()



MainFrameにイベントを集める形にすれば、少なくともLeftFrameからはself.master.insert_text と呼び出せます。
root.insert_text = print とすることで、モジュールの単独実行もできるでしょう。
しかし、LeftFrameの中にFrameの類が更にネストしていったらどうでしょうか。


そこで、次のようなモジュールを作成します。
"""各ウィジェットやクラス間でのやり取りを仲介する機能を提供します

このモジュールを利用することで、
self.mastet.master.other_frame....
のようなわずらわしいコードを失くすことができ、グローバルなショートカットとして利用できます。
更にMockMediatorを使うことで、各Frameを単独で実行することも容易にしています。
"""


class MockMediator:
    """テスト、デバッグ用のイベント仲介クラス

    これはモックとして動作します。
    本来他ウィジェットと連携する予定の処理が呼ばれてもエラーにならず、その処理が呼ばれたことを出力します。
    """

    def __getattr__(self, name):
        """呼ばれたメソッド名と引数を出力する."""
        def inner(*args, **kwargs):
            print('Call {0}({1}, {2})'.format(name, args, kwargs))
        return inner


class EventMediator:
    """遠く離れたウィジェット間でのやりとりを仲介するデフォルトのクラス"""

    def __init__(self, left, right, bottom):
        """各フレームの参照を保持する"""
        self.left = left
        self.right = right
        self.bottom = bottom

    def insert_text(self, event=None, text=''):
        """BottomFrameのinsert_textを呼び出す"""
        self.bottom.insert_text(text)


event = MockMediator()


def set_mediator(*widgets, default_mediator_cls=EventMediator):
    """メディエーターを設定する."""
    event = default_mediator_cls(*widgets)
    globals()['event'] = event





main.pyを少し修正し...
...
...
import mediator  # 足した
...
...
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()
        self.set_mediator()  # 足した

    def set_mediator(self):  # 足した
        """イベント仲介オブジェクトの設定"""
        mediator.set_mediator(self.left, self.right, self.bottom)



left.py。bindでの処理がかわりました。
import tkinter as tk
import tkinter.ttk as ttk
import mediator  # 足した


class LeftFrame(ttk.Frame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.create_widgets()

    def create_widgets(self):
        """ウィジェットの作成と配置"""
        self.button = ttk.Button(self, text='左側のボタン')
        self.button.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        # ここ
        self.button.bind(
            '<Button-1>',
            lambda event: mediator.event.insert_text(text='左側のボタンです')
        )
        # ウィジェットの引き伸ばし設定
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)


if __name__ == '__main__':
    root = tk.Tk()
    app = LeftFrame(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)
    root.mainloop()




モジュールを単独実行しても、エラーにならず以下のように出力されました。
Call insert_text((), {'text': '左側のボタンです'})



仕組みとしては非常にシンプルで、mediator.event.spam()のように呼び出すと、モジュールを単独で実行した場合はspamの部分を出力し、そうでない場合はEventMediatorのspam()が呼び出されます。

モジュールのドックストリングの通り、モジュールの単独実行時はモックとして動作し、それ以外の場合でも動作します。他ウィジェットの処理を呼び出すためのコードもシンプルになりますし、if __name__ == '__main__'部分も複雑になりません。
各クラスがアクセサメソッドだらけになることもなく、間にはEventMediatorのメソッドが一つだけです。


self.mastet.master.other_frame....
のようなわずらわしいコードを失くすことができ、グローバルなショートカットとして利用できます。
更にMockMediatorを使うことで、各Frameを単独で実行することも容易にしています。




注意点ですが、モジュールでの単独実行時、LeftFrameの中に更に複雑なレイアウトがネストするような場合で、LeftFrameから子にアクセスしたい場合...
具体例を出すと、LeftFrameがttk.NoteBookで、各タブにtk.TextやそれをラップしたFrameがあるような場合で、NoteBookから各タブにアクセスしたいといったときは直接self.text_frame...のようにアクセスするのが無難です。
LeftFrameの作成時に子も一緒に作成されているのでアクセスできますし、処理としても分かりやすいでしょう。
ざっくりした指針としては、縦の関係で繋がっていないウィジェットや、子から親へのアクセスにはmediator.eventでアクセスをすることを心がけると良いはずです。


今回作成したモジュールは、ウィジェットの単独実行や遠く離れたウィジェット間のやり取りを簡単にしますが、銀の弾丸にならないでしょう。
場合によって臨機応変に対応していきたいですね。