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.
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
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.
Add a new comment