My task is to change the architecture of a previously written application to Model-View-ViewModel and use the Command pattern. It is written in Python 3.10.6 and uses the tkinter library for the GUI.
From previous research, I noticed that the core concept behind MVVM is binding: automated communication between the View and the bound properties of ViewModel. However, tkinter does not have such a binding technology, unlike pyqt.
Given that my project is huge, I do not wish to re-implement the interface in an MVVM-compatible language/ technology. It is enough for me to mock this binding. For a minimal reproducible example, I started off with ArjanCode's MVC example. I removed the database connection, and tried to convert it to MVVM. It currently looks as follows:
command.py:
from typing import Protocol
class Command(Protocol):
def execute(self) -> None:
...
add_task_command.py:
from command import Command
# to avoid circular import error: https://www.youtube.com/watch?v=B5cjckVzY4g
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from view_model import ViewModel
class AddTaskCommand(Command):
def __init__(self, view_model: 'ViewModel'):
self.view_model = view_model
def execute(self) -> None:
print("Executing add task command")
self.view_model.model.add_task(self.view_model.my_entry)
get_task_commands.py:
from command import Command
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from view_model import ViewModel
class GetTasksCommand(Command):
def __init__(self, view_model: 'ViewModel'):
self.view_model = view_model
self.result = None
def execute(self) -> None:
self.result = self.view_model.model.get_tasks()
model.py:
class Model:
def __init__(self) -> None:
self.tasks: list[str] = ["Ask StackOverflow", "Write Documentation", "Take a Nap"]
def add_task(self, task: str) -> None:
self.tasks.append(task)
def delete_task(self, task: str) -> None:
self.tasks.remove(task)
def get_tasks(self) -> list[str]:
return self.tasks
view.py:
import tkinter as tk
from view_model import ViewModel
class View(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.view_model = ViewModel()
self.title("To Do List")
self.geometry("500x300")
self.create_ui()
self.update_task_list()
def create_ui(self):
self.frame = tk.Frame(self, padx=10, pady=10)
self.frame.pack(fill=tk.BOTH)
self.task_list = tk.Listbox(
self.frame,
height=10
)
self.task_list.bind("<FocusOut>", self.on_focus_out)
self.task_list.bind("<<ListboxSelect>>", self.on_select_task)
self.task_list.pack(fill=tk.X)
self.my_entry = tk.Entry(self.frame, textvariable=self.view_model._my_entry)
self.my_entry.pack(fill=tk.X)
self.my_entry.bind("<Return>", self.on_my_entry_return)
self.del_task_button = tk.Button(
self.frame,
text="Delete",
width=6,
pady=5,
state=tk.DISABLED
)
self.del_task_button.bind("<Button-1>", self.on_del_task_button_click)
self.del_task_button.pack()
def on_my_entry_return(self, event=None) -> None:
self.view_model.my_entry = self.my_entry.get()
self.update_task_list()
self.my_entry.delete(0, "end")
def on_del_task_button_click(self, event=None) -> None:
self.view_model.delete_task()
self.update_task_list()
def on_select_task(self, event=None) -> None:
self.del_task_button.config(state=tk.NORMAL)
self.view_model.selected_task = self.task_list.get(self.task_list.curselection())
def on_focus_out(self, event=None) -> None:
self.task_list.selection_clear(0, tk.END)
self.del_task_button.config(state=tk.DISABLED)
def update_task_list(self) -> None:
self.task_list.delete(0, tk.END)
for item in self.view_model.get_tasks():
self.task_list.insert(tk.END, item)
self.del_task_button.config(state=tk.DISABLED)
self.task_list.yview(tk.END)
view_model.py:
import tkinter as tk
from model import Model
from add_task_command import AddTaskCommand
from delete_task_command import DeleteTaskCommand
from get_tasks_command import GetTasksCommand
class ViewModel:
_selected_task: [tk.StringVar]
_my_entry: tk.StringVar
def __init__(self):
self.model = Model()
self._selected_task = tk.StringVar()
self._my_entry = tk.StringVar()
# self._task_list = tk.StringVar(value=self.model.get_tasks)
self.add_task_command = AddTaskCommand(self)
self.delete_task_command = DeleteTaskCommand(self)
self.get_tasks_command = GetTasksCommand(self)
def delete_task(self, event=None) -> None:
self.delete_task_command.execute()
def get_tasks(self) -> [str]:
self.get_tasks_command.execute()
return self.get_tasks_command.result
@property
def selected_task(self) -> str:
return self._selected_task.get()
@selected_task.setter
def selected_task(self, value: str):
self._selected_task.set(value)
@property
def my_entry(self) -> str:
return self._my_entry.get()
@my_entry.setter
def my_entry(self, value: str):
self._my_entry.set(value)
self.add_task_command.execute()
main.py:
from view import View
def main() -> None:
view = View()
view.mainloop()
if __name__ == "__main__":
main()
Is my approach correct? Does it follow the MVVM pattern? Is there a better way of writing it? And last but not least, how would I mimic the binding for other widgets, like buttons and clickable labels? (for a slider it seems straight forward, since I can bind it with an IntVar).