1
\$\begingroup\$

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).

\$\endgroup\$

0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.