I am working on a relatively simple Tkinter GUI application which reads data from a file and then displays it. One of the puzzles I am currently faces concerns communicating between background tasks which handle long running IO calls and the main thread on which Tkinter and the rest of the code is running. Specifically, I would like to set the value of one of my model class attributes to the data read from file in another thread and then update the GUI after the model has been updated with this data.
The Threads or Processes section of TkDocs seems to recommended passing the Tk instance to the worker thread and emitting a virtual event from the thread which can then be bound to a widget running off the main thread. However, I am unsure how to integrate this with the MVP pattern, since it seems to make the view/Tkinter instance responsible for some of the communication between the model and the view (see comments in reprex below).
Is there a better approach to this problem? Am I thinking about this in the wrong way?
Minimal reprex:
view.py
import tkinter as tk
import tkinter.ttk as ttk
from tkinter.messagebox import showinfo
from typing import Protocol
class Presenter(Protocol):
def handle_bkg_task(self):
...
def update_label(self):
...
class View:
def __init__(self):
self.presenter: Presenter
self.root = tk.Tk()
self.root.grid_columnconfigure(0, weight=1)
self.root.grid_rowconfigure(0, weight=1)
self.mainframe = ttk.Frame(self.root)
self.mainframe.grid(column=0, row=0, sticky="ew")
self.mainframe.grid_columnconfigure(0, weight=1)
self.label = ttk.Label(self.mainframe, text="Hello world!", justify="center", anchor="center")
self.mainframe.grid(column=0, row=0)
self.label.grid(column=0, row=0)
def create_ui(self):
"""Binds on_io_complete to the virtual event generated by presenter.fake_io"""
self.btn = ttk.Button(self.mainframe, text="Start background task", command=self.presenter.handle_bkg_task)
self.btn.grid(column=0, row=1)
self.root.bind("<<IOComplete>>", self.on_io_complete)
def disable_btn(self) -> None:
self.btn.state(['disabled'])
def enable_btn(self) -> None:
self.btn.state(['!disabled'])
def on_io_complete(self, event=None) -> None:
"""Callback executed when <<IOComplete>> event emitted from secondary thread."""
showinfo(title="Info", message="Background task complete!")
self.presenter.update_label()
tkinter-threading.py
from tkinter import Tk
from typing import Callable
from threading import Thread
from view import View
from time import sleep
class Model:
def __init__(self):
# I want to set this attribute with data returned from some io method,
# which may be slow and then use this attribute to update the GUI.
self.data: list
def __repr__(self):
return f"MyModel(self.date)"
class Presenter:
def __init__(self, view: View, model: Model):
self.view = view
self.model = model
def run(self) -> None:
self.view.root.mainloop()
def fake_io(self, tcl_interpreter: Tk) -> None:
"""Pretend this is an IO function which quickly calls C code, avoiding
limitations of the GIL."""
sleep(5)
res = [*range(1, 11)]
sleep(5)
self.model.data = res
# As recommended in TkDocs, generate a virtual event which is bound to a widget
# running in main thread.
tcl_interpreter.event_generate("<<IOComplete>>")
def handle_bkg_task(self) -> None:
"""Bound to the button widget in view.py"""
self.view.disable_btn()
self.run_in_worker_thread(self.fake_io, self.view.root)
def run_in_worker_thread(self, task: Callable, *args, **kwargs) -> None:
"""
This seems hard to generalize to arbitary functions/callables with the
current approach. Each background task must emit some event bound to a widget
running in the main thread.
Also, this seems to make the View responsable for managing some of the communication
between the model and the view.
"""
worker_thread = Thread(target=task, args=args, kwargs=kwargs)
worker_thread.start()
if __name__ == "__main__":
view = View()
model = Model()
presenter = Presenter(view, model)
view.presenter = presenter
view.create_ui()
presenter.run()