Introduction

In this post I discuss a Tkinter project which allows the user to group treeview row data together. This will be done by adding a column to the data which determines if a row is part of a group. Grouped data will have an additional row above the group and will share the same background color.

Below we can look at the programs view.

Image 1: TreeGroup with a single group open

Starting on row 5 we have an expanded group for the merchant Super Corp. We can expand or contract a group by click the plus or minus button.

In this project we focus on presentation and do not include methods to modify the treeview data from the program. Our data is simply a list of tuples in our model. The model comes from the M in the MVC software architectural pattern. Take a look at our file structure below.

treegroups/
| - treegroups/
|   | - __init__.py
|   | - application.py
|   | - models.py
|   | - views.py
|   | - widgets.py
| - README.md
| - treegroups.py

The inner treegroups directory acts as a local package which we call from the treegroups.py file.

from treegroups.application import Application

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

The README.md file is a markdown file which holds information about the program. Ours is rather bare.

# Treegroups

Use hierarchical treeview to group rows together.

Once we call the local package in the inner treegroups directory we run a class in the application.py file which acts as the controller or C from MVC. This class controls how the other files work. The exception being our empty file __init__.py which tells python to treat the inner treegroups directory as a local package.

We will display the remaining files of our program in the Code section below. Before we do this we want to discuss the TreeGroup widget which can be found in the widgets.py file.

The TreeGroup Widget

In this project we created a widget called TreeGroup. This widget allows the user to input row and column data along with selecting which column should be used to group data and which columns should be partially hidden in a group. We can see this clearly in the image below

Image 2: TreeGroup open group

This is an example of an expanded group with three elements. The top row is used to group the data together and avoid redundant information in the date and merchant column. This is what we are referring to when we mentioned columns being partially hidden in a group. We can also see that the elements are grouped together with the group tag of 7. While I choose 7 to match with the first element's ID number this is not required. So long as our group elements share the same group value they will be grouped together.

This is a very useful widget. One feature it lacks, however, is the ability to aggregate outlay and inflow column data for each group. This points out one of the downsides of general use widgets. In widget creation we will usually have to choose between specialization or generalization. We also have the choice to create a large number of customizable options, but this can become very disorganized and may have little practical value. I will take these choices into consideration as I continue to enhance this widget in future projects.

Code

In this section we will display the code used for this project. This includes application.py, models.py, views.py and widgets.py.

application.py

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

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

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

        self.wm_title("Tree Groups")

        self.callbacks = {
            'get_data': self.get_data,
        }

        self.datamodel = DataModel()

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

    def get_data(self):
        """Return data from model"""

        return self.datamodel.data

models.py

class DataModel:
    """Model which contains data."""
    def __init__(self):
        self.data = [
            ('2023-05-01', 'Groceries 4 U',  1, '',   'Groceries',        '87.99', '0'),
            ('2023-05-03', 'Gas Mart',       2, '',   'Gas',              '45.00', '0'),
            ('2023-05-08', 'Groceries 4 U',  3, '3',  'Groceries',       '120.55', '0'),
            ('2023-05-08', 'Groceries 4 U',  4, '3',  'Personal Care',    '32.46', '0'),
            ('2023-05-08', 'Groceries 4 U',  5, '3',  'Home Improvement', '11.62', '0'),
            ('2023-05-08', 'Quick Food',     6, '',   'Restaurant',       '21.34', '0'),
            ('2023-05-12', 'Super Corp',     7, '7',  'Income',            '0', '2000'),
            ('2023-05-12', 'Super Corp',     8, '7',  'Taxes',           '400',    '0'),
            ('2023-05-12', 'Super Corp',     9, '7',  '401K',            '200',    '0'),
            ('2023-05-13', 'Side Gig',      10, '10', 'Income',            '0',  '450'),
            ('2023-05-15', 'Groceries 4 U', 11, '',   'Groceries',        '94.43', '0'),
            ('2023-05-16', 'My Books',      12, '',   'Entertainment',    '56.24', '0'),
        ]

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

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

views.py

import tkinter as tk
from tkinter import ttk
from . widgets import TreeGroup


class MainView(ttk.Frame):
    """Main view for application."""

    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

        # columns
        self.tree_columns = [
            {'id': 'date',     'text': 'Date',     'minwidth': 40, 'width': 100},
            {'id': 'merchant', 'text': 'Merchant', 'minwidth': 40, 'width': 160},
            {'id': 'id',       'text': 'ID',       'minwidth': 20, 'width':  40},
            {'id': 'group',    'text': 'Group',    'minwidth': 40, 'width':  80},
            {'id': 'category', 'text': 'Category', 'minwidth': 40, 'width': 160},
            {'id': 'outlay',   'text': 'Outlay',   'minwidth': 40, 'width': 100},
            {'id': 'inflow',   'text': 'Inflow',   'minwidth': 40, 'width': 100},
        ]

        # icon column
        self.tree_icon_column = {'width': 40, 'stretch': 'NO'}

        # rows are (date, merchant, id, group, category, outlay, inflow)
        self.tree_rows = self.callbacks['get_data']()

        self.treegroup = TreeGroup(self, self.tree_columns, self.tree_rows, group_index=3, hidden_columns=[0, 1])
        self.treegroup.pack(fill='both', expand='True', padx=10, pady=10)

        # 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."""

        # set even and odd row styles for DataGroup object table's treeview
        self.treegroup.treeview.tag_configure('even', foreground='#031525', background='#A0bdda')  # ~ Pigeon Post
        self.treegroup.treeview.tag_configure('odd', foreground='#031525', background='#Bcc9d6')  # ~ Heather

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

widgets.py

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 TreeGroup(ttk.Frame):
    """
    A Treeview with hierarchical structure allowing logical data grouping.
    """

    def __init__(self, master, columns, rows, group_index, hidden_columns, *args, **kwargs):
        """
        Initialize TreeGroup class.

        :arguments
        ----------
        master : tkinter object
            object that TreeGroup resides
        columns : list
            Each row of the list contains a dictionary which may contain
            the following key values: 'id', 'text', 'minwidth', and 'width'
            with 'id' being required. An example for columns would be
                [
                    {'id': 'name',  'text': 'Name' },
                    {'id': 'age',   'text': 'Age'  },
                    {'id': 'grade', 'text': 'Grade'},
                ]
        rows : list
            list whose values are either a list or tuple with values
            corresponding to rows in the columns attribute. If the treeview
            has three columns (Name, Age, Grade) our list could be
            something like:
                [
                    ('Bob', 29, 'A'),
                    ('Alice', 24, 'A'),
                    ('John', 25, 'B'),
                ]
        group_index : int
            Integer value corresponding to column containing grouping criterion.
            In the case where we have columns (Name, Age, Grade) we could
            specify group_index = 2 to group by grade. Alternatively we could
            create a new column to group categories by a user selected category.
        hidden_columns : list
            list of integers corresponding to columns which should not appear
            in TreeGroup. For example if we have an ID column from a database
            we may not wish to display it.
        """

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

        self._columns = columns
        self._rows = rows
        self._group_index = group_index
        self._hidden_columns = hidden_columns

        # create treeview with `browse` which allows one selection at a time.
        self.treeview = ttk.Treeview(self, selectmode='browse')

        # use config method to add column id
        self.treeview.config(
            columns=tuple([col['id'] for col in self._columns])
        )

        # Settings for the #0 column header
        self.treeview.column('#0', width=20, 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=col['minwidth'])
            if 'width' in col:
                self.treeview.column(col['id'], width=col['width'])
            if 'text' in col:
                self.treeview.heading(col['id'], text=col['text'])
            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 row groups are given the tag 'even' while odd numbered
        row groups are given the tag 'odd'.
        """

        tracked_groups = {}
        index = 0
        parity_index = 0


        for row in self._rows:
            group = row[self._group_index]

            if parity_index % 2 == 0:
                parity = 'even'
            else:
                parity = 'odd'

            # case when row is in a group
            if group:
                # instantiate a group
                if group not in tracked_groups:
                    tracked_groups[group] = {'index': index, 'tag': parity}
                    self.treeview.insert(
                        parent='',
                        index=index,
                        iid=index,
                        values=[item if idx in self._hidden_columns else '' for idx, item in enumerate(row)],
                        tags=(tracked_groups[group]['tag'],),
                    )
                    index += 1
                    parity_index += 1

                # insert a row as part of group    
                self.treeview.insert(
                    parent=tracked_groups[group]['index'],
                    index=index,
                    iid=index,
                    values=[item if idx not in self._hidden_columns else '' for idx, item in enumerate(row)],
                    tags=(tracked_groups[group]['tag'],),
                )
                index += 1

            # case when row is not in a group
            else:
                # insert row that is not in a group
                self.treeview.insert(
                    parent='',
                    index=index,
                    iid=index,
                    values=row,
                    tags=(parity,),
                )
                index += 1
                parity_index += 1

Conclusion

The goal of this project was to create a modified treeview which allowed for grouping of data. This was accomplished using the TreeGroup class in the widgets.py file. We can see in our first image that we can open and close groups.

One feature we did not add to our groups was aggregation of columns with numerical values. We plan to add aggregation for groups as well as computed columns as we continue to build this widget. One issue with these features is knowing which columns to aggregate and which columns to compute when using a general widget. So future projects will have to decide between specialization and generalization.

Overall I am very happy with this proof of concept for grouping rows together and look forward to expanding the features in future work.

Treegroup with a single group open. Single open TreeGroup group