Table of contents

Introduction

In this post I will showcase a Tkinter project which allows the user to add, edit and remove rows in a Tkinter treeview widget. In this project we will discuss the basics of MVC (Model View Controller) for code organization. We will also discuss how to create and use widgets. We finish with a short recap of our project along with a short discussion on database integration. This project uses SQLite as its database.

MVC

MVC stands for Model View Controller. It is a software architectural pattern designed to allow a program to organize tasks by their function. The model refers to the data, the view refers to the parts of the program the user can see and interact with, and the controller is the central part of the program that allows user interaction in the view to have an effect on the model.

Controller
Controller
Model
Model
View
View
Direct
Direct
Direct / Callbacks
Direct / Callbacks
Indirect
Indirect
Text is not SVG - cannot display
Figure 1: MVC diagram

In this diagram we can visualize how the MVC model works. The controller is the central structure having direct control of both the model and the view. On the other hand the model has zero control over the other parts of the program. This allows it to focus on storing and interacting with the data without needing to know anything about how the rest of the program works. In the case of the view it has indirect access to the controller and the model. This indirect access is allowed through the use of callbacks. These are callable functions which are passed to the view from the controller. This is how the user can interact with the view and indirectly change and update the data held in the model.

Let's take a look at the projects' file structure.

numtree/
| - numtree/
|   | - __init__.py
|   | - application.py
|   | - models.py
|   | - views.py
|   | - widgets.py
| - venv/
| - .gitignore
| - numbers.db
| - numtree.py

We can ignore the venv/ directory and .gitignore file for now, but notice that in the inner numtree/ directory we have the files application.py, models.py and views.py. Though named differently the application.py file represents the controller for this program. With these three python files we have the core of an MVC program.

Notice that we have files both inside and outside of the inner numtree/ directory. Of the outer files numtree.py is the most important. This is because it is used to run the whole program and call the inner files. The code for numtree.py is below:

from numtree.application import Application

if __name__ == '__main__':
    app = Application()
    app.mainloop()

As we can see this is a very short program, however, it both creates the main class in the application.py file and runs the Tkinter program calling the mainloop method.

Let me comment briefly about venv/ and .gitignore. The venv/ directory refers to a virtual environment. By creating a virtual environment we can store and run python packages that only apply to the current project. This is a great strategy for keeping code self contained. The .gitignore file on the other hand refers to version control. We use git to keep track of our files and changes made to them. We can also store git changes online with a service such as GitHub. Using a virtual environment and version control are great ideas even for smaller projects.

Creating a Local Package

In the inner numtree/ directory we have an empty file __init__.py. This is a special file which tells python to treat the directory it is in as a package. Note that this file does not have to be empty, however, this is often the case. This is what allows us to import the Application class as we did in the numtree.py file. By using a local package we are able to separate application runtime behavior from the application itself.

The Controller

As mentioned earlier the application.py file is this program's equivalent to a controller in the MVC diagram. Let's Take a look at the code for this file:

import tkinter as tk
from tkinter import ttk
from . import views as v
from . models import DataModel


class Application(tk.Tk):
    """Controller models and views."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.wm_title("MVC Project")

        self.callbacks = {
            'get_data': self.get_data,
            'insert_row': self.insert_row,
            'edit_row': self.edit_row,
            'remove_row': self.remove_row,
        }

        self.data_model = DataModel()

        self.mainview = v.MainView(self, self.callbacks)
        self.mainview.pack(fill="both", expand=True)

    def get_data(self):
        """Returns data from model connected to database."""

        return self.data_model.data

    def insert_row(self, row):
        # Update database with new row
        self.data_model.insert_row(row)

        # Clear old treeview table
        self.mainview.table.refresh(self.data_model.data)

    def edit_row(self, row):
        # Edit a row in database
        self.data_model.edit_row(row)

        # Clear old treeview table
        self.mainview.table.refresh(self.data_model.data)

    def remove_row(self, row):
        # Remove a selected row from database
        self.data_model.remove_row(row)

        # Clear old treeview table
        self.mainview.table.refresh(self.data_model.data)

For the Application class we inherit the Tk class from the Tkinter package we have aliased as tk. This allows us to build all our Tkinter widgets and classes on top of the Application class. Notice that we have built the MainView class by passing the arguments self and self.callbacks. The structure of the MainView class in our view allows for direct access to the controller with the self argument, however, we choose not to use this functionality in the MainView class and instead rely on the callback attribute we passed to MainView. This ensures we only call methods from the Application that are meant to be called.

Notice that we also initialize the DataModel class from our model. Since the model does not need to know anything about the controller or view we do not pass it any arguments. Instead the model only sends and receives data when requested from the Application class. We can see how our controller interacts with the model by looking at the methods for Application. Notice how each callback in Application has an associated method.

The View

How we interact with and see our program is governed by the view. For a Graphical User Interface (GUI) this is a critical component. So how does out view work and what can we say about it? For this program our view is in the file views.py. This is where we start taking advantage of the ttk submodule of Tkinter.

We build the MainView class on the ttk.Frame themed widget. What is a widget in this context? We can think of a widget as a modular part of the GUI. Widgets can be a single component or contain many components. By allowing MainView to inherit from the ttk.Frame widget MainView is itself a widget and we utilize it to store all other widgets in the view.

What is a themed widget? In Tkinter many widgets have a themed and a regular version. For example we can create a regular Frame widget by instead defining the frame with tk.Frame instead of ttk.Frame, however, in modern Tkinter applications the regular frame is rarely used. This is because we want a way to style widgets on a group level using so called themes. Regular old widgets can only be styled individually which is not ideal in modern graphical design. We will talk a bit more about styles a little later in this section.

Let's take a look at the code below.

import tkinter as tk
from tkinter import ttk
from . widgets import DataTree, EntryBox


class MainView(ttk.Frame):
    """DocString"""

    def __init__(self, master, callbacks, *args, **kwargs):
        """
        Initialize MainView

        :arguments
        ----------
        master : tkinter object
            object that MainView resides in
        callbacks : dictionary
            contains references to callable methods in `master`
        """

        super().__init__(master, *args, **kwargs)

        self.callbacks = callbacks

        self.masthead = ttk.Label(self, text="Number entry table")
        self.masthead.grid(row=0, column=0, columnspan=2)

        self.table_columns = [
            {'id': 'id',     'name': 'ID',     'minwidth': 20, 'width':  40},
            {'id': 'number', 'name': 'Number', 'minwidth': 40, 'width': 160},
            {'id': 'type',   'name': 'Type',   'minwidth': 40, 'width': 100},
            {'id': 'float',  'name': 'Float',  'minwidth': 40, 'width': 100},
        ]

        self.row_data = self.callbacks['get_data']()

        self.table = DataTree(self, columns=self.table_columns, rows=self.row_data)
        self.table.grid(row=1, column=0, padx="10px", pady="10px", sticky='nsew')

        # create and grid a notebook to hold add, edit and delete EntryBox widgets
        self.notebook = ttk.Notebook(self)
        self.notebook.grid(row=1, column=1, padx="10px", pady="10px", sticky='n')

        # the add EntryBox
        self.add_box = EntryBox(
            self.notebook,
            title="Add New Entry",
            labels=['Number', "Type", "Float"],
            receiver=self.callbacks['insert_row'],
        )
        self.add_box.grid(sticky="nsew")
        self.notebook.add(self.add_box, text='Add Row')

        self.edit_box = EntryBox(
            self.notebook,
            title="Edit Entry by ID",
            labels=['ID', 'Number', 'Type', 'Float'],
            receiver=self.callbacks['edit_row'],
        )
        self.edit_box.grid(sticky="nsew")
        self.notebook.add(self.edit_box, text="Edit Row")

        self.removal_box = EntryBox(
            self.notebook,
            title="Remove Entry by ID",
            labels=['ID'],
            receiver=self.callbacks['remove_row'],
        )
        self.removal_box.grid(sticky="nsew")
        self.notebook.add(self.removal_box, text="Remove Row")


        self.grid_columnconfigure(0, weight=1)
        self.grid_columnconfigure(1, weight=0)
        self.grid_rowconfigure(0, weight=0)
        self.grid_rowconfigure(1, weight=1)

        # Instantiate style object and then set styles
        self.styles = ttk.Style()
        self.set_styles()

    def set_styles(self):
        """Method used to apply styling to view objects."""

        # Apply font styles to masthead label
        self.masthead.config(font=("Calibri", 32))

        for col in self.table_columns:
            self.table.treeview.column(col['id'], minwidth=col['minwidth'], width=col['width'])

        # set even and odd row styles for DataTree object table's treeview
        self.table.treeview.tag_configure('even', foreground='blue', background='white')
        self.table.treeview.tag_configure('odd', foreground='red', background='white')

        # map style to all treeview instances
        self.styles.map(
            "Treeview",
            background=[('selected', 'focus', '#005A92')],  # Imperial Blue
            foreground=[('selected', 'focus', '#EAF4FC')],  # Audience Anger
        )

Now that we have had a chance to view the code we will discuss some specific elements. This includes responsive design, callbacks, widgets and styles.

In this project we utilize the grid geometry manager (as opposed to pack or place geometry managers) to control how objects are placed in the view. To get responsive behavior with grid we use the two methods grid_columnconfigure and grid_rowconfigure to set the weight of different rows and columns. These methods control which rows and columns can resize by adjusting the weight. A weight of 0 will not resize while a while a positive weight will proportionately take up space relative to other items with a positive weight. In this program we set the treeview table in the DataTree class to expand based on window size by giving its row and column a weight of 1.

Another important concept that we utilize in our view is a callback. We mentioned earlier that the Application class sends the MainView class a variable with a dictionary of callable methods. This parameter callbacks is stored as an attribute self.callbacks in MainView. To see how the callback works consider the line of code taken from the __init__ method of MainView

self.row_data = self.callbacks['get_data']()

This allows us to call the method in the Application class tied to the dictionary key 'get_data'. From there if we look at the method being called we can see that we are returning a value held by our model. This corresponds to a database created by our model which holds user generated information about numbers. We will see more about this below in our section about the model.

As we mentioned earlier Tkinter widgets can be considered as modular parts of the GUI. In our case each widget is a stand alone class with self contained functionality. This can includes built in classes such as ttk.Frame as well as ttk.Notebook which is used in the view, but can also include user created widgets. In this case our view has two user created widgets. We have DataTree a treeview based widget and EntryBox an entry based widget. We will talk more about user created widgets in their own section.

We will not go in depth on styles, but this is how we give a Tkinter project a little extra flair. In this project we initialize our styles using the ttk.Style object. With this created we run a set_styles method in MainView. Looking at this method we utilize a few different methods of styling. First off we have direct styling of an element with config and column methods:

# Apply font styles to masthead label
self.masthead.config(font=("Calibri", 32))

for col in self.table_columns:
    self.table.treeview.column(col['id'], minwidth=col['minwidth'], width=col['width'])

We also have the tag_configure method which styles a widget based on its tag.

# set even and odd row styles for DataTree object table's treeview
self.table.treeview.tag_configure('even', foreground='blue', background='white')
self.table.treeview.tag_configure('odd', foreground='red', background='white')

And finally we can utilize the styles.map method to apply a style to, in this case, all Treeview widgets:

# map style to all treeview instances
self.styles.map(
    "Treeview",
    background=[('selected', 'focus', '#005A92')],  # Imperial Blue
    foreground=[('selected', 'focus', '#EAF4FC')],  # Audience Anger
)

Each one of these style methods can be useful for giving a project the design you need.

The Model

Having discussed the controller and view we are left with the model. This is a critical component for any project that stores and uses data. For this project my model relies on two packages. We use sqlite3 which allows us to create and utilize a SQLite database. We also use contextlib to utilize its contextmanager function to create the context manager database_connection. This context manager allows us to avoid excess code repetition when opening and closing our database. Below we can see how our context manager is created along with the full code for our model.

import sqlite3
from contextlib import contextmanager


# Create contextmanager for DRY opening and closing of sqlite database
@contextmanager
def database_connection(database_path):
    conn = sqlite3.connect(database_path)  # Create a database or connect to one
    cursor = conn.cursor()  # Create cursor
    yield cursor
    conn.commit()  # Commit Changes
    conn.close()  # Close Connection


class DataModel:
    """Model which contains database data and manages database connection."""

    DATABASE_NAME = 'numbers.db'

    def __init__(self):
        self.data = []
        self.create_table()
        self.query()

    @property
    def data(self):
        return self._data

    @data.setter
    def data(self, new_data):
        self._data = new_data

    def create_table(self):
        """
        This method creates a table in the sqlite database tied to this model.
        If the table already exists the method quietly fails.

        :exception
            sqlite3.OperationalError: Quietly does nothing if table already exists.
        """

        with database_connection(self.DATABASE_NAME) as c:
            try:
                # Create table
                c.execute("""
                    CREATE TABLE if not exists numbers (
                        id INTEGER PRIMARY KEY,
                        number TEXT,
                        type TEXT,
                        float REAL
                    )
                """)
            except sqlite3.OperationalError:
                pass

    def query(self):
        """This method gets all data from our database table."""

        with database_connection(self.DATABASE_NAME) as c:
            # Query
            c.execute("SELECT * FROM numbers")
            self.data = c.fetchall()


    def insert_row(self, new_row):
        """
        Adds new row to database table.

        :argument
            new_row (list): contains new row data with number, type, and float
        """

        with database_connection(self.DATABASE_NAME) as c:
            # Add to table
            c.execute("""
                    INSERT INTO numbers (number, type, float) VALUES
                        (:number, :type, :float)
                """,
                {  # second argument of .execute which passes dictionary values into corresponding :colon place holders
                    'number': new_row[0],
                    'type': new_row[1],
                    'float': new_row[2]
                }
            )

        # Call query method to update stored data to match database table
        self.query()

    def edit_row(self, updated_row):
        """Edit a row based based on ID given new row data.

        :argument
            updated_row (list): contains new row data with id, number, type, and float
        """

        with database_connection(self.DATABASE_NAME) as c:
            c.execute("""UPDATE numbers SET
                number = :number,
                type = :type,
                float = :float

                WHERE id = :id""",
                {
                    'id': updated_row[0],
                    'number': updated_row[1],
                    'type': updated_row[2],
                    'float': updated_row[3],
                }
            )

        # Call query method to update stored data to match database table
        self.query()

    def remove_row(self, selected_row):
        """Delete a row based on row ID."""

        with database_connection(self.DATABASE_NAME) as c:
            c.execute("DELETE FROM numbers WHERE id = :id", {'id': selected_row[0]})

        # Call query method to update stored data to match database table
        self.query()

Having already discussed the context manager the bulk of our code belongs to the DataModel class. This is where we create and access our database.

In our the DataModel class we store the data from the database in the property data. A property is similar to a class attribute with special getter, setter, and deleter methods. The getter method gets the data when called, the setter method replaces the data with new data when called and the deleter method deletes the data. In this project there is no deleter method making it impossible to delete the data property while the program runs.

The primary functionality of our DataModel class is to manipulate the database. Since this is a simple project we only have methods for querying the data, inserting a new row, editing an existing row and deleting a row. We also have a method for creating the database if it does not already exist. Our chosen name for the database is 'numbers.db' which matches our file structure for the project.

Widgets

We have already discussed what a widget is in the previous sections. Widgets are classes which can be used as modular components for building a GUI with Tkinter. For this section we will take a look at the user created widgets for this projects in our file widgets.py. To begin with let's look at the code.

import tkinter as tk
from tkinter import ttk


class AutoScrollbar(ttk.Scrollbar):
    """Class from <https://www.geeksforgeeks.org/autohiding-scrollbars-using-python-tkinter/>
    which creates class AutoScrollbar that automatically hides scrollbars when not needed.
    Does not allow pack or place geometry management. Only allows grid geometry management."""

    # Defining set method with all
    # its parameter
    def set(self, low, high):

        if float(low) <= 0.0 and float(high) >= 1.0:

            # Using grid_remove
            self.tk.call("grid", "remove", self)
        else:
            self.grid()
        ttk.Scrollbar.set(self, low, high)

    # Defining pack method
    def pack(self, **kw):

        # If pack is used it throws an error
        raise (tk.TclError, "pack cannot be used with \
            this widget")

    # Defining place method
    def place(self, **kw):

        # If place is used it throws an error
        raise (tk.TclError, "place cannot be used  with \
            this widget")


class DataTree(ttk.Frame):
    """
    A Treeview that allows for simplified data population.

    Includes an attached scrollbar. Scrollbar is hidden by default when not
    needed.
    """

    def __init__(self, master, columns, rows, *args, **kwargs):
        """
        Initialize DataTree class

        :arguments
        ----------
        master : tkinter object
            object that DataTree resides in
        columns : list
            Each row of the list contains a dictionary which may contain
            the following key values: 'id', 'name', 'minwidth', and 'width'
            with 'id' being required. An example for columns would be
                [
                    {'id': 'name',  'name': 'Name' },
                    {'id': 'age',   'name': 'Age'  },
                    {'id': 'grade', 'name': 'Grade'},
                ]
        rows : list
            list whose values are either a list or tuple with values
            corresponding to the header dictionary for example if the treeview
            has three columns (Name, Age, Grade) our list could be
            something like:
                [
                    ('Bob', 29, 'A'),
                    ('Alice', 24, 'A'),
                    ('John', 25, 'B'),
                ]
        """

        super().__init__(master, *args, **kwargs)

        self._columns = columns
        self._rows = rows

        # create treeview which allows selection of one line
        self.treeview = ttk.Treeview(self, selectmode='browse')

        self.treeview.config(
            columns=tuple([col['id'] for col in self._columns])
        )

        # Settings for the #0 column header
        self.treeview.column('#0', width=0, stretch="NO")
        self.treeview.heading('#0', text='')

        # Settings for the remaining headers
        for col in self._columns:
            if 'minwidth' in col:
                self.treeview.column(col['id'], minwidth=20)
            if 'width' in col:
                self.treeview.column(col['id'], width=100)
            if 'name' in col:
                self.treeview.heading(col['id'], text=col['name'])
            else:
                self.treeview.heading(col['id'], text='')

        self._populate_treeview()

        # Attaches treeview to the grid
        self.treeview.grid(row=0, column=0, sticky='nsew')

        # Creates an AutoScrollbar to right of treeview
        self.horizontal_scrollbar = AutoScrollbar(self, orient='vertical')
        self.horizontal_scrollbar.grid(row=0, column=1, sticky='ns')

        # Configures rows and columns to allow row 0 and column 0 to expand
        self.grid_columnconfigure(0, weight=1)  # weight=1 allows treeview to expand horizontally
        self.grid_columnconfigure(1, weight=0)  # weight=0 blocks expansion
        self.grid_rowconfigure(0, weight=1)  # weight=1 allows treeview to expand vertically

        # activate scrollbar to content in treeview
        self.horizontal_scrollbar['command'] = self.treeview.yview
        self.treeview.configure(
            yscrollcommand=self.horizontal_scrollbar.set
        )

    def _populate_treeview(self):
        """
        Internal method which populates an empty treeview with rows.

        Treeview must be cleared out and empty before running this method.
        Even numbered rows are given the tag 'even' while odd numbered
        rows are given the tag 'odd'.
        """

        for index, value in enumerate(self._rows):
            if index % 2 == 0:
                parity = 'even'
            else:
                parity = 'odd'
            self.treeview.insert(
                parent='',
                index=index,
                iid=index,
                values=value,
                tags=(parity,),
            )

    def refresh(self, rows):
        """
        Refresh treeview with new row data.

        Method for external use. Takes an argument `rows` which is used to
        refresh all data in the treeview.

        :arguments
        ----------
        rows : list
            List containing lists or tuples. For example if the treeview
            has three columns (name, age, grade) our list could be
            something like:
                [
                    ('Bob', 29, 'A'),
                    ('Alice', 24, 'A'),
                    ('John', 25, 'B'),
                ]
        """

        self._rows = rows
        self.treeview.delete(*self.treeview.get_children())
        # Populates treeview rows
        self._populate_treeview()


class EntryBox(ttk.Frame):
    """
    Tkinter frame with entry boxes whose data can be submitted to a receiver.

    This frame contains a title or label at the top. Below this are multiple
    pairs of tkinter Label and Entry widgets. At the bottom is a submit button
    which can submit the data in the entry widgets to a receiver function.
    """

    def __init__(self, master, title, labels, receiver, *args, **kwargs):
        """
        Initialize Entry box class.

        :arguments
        ----------
        master : tkinter object
            Object that EntryBox resides in
        title : string
            Title which displays at the top left of widget
        labels : list
            List of strings which are used for entry labels. The length
            of this list is used to determine the number of entry boxes.
        receiver : function
            External function or method which accepts submitted entry data.
            This data will be in the form of a list.
        """
        super().__init__(master, *args, **kwargs)

        self.title = ttk.Label(self, text=title)
        self.title.grid(row=0, column=0, columnspan=2, sticky="ew")

        self.labels = []
        self.entries = []

        self.receiver = receiver

        for index, entry in enumerate(labels):
            self.labels.append(ttk.Label(self, text=entry))
            self.labels[index].grid(row=index+1, column=0)
            self.entries.append(ttk.Entry(self))
            self.entries[index].grid(row=index+1, column=1)

        submit_btn = ttk.Button(self, text="Submit", command=self.submit)
        submit_btn.grid(row=index+2, column=0, columnspan=2)

    def submit(self):
        """Method called by submit button to send entry data to the receiver."""

        output = [entry.get() for entry in self.entries]

        # Clear Entry boxes on submit
        for entry in self.entries:
            entry.delete(0, tk.END)

        self.receiver(output)

In this file we have three widgets AutoScrollbar, DataTree and EntryBox. Starting off we have AutoScrollbar. The source code for this widget can be found at Geeks for Geeks. This widget modifies the base Scrollbar class to autohide scrollbars when they are not needed. The remaining widgets are custom built for this project.

Of my custom widgets we first have DataTree. This widget allows the user to submit data (in a format described in the docstring) to auto populate a Treeview widget and includes automatic scrollbars from the `AutoScrollbar' class. A benefit of using this widget is to encapsulate the treeview code. By putting the code into a widget we do not have to worry about reformatting if we change its location in the view. The use of a widget also allows for clean and easy code reuse. This depends partly on how project specific our widget is. If we use very general code we may be able to use the widget in many other programs. On the other hand having project specific code will not have many uses outside of the project, but can still be reused inside the project. It is up to the programmer to decide the needed level of abstraction.

The final widget and second custom widget I created for this project is the EntryBox widget. This widget can accept an associated title, along with entry labels and a receiver. The title simple shows up at the top of the Frame class EntryBox inherits from. In the case of entry labels we use a list with strings. If we have multiple list items we will end up with multiple entry boxes. Finally each EntryBox widget has a submit button. For this button to do something with the data in the entry boxes we need a function to gather and use this data. Since we want our widget to be self contained we require the programmer to include the function that will receive the data when instantiating the class; this function is mapped to the receiver variable. Notice that our EntryBox widget is contained in a Notebook widget. In fact, depending on the receiver, we can use our EntryBox widget to add, edit or remove a row.

Based on this discussion we can see how widgets can be very diverse and come from many sources. They can be part of the Tkinter package itself, simple modifications of a class like with AutoScrollbar or contain extra functionality which is specific to the program such as with my custom widgets. Additionally, we can help reduce code redundancy such as we did with EntryBox.

Numtree

So what have we managed to create by using MVC, widgets and database integration? We have a small project which allows a user to add, edit and remove a row from a database. Since our project is so simple the database consists of exactly one table which we can see expressed in DataTree our modified treeview class.

Now before we finish things up let's talk about our data storage just a little more. We have used SQLite for this project due largely to how easy it is to use, but also because we prefer having a local database. This is a key feature of SQLite. Having a local database means we can store our data with the program rather than an exterior location. If we did want to store data with an external database, however, this is entirely possible. In fact since we are utilizing MVC we can do this by modifying just our class DataModel. This is one of the great advantages of using MVC. We only need to adjust the part of the program we are directly interested in.

Conclusion

In this project we discussed what MVC is along with its components the model, view and controller. We also discussed what widgets are and how to apply them to our projects. Finally we were able to put everything together to create a simple number entry table with database support through SQLite.

Number Tree